Compare commits
3 Commits
608bff791d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 18db389beb | |||
| 6f07849a53 | |||
| 56db1cc642 |
@@ -0,0 +1,46 @@
|
||||
# Home-Vue-Go v1.0.0
|
||||
|
||||
Home-Vue-Go 的首个正式版本,将 Vue 3 前端与 Go 后端整合为可直接部署的单文件应用。
|
||||
|
||||
## 主要功能
|
||||
|
||||
- Vue 3 + Vite 前端,提供主页、登录页和可视化管理后台。
|
||||
- Go + Gin API,使用 Ent ORM 与 SQLite 存储站点配置和业务数据。
|
||||
- 支持站点、联系方式、轮换文本和站点外观配置管理。
|
||||
- 支持图片上传、JWT 登录认证、密码修改和登录历史记录。
|
||||
- 支持本地访问统计以及 Umami 自托管、Umami Cloud 数据源。
|
||||
- 前端资源嵌入可执行文件,无需单独部署 Web 静态目录。
|
||||
|
||||
## 单端口部署
|
||||
|
||||
前端页面、API 和上传文件统一通过一个 HTTP 端口提供服务:
|
||||
|
||||
- 默认访问地址:`http://localhost:1552`
|
||||
- API 地址:`http://localhost:1552/api`
|
||||
- 管理后台:`http://localhost:1552/admin`
|
||||
- 可通过 `PORT` 环境变量修改监听端口。
|
||||
|
||||
## 下载与运行
|
||||
|
||||
请根据系统下载对应安装包:
|
||||
|
||||
- `Home-Vue-Go_Windows.zip`
|
||||
- `Home-Vue-Go_Linux.tar.gz`
|
||||
- `Home-Vue-Go_macOS.tar.gz`
|
||||
|
||||
解压后直接运行 `Home-Vue-Go`;Windows 运行 `Home-Vue-Go.exe`。Linux 或 macOS 首次运行前可能需要增加执行权限:
|
||||
|
||||
```bash
|
||||
chmod +x Home-Vue-Go
|
||||
./Home-Vue-Go
|
||||
```
|
||||
|
||||
首次启动会在程序同级目录创建 `data` 数据目录。默认管理员账号为 `admin`,默认密码为 `admin123`,登录后请立即修改密码。
|
||||
|
||||
## 升级与数据
|
||||
|
||||
升级前请备份程序同级目录中的 `data` 文件夹。替换可执行文件即可升级,请勿删除原有的数据库和上传文件。
|
||||
|
||||
## 构建产物
|
||||
|
||||
本版本通过 GitHub Actions 自动测试并构建 Windows、Linux 和 macOS amd64 安装包。所有安装包均包含 README 和与原仓库一致的 MIT 许可证。
|
||||
@@ -0,0 +1,134 @@
|
||||
name: Build Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
- name: Run frontend tests
|
||||
run: npm test -- --run
|
||||
- name: Build frontend assets
|
||||
run: npm run build
|
||||
- name: Run Go tests
|
||||
run: go test -buildvcs=false ./...
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.name }} package
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Windows
|
||||
platform: Windows
|
||||
goos: windows
|
||||
extension: .exe
|
||||
archive: zip
|
||||
- name: Linux
|
||||
platform: Linux
|
||||
goos: linux
|
||||
extension: ''
|
||||
archive: tar.gz
|
||||
- name: macOS
|
||||
platform: macOS
|
||||
goos: darwin
|
||||
extension: ''
|
||||
archive: tar.gz
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
- name: Build embedded frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
- name: Build binary and package
|
||||
shell: bash
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: amd64
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_name="Home-Vue-Go_${{ matrix.platform }}"
|
||||
binary_name="Home-Vue-Go${{ matrix.extension }}"
|
||||
go build -trimpath -buildvcs=false -ldflags="-s -w" -o "${binary_name}" .
|
||||
mkdir package
|
||||
cp "${binary_name}" README.md LICENSE package/
|
||||
if [ "${{ matrix.archive }}" = "zip" ]; then
|
||||
(cd package && zip -q -r "../${package_name}.zip" .)
|
||||
else
|
||||
tar -czf "${package_name}.tar.gz" -C package .
|
||||
fi
|
||||
- name: Upload package artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Home-Vue-Go_${{ matrix.platform }}
|
||||
path: Home-Vue-Go_${{ matrix.platform }}.${{ matrix.archive }}
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Select release notes
|
||||
id: notes
|
||||
shell: bash
|
||||
run: |
|
||||
notes_file=".github/release-notes/${GITHUB_REF_NAME}.md"
|
||||
if [ -f "${notes_file}" ]; then
|
||||
echo "path=${notes_file}" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "path=" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
- name: Download package artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: Home-Vue-Go_*
|
||||
path: release
|
||||
merge-multiple: true
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: Home-Vue-Go ${{ github.ref_name }}
|
||||
body_path: ${{ steps.notes.outputs.path }}
|
||||
generate_release_notes: ${{ steps.notes.outputs.path == '' }}
|
||||
files: release/*
|
||||
@@ -1,8 +1,6 @@
|
||||
# 构建说明
|
||||
|
||||
本项目支持打包为**单一可执行文件**,包含前后端,启动后同时提供:
|
||||
- **1551端口**:后端API服务
|
||||
- **1552端口**:前端界面服务(自动代理API请求到1551)
|
||||
本项目支持打包为**单一可执行文件**,包含前后端。启动后通过一个端口同时提供前端、API和上传文件,默认端口为 **1552**。
|
||||
|
||||
所有前端文件已嵌入到二进制文件中,无需额外文件。
|
||||
|
||||
@@ -123,18 +121,13 @@ cd dist
|
||||
|
||||
## 访问地址
|
||||
|
||||
启动后,服务器会同时提供两个服务:
|
||||
启动后,服务器通过统一端点提供完整服务:
|
||||
|
||||
- **后端API**: http://localhost:1551
|
||||
- API接口:http://localhost:1551/api
|
||||
- 管理接口:http://localhost:1551/api/admin
|
||||
|
||||
- **前端界面**: http://localhost:1552
|
||||
- **访问端点**: http://localhost:1552
|
||||
- 主页:http://localhost:1552
|
||||
- 管理界面:http://localhost:1552/admin
|
||||
- 登录页面:http://localhost:1552/login
|
||||
|
||||
**注意**:前端会自动将 `/api` 请求代理到 `http://localhost:1551`,无需额外配置。
|
||||
- API接口:http://localhost:1552/api
|
||||
|
||||
## 1Panel 配置
|
||||
|
||||
@@ -142,9 +135,7 @@ cd dist
|
||||
|
||||
1. **上传文件**:将 `home-vue-go`(Linux版本)上传到服务器
|
||||
|
||||
2. **配置端口**:
|
||||
- 后端API端口:`1551`
|
||||
- 前端服务端口:`1552`
|
||||
2. **配置端口**:只需放行统一服务端口 `1552`
|
||||
|
||||
3. **运行命令**:
|
||||
```bash
|
||||
@@ -155,8 +146,7 @@ cd dist
|
||||
|
||||
## 配置说明
|
||||
|
||||
- **后端API端口**:默认 `1551`,可通过环境变量 `API_PORT` 修改
|
||||
- **前端服务端口**:默认 `1552`,可通过环境变量 `FRONTEND_PORT` 修改
|
||||
- **统一服务端口**:默认 `1552`,可通过环境变量 `PORT` 修改
|
||||
- **数据目录**:运行时会自动在二进制文件同目录下创建 `data` 目录
|
||||
- **默认管理员账号**:`admin` / `admin123`(首次启动时显示)
|
||||
|
||||
@@ -164,15 +154,13 @@ cd dist
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
set API_PORT=8080
|
||||
set FRONTEND_PORT=8081
|
||||
set PORT=8080
|
||||
home-vue-go.exe
|
||||
```
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
export API_PORT=8080
|
||||
export FRONTEND_PORT=8081
|
||||
export PORT=8080
|
||||
./home-vue-go
|
||||
```
|
||||
|
||||
@@ -181,7 +169,7 @@ export FRONTEND_PORT=8081
|
||||
1. **SQLite驱动**:使用纯 Go SQLite 驱动,构建无需 CGO 或 GCC
|
||||
2. **前端构建**:构建时必须先运行 `npm run build` 生成dist目录,Go编译时会嵌入这些文件
|
||||
3. **Go版本**:需要Go 1.23或更高版本(支持embed功能)
|
||||
4. **端口占用**:确保1551和1552端口未被占用
|
||||
4. **端口占用**:确保统一服务端口(默认1552)未被占用
|
||||
5. **单一文件**:构建完成后,只需一个可执行文件即可运行,无需其他依赖
|
||||
6. **Make工具**:Windows用户需要安装make工具(Git for Windows自带,或使用Chocolatey安装)
|
||||
7. **跨平台编译**:Windows、Linux 和 macOS amd64 目标均可直接交叉编译
|
||||
@@ -222,24 +210,13 @@ choco install make
|
||||
│ 包含: 后端代码 + 前端文件(嵌入) │
|
||||
├─────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────┐ │
|
||||
│ │ 后端API服务 │ │ 前端服务 │ │
|
||||
│ │ 端口: 1551 │ │ 端口:1552│ │
|
||||
│ └──────┬───────┘ └────┬─────┘ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └───────┬───────┘ │
|
||||
│ │ │
|
||||
│ API代理 │
|
||||
│ (前端/api/* → 后端1551) │
|
||||
│ │
|
||||
│ 统一 HTTP 服务 :1552 │
|
||||
│ /api /uploads /assets SPA │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **后端服务(1551)**:提供所有API接口
|
||||
- **前端服务(1552)**:
|
||||
- 从嵌入的文件系统提供前端静态文件(HTML、CSS、JS)
|
||||
- 自动代理 `/api/*` 请求到后端1551端口
|
||||
- 支持SPA路由
|
||||
- **统一服务(1552)**:直接提供 API、上传文件、嵌入的前端静态文件和 SPA 路由
|
||||
|
||||
## 完整构建流程示例
|
||||
|
||||
@@ -331,7 +308,7 @@ make build-linux
|
||||
|
||||
**1Panel配置:**
|
||||
- 运行命令:`./home-vue-go`(或完整路径)
|
||||
- 端口映射:1551(后端API)、1552(前端界面)
|
||||
- 端口映射:1552(统一服务端口)
|
||||
- 工作目录:可执行文件所在目录
|
||||
|
||||
**注意**:SQLite 使用纯 Go 驱动,因此从 Windows 交叉构建 Linux 版本不需要额外的 C 工具链。
|
||||
@@ -365,26 +342,23 @@ make clean
|
||||
|
||||
开发时,可以分别运行前后端:
|
||||
|
||||
**终端1 - 后端:**
|
||||
**终端1 - 统一服务:**
|
||||
```bash
|
||||
make run
|
||||
# 或
|
||||
go run main.go
|
||||
# 后端运行在 http://localhost:1551
|
||||
# 前端和 API 均运行在 http://localhost:1552
|
||||
```
|
||||
|
||||
**终端2 - 前端:**
|
||||
```bash
|
||||
npm run dev
|
||||
# 前端运行在 http://localhost:1552,自动代理API到1551
|
||||
# 热更新页面运行在 http://localhost:5173,API代理到统一服务端口
|
||||
```
|
||||
|
||||
## 部署优势
|
||||
|
||||
✅ **单一可执行文件**:前后端一体化,所有文件嵌入在二进制中
|
||||
✅ **无需依赖**:不需要Node.js、npm或其他运行时
|
||||
✅ **端口分离**:API和前端服务分离,便于管理和扩展
|
||||
✅ **自动代理**:前端自动代理API请求,无需额外配置
|
||||
✅ **1Panel友好**:只需配置两个端口,运行一个命令即可
|
||||
✅ **单端口服务**:前端、API和上传文件共用一个端口
|
||||
✅ **1Panel友好**:只需配置一个端口,运行一个命令即可
|
||||
✅ **部署简单**:上传一个文件,配置端口,即可运行
|
||||
✅ **跨平台构建**:使用make统一构建流程,支持多平台
|
||||
|
||||
@@ -42,35 +42,35 @@ frontend:
|
||||
backend-windows: frontend
|
||||
@echo [backend] Building Windows amd64 executable...
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=windows" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME).exe .
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=windows" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME).exe .
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME).exe .
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME).exe .
|
||||
endif
|
||||
|
||||
backend-linux: frontend
|
||||
@echo [backend] Building Linux amd64 executable...
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=linux" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME) .
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=linux" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME) .
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) .
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) .
|
||||
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
|
||||
endif
|
||||
|
||||
backend-darwin: frontend
|
||||
@echo [backend] Building macOS amd64 executable...
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=darwin" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME) .
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=darwin" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME) .
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) .
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) .
|
||||
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
|
||||
endif
|
||||
|
||||
backend: frontend
|
||||
@echo [backend] Building for the current platform...
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME).exe .
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME).exe .
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) .
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) .
|
||||
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
|
||||
endif
|
||||
|
||||
@@ -78,12 +78,12 @@ endif
|
||||
build-windows: clean frontend
|
||||
@echo [build] Building Windows amd64 package...
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=windows" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(WINDOWS_TEMP) .
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=windows" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(WINDOWS_TEMP) .
|
||||
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
|
||||
@mkdir $(DIST_DIR)
|
||||
@move /y $(WINDOWS_TEMP) $(DIST_DIR)\$(BINARY_NAME).exe >nul
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(WINDOWS_TEMP) .
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(WINDOWS_TEMP) .
|
||||
@rm -rf $(DIST_DIR) && mkdir -p $(DIST_DIR)
|
||||
@mv $(WINDOWS_TEMP) $(DIST_DIR)/$(BINARY_NAME).exe
|
||||
endif
|
||||
@@ -92,12 +92,12 @@ endif
|
||||
build-linux: clean frontend
|
||||
@echo [build] Building Linux amd64 package...
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=linux" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(LINUX_TEMP) .
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=linux" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(LINUX_TEMP) .
|
||||
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
|
||||
@mkdir $(DIST_DIR)
|
||||
@move /y $(LINUX_TEMP) $(DIST_DIR)\$(BINARY_NAME) >nul
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(LINUX_TEMP) .
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(LINUX_TEMP) .
|
||||
@rm -rf $(DIST_DIR) && mkdir -p $(DIST_DIR)
|
||||
@mv $(LINUX_TEMP) $(DIST_DIR)/$(BINARY_NAME)
|
||||
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
|
||||
@@ -107,12 +107,12 @@ endif
|
||||
build-darwin: clean frontend
|
||||
@echo [build] Building macOS amd64 package...
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=darwin" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(DARWIN_TEMP) .
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=darwin" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DARWIN_TEMP) .
|
||||
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
|
||||
@mkdir $(DIST_DIR)
|
||||
@move /y $(DARWIN_TEMP) $(DIST_DIR)\$(BINARY_NAME) >nul
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(DARWIN_TEMP) .
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DARWIN_TEMP) .
|
||||
@rm -rf $(DIST_DIR) && mkdir -p $(DIST_DIR)
|
||||
@mv $(DARWIN_TEMP) $(DIST_DIR)/$(BINARY_NAME)
|
||||
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
|
||||
@@ -122,22 +122,22 @@ endif
|
||||
build: clean frontend
|
||||
@echo [build] Building Windows amd64 package...
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=windows" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_WINDOWS_TEMP) .
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=windows" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(ALL_WINDOWS_TEMP) .
|
||||
@echo [build] Building Linux amd64 package...
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=linux" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_LINUX_TEMP) .
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=linux" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(ALL_LINUX_TEMP) .
|
||||
@echo [build] Building macOS amd64 package...
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=darwin" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_DARWIN_TEMP) .
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=darwin" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(ALL_DARWIN_TEMP) .
|
||||
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
|
||||
@mkdir $(DIST_DIR)
|
||||
@move /y $(ALL_WINDOWS_TEMP) $(DIST_DIR)\$(BINARY_NAME)-windows-amd64.exe >nul
|
||||
@move /y $(ALL_LINUX_TEMP) $(DIST_DIR)\$(BINARY_NAME)-linux-amd64 >nul
|
||||
@move /y $(ALL_DARWIN_TEMP) $(DIST_DIR)\$(BINARY_NAME)-darwin-amd64 >nul
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_WINDOWS_TEMP) .
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(ALL_WINDOWS_TEMP) .
|
||||
@echo [build] Building Linux amd64 package...
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_LINUX_TEMP) .
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(ALL_LINUX_TEMP) .
|
||||
@echo [build] Building macOS amd64 package...
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_DARWIN_TEMP) .
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(ALL_DARWIN_TEMP) .
|
||||
@rm -rf $(DIST_DIR) && mkdir -p $(DIST_DIR)
|
||||
@mv $(ALL_WINDOWS_TEMP) $(DIST_DIR)/$(BINARY_NAME)-windows-amd64.exe
|
||||
@mv $(ALL_LINUX_TEMP) $(DIST_DIR)/$(BINARY_NAME)-linux-amd64
|
||||
@@ -150,7 +150,7 @@ endif
|
||||
|
||||
dist: build
|
||||
|
||||
run:
|
||||
run: frontend
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && $(GO) run .
|
||||
else
|
||||
|
||||
@@ -82,12 +82,12 @@ cd ../..
|
||||
|
||||
**开发模式:**
|
||||
|
||||
需要打开两个终端窗口:
|
||||
生产运行只需要启动一个服务。需要前端热更新时,可额外启动 Vite 开发服务器:
|
||||
|
||||
**终端1 - 启动Go后端:**
|
||||
**终端1 - 启动统一服务:**
|
||||
```bash
|
||||
# 在项目根目录运行
|
||||
go run main.go
|
||||
make run
|
||||
```
|
||||
|
||||
**终端2 - 启动前端开发服务器:**
|
||||
@@ -97,11 +97,11 @@ npm run dev
|
||||
```
|
||||
|
||||
**说明:**
|
||||
- `go run main.go` 会编译并运行Go程序
|
||||
- 后端默认运行在 `http://localhost:1551`
|
||||
- 前端默认运行在 `http://localhost:1552`
|
||||
- `make run` 会先构建前端,再启动同时提供前端、API和上传文件的统一服务
|
||||
- 统一服务默认运行在 `http://localhost:1552`
|
||||
- Vite 开发服务器运行在 `http://localhost:5173`,并将 API 请求代理到统一服务
|
||||
- 首次运行会自动创建 `data/` 目录和数据库
|
||||
- 可以通过环境变量 `PORT` 修改后端端口(默认1551)
|
||||
- 可以通过环境变量 `PORT` 修改统一服务端口(默认1552)
|
||||
|
||||
**Windows用户注意:** 如果遇到中文乱码,在PowerShell中运行:
|
||||
```powershell
|
||||
@@ -113,6 +113,7 @@ chcp 65001
|
||||
- 前端:http://localhost:1552
|
||||
- 管理界面:http://localhost:1552/admin
|
||||
- 登录页面:http://localhost:1552/login
|
||||
- API:http://localhost:1552/api
|
||||
|
||||
**默认管理员账号:**
|
||||
- 用户名:`admin`
|
||||
@@ -146,8 +147,8 @@ npm run build
|
||||
```
|
||||
|
||||
构建完成后:
|
||||
- Go二进制文件:`./home-vue-go` (Linux) 或 `./home-vue-go.exe` (Windows)
|
||||
- 前端构建文件:`./dist`
|
||||
- `make build` 的发布包位于 `./dist/`
|
||||
- 前端资源已经嵌入每个平台的二进制文件,部署时只需要对应的可执行文件
|
||||
|
||||
**说明:**
|
||||
- `go build` 编译Go程序为二进制文件
|
||||
@@ -159,7 +160,6 @@ npm run build
|
||||
|
||||
1. **上传文件到服务器:**
|
||||
- 上传 `home-vue-go` 二进制文件
|
||||
- 上传 `dist` 目录(前端构建文件)
|
||||
|
||||
2. **运行二进制文件:**
|
||||
```bash
|
||||
@@ -173,7 +173,7 @@ npm run build
|
||||
|
||||
4. **环境变量(可选):**
|
||||
```bash
|
||||
export PORT=1551 # 服务端口,默认1551
|
||||
export PORT=1552 # 统一服务端口,默认1552
|
||||
export JWT_SECRET=your-secret-key # JWT密钥,建议修改
|
||||
```
|
||||
|
||||
@@ -239,7 +239,7 @@ go mod download
|
||||
cd internal/ent && go generate ./... && cd ../..
|
||||
|
||||
# 运行后端(开发模式)
|
||||
go run main.go
|
||||
make run
|
||||
|
||||
# 构建后端(当前平台)
|
||||
CGO_ENABLED=0 go build -o home-vue-go .
|
||||
@@ -269,6 +269,21 @@ npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
### GitHub Actions 构建与安装包
|
||||
|
||||
仓库内置 `.github/workflows/build.yml`,在推送到 `main` 或创建 Pull Request 时运行测试和跨平台构建。每次工作流运行都会产生三个可下载的安装包:
|
||||
|
||||
- Windows amd64:`Home-Vue-Go_Windows.zip`
|
||||
- Linux amd64:`Home-Vue-Go_Linux.tar.gz`
|
||||
- macOS amd64:`Home-Vue-Go_macOS.tar.gz`
|
||||
|
||||
在 GitHub Actions 的运行详情页下载对应平台的 `Home-Vue-Go_<平台>` 构建产物。发布版本时推送一个 `v` 开头的标签,工作流会自动创建 GitHub Release、读取对应版本的发行说明并附加这三个安装包:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push github v1.0.0
|
||||
```
|
||||
|
||||
### 许可证
|
||||
|
||||
MIT License
|
||||
本项目沿用原仓库的 [MIT License](./LICENSE),发行包中也会包含完整的 `LICENSE` 文件。原项目版权声明及许可条款予以保留。
|
||||
|
||||
+21
-8
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"home-vue-go/internal/config"
|
||||
"home-vue-go/internal/database"
|
||||
"home-vue-go/internal/ent"
|
||||
"home-vue-go/internal/ent/user"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -84,11 +85,11 @@ func ChangePassword(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
OldPassword string `json:"oldPassword" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required,min=8"`
|
||||
NewPassword string `json:"newPassword" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "旧密码和新密码不能为空,且新密码至少8位"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "当前密码和新密码不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,19 +100,32 @@ func ChangePassword(db *database.Database) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
usernameStr, ok := username.(string)
|
||||
if !ok || strings.TrimSpace(usernameStr) == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户信息无效"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// 查询用户
|
||||
user, err := db.Client.User.Query().Where(user.UsernameEQ(usernameStr)).First(ctx)
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询用户失败"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "旧密码错误"})
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "当前密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateNewPassword(usernameStr, req.OldPassword, req.NewPassword); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -125,13 +139,12 @@ func ChangePassword(db *database.Database) gin.HandlerFunc {
|
||||
// 更新密码到数据库
|
||||
updatedUser, err := db.Client.User.UpdateOneID(user.ID).SetPassword(string(hashedPassword)).Save(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败: " + err.Error()})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证密码已保存(可选,用于调试)
|
||||
if updatedUser == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败: 未返回更新后的用户"})
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(updatedUser.Password), []byte(req.NewPassword)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新校验失败"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"home-vue-go/internal/config"
|
||||
"home-vue-go/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newAuthTestServer(t *testing.T) (*gin.Engine, *database.Database, *config.Config) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := config.New(t.TempDir())
|
||||
db, err := database.Init(cfg.DatabasePath, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := gin.New()
|
||||
r.POST("/login", Login(db, cfg))
|
||||
r.PUT("/change-password", JWTAuthMiddleware(cfg.JWTSecret), ChangePassword(db))
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return r, db, cfg
|
||||
}
|
||||
|
||||
func authJSONRequest(t *testing.T, router http.Handler, method, path string, payload any, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, req)
|
||||
return response
|
||||
}
|
||||
|
||||
func tokenFromResponse(t *testing.T, response *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Token == "" {
|
||||
t.Fatalf("login did not return a token: %s", response.Body.String())
|
||||
}
|
||||
return payload.Token
|
||||
}
|
||||
|
||||
func TestChangePasswordPersistsAndAllowsNewLogin(t *testing.T) {
|
||||
router, _, _ := newAuthTestServer(t)
|
||||
login := authJSONRequest(t, router, http.MethodPost, "/login", map[string]string{"username": "admin", "password": "admin123"}, "")
|
||||
if login.Code != http.StatusOK {
|
||||
t.Fatalf("initial login failed: %d %s", login.Code, login.Body.String())
|
||||
}
|
||||
token := tokenFromResponse(t, login)
|
||||
|
||||
change := authJSONRequest(t, router, http.MethodPut, "/change-password", map[string]string{
|
||||
"oldPassword": "admin123",
|
||||
"newPassword": "New-admin-2026!",
|
||||
}, token)
|
||||
if change.Code != http.StatusOK {
|
||||
t.Fatalf("password change failed: %d %s", change.Code, change.Body.String())
|
||||
}
|
||||
|
||||
oldLogin := authJSONRequest(t, router, http.MethodPost, "/login", map[string]string{"username": "admin", "password": "admin123"}, "")
|
||||
if oldLogin.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("old password should be rejected: %d", oldLogin.Code)
|
||||
}
|
||||
newLogin := authJSONRequest(t, router, http.MethodPost, "/login", map[string]string{"username": "admin", "password": "New-admin-2026!"}, "")
|
||||
if newLogin.Code != http.StatusOK {
|
||||
t.Fatalf("new password should work: %d %s", newLogin.Code, newLogin.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordRejectsInvalidInputWithoutLoggingOut(t *testing.T) {
|
||||
router, _, _ := newAuthTestServer(t)
|
||||
login := authJSONRequest(t, router, http.MethodPost, "/login", map[string]string{"username": "admin", "password": "admin123"}, "")
|
||||
token := tokenFromResponse(t, login)
|
||||
|
||||
weak := authJSONRequest(t, router, http.MethodPut, "/change-password", map[string]string{
|
||||
"oldPassword": "admin123",
|
||||
"newPassword": "12345678",
|
||||
}, token)
|
||||
if weak.Code != http.StatusBadRequest {
|
||||
t.Fatalf("weak password should be rejected: %d", weak.Code)
|
||||
}
|
||||
|
||||
wrongOld := authJSONRequest(t, router, http.MethodPut, "/change-password", map[string]string{
|
||||
"oldPassword": "wrong-password",
|
||||
"newPassword": "New-admin-2026!",
|
||||
}, token)
|
||||
if wrongOld.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("wrong current password should be a validation error: %d", wrongOld.Code)
|
||||
}
|
||||
|
||||
stillValid := authJSONRequest(t, router, http.MethodPut, "/change-password", map[string]string{
|
||||
"oldPassword": "admin123",
|
||||
"newPassword": "New-admin-2026!",
|
||||
}, token)
|
||||
if stillValid.Code != http.StatusOK {
|
||||
t.Fatalf("valid token should remain usable after a rejected attempt: %d %s", stillValid.Code, stillValid.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNewPassword(t *testing.T) {
|
||||
if err := validateNewPassword("admin", "admin123", "New-admin-2026!"); err != nil {
|
||||
t.Fatalf("expected valid password: %v", err)
|
||||
}
|
||||
for _, password := range []string{"short1!", "admin123", "12345678", "lettersonly", "New-admin-2026! "} {
|
||||
if err := validateNewPassword("admin", "admin123", password); err == nil {
|
||||
t.Errorf("expected password to be rejected: %q", password)
|
||||
}
|
||||
}
|
||||
if err := validateNewPassword("admin", "admin123", strings.Repeat("a1!", 30)); err == nil {
|
||||
t.Fatal("expected bcrypt-overlong password to be rejected")
|
||||
}
|
||||
if err := validateNewPassword("admin", "admin123", "New-管理-2026!"); err != nil {
|
||||
t.Fatalf("expected unicode password to be valid: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
minPasswordRunes = 8
|
||||
maxPasswordBytes = 72 // bcrypt only uses the first 72 bytes.
|
||||
)
|
||||
|
||||
var commonPasswords = map[string]struct{}{
|
||||
"12345678": {},
|
||||
"admin123": {},
|
||||
"password": {},
|
||||
"password123": {},
|
||||
"qwerty123": {},
|
||||
}
|
||||
|
||||
func validateNewPassword(username, oldPassword, newPassword string) error {
|
||||
if !utf8.ValidString(newPassword) {
|
||||
return fmt.Errorf("新密码包含无效字符")
|
||||
}
|
||||
if newPassword == oldPassword {
|
||||
return fmt.Errorf("新密码不能与当前密码相同")
|
||||
}
|
||||
if utf8.RuneCountInString(newPassword) < minPasswordRunes {
|
||||
return fmt.Errorf("新密码至少需要%d个字符", minPasswordRunes)
|
||||
}
|
||||
if len([]byte(newPassword)) > maxPasswordBytes {
|
||||
return fmt.Errorf("新密码不能超过%d字节", maxPasswordBytes)
|
||||
}
|
||||
if strings.TrimSpace(newPassword) != newPassword {
|
||||
return fmt.Errorf("新密码不能以空格开头或结尾")
|
||||
}
|
||||
for _, char := range newPassword {
|
||||
if unicode.IsControl(char) {
|
||||
return fmt.Errorf("新密码不能包含控制字符")
|
||||
}
|
||||
}
|
||||
if _, exists := commonPasswords[strings.ToLower(newPassword)]; exists {
|
||||
return fmt.Errorf("新密码过于常见,请使用更复杂的密码")
|
||||
}
|
||||
if username != "" && strings.EqualFold(newPassword, username) {
|
||||
return fmt.Errorf("新密码不能与用户名相同")
|
||||
}
|
||||
|
||||
categoryCount := 0
|
||||
hasLetter, hasNumber, hasSymbol := false, false, false
|
||||
for _, char := range newPassword {
|
||||
switch {
|
||||
case unicode.IsLetter(char):
|
||||
hasLetter = true
|
||||
case unicode.IsNumber(char):
|
||||
hasNumber = true
|
||||
case unicode.IsPunct(char) || unicode.IsSymbol(char):
|
||||
hasSymbol = true
|
||||
}
|
||||
}
|
||||
for _, present := range []bool{hasLetter, hasNumber, hasSymbol} {
|
||||
if present {
|
||||
categoryCount++
|
||||
}
|
||||
}
|
||||
if categoryCount < 2 {
|
||||
return fmt.Errorf("新密码至少需要包含字母、数字、符号中的两类")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,11 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -24,263 +23,120 @@ import (
|
||||
var distFS embed.FS
|
||||
|
||||
func main() {
|
||||
// 设置亚洲/上海时区
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
log.Fatal("无法加载时区:", err)
|
||||
}
|
||||
time.Local = loc
|
||||
|
||||
// 获取可执行文件所在目录
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Fatal("无法获取可执行文件路径:", err)
|
||||
}
|
||||
exeDir := filepath.Dir(exePath)
|
||||
|
||||
// 创建data目录
|
||||
dataDir := filepath.Join(exeDir, "data")
|
||||
dataDir := filepath.Join(filepath.Dir(exePath), "data")
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
log.Fatal("无法创建data目录:", err)
|
||||
}
|
||||
|
||||
// 初始化配置
|
||||
cfg := config.New(dataDir)
|
||||
|
||||
// 初始化数据库
|
||||
db, err := database.Init(cfg.DatabasePath, cfg)
|
||||
if err != nil {
|
||||
log.Fatal("数据库初始化失败:", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// 设置Gin模式
|
||||
if os.Getenv("GIN_MODE") == "" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// 创建Gin路由(不使用Default以避免重复日志)
|
||||
r := gin.New()
|
||||
|
||||
// 添加恢复中间件
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
// 静态文件服务 - 从文件系统提供前端构建文件
|
||||
// 优先使用可执行文件同目录下的dist目录
|
||||
distPath := filepath.Join(exeDir, "dist")
|
||||
if _, err := os.Stat(distPath); err == nil {
|
||||
// 使用可执行文件同目录下的dist
|
||||
r.Static("/static", filepath.Join(distPath, "static"))
|
||||
r.StaticFile("/favicon.ico", filepath.Join(distPath, "favicon.ico"))
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
// API和上传路径不处理
|
||||
if path == "/api" || strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/uploads/") {
|
||||
c.Status(404)
|
||||
return
|
||||
}
|
||||
// 其他路径返回index.html(SPA路由支持)
|
||||
c.File(filepath.Join(distPath, "index.html"))
|
||||
})
|
||||
log.Printf("前端文件目录: %s", distPath)
|
||||
} else {
|
||||
// 回退到当前工作目录的dist(开发模式)
|
||||
if _, err := os.Stat("./dist"); err == nil {
|
||||
r.Static("/static", "./dist/static")
|
||||
r.StaticFile("/favicon.ico", "./dist/favicon.ico")
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
if c.Request.URL.Path != "/api" && !strings.HasPrefix(c.Request.URL.Path, "/api/") && !strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
|
||||
c.File("./dist/index.html")
|
||||
}
|
||||
})
|
||||
log.Println("使用当前目录的dist文件夹(开发模式)")
|
||||
} else {
|
||||
log.Println("警告: 未找到前端文件目录,前端功能不可用")
|
||||
log.Println("提示: 请将前端构建文件放在可执行文件同目录的dist文件夹中")
|
||||
}
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "1552"
|
||||
}
|
||||
|
||||
// 配置CORS
|
||||
r.Use(corsMiddleware())
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery(), corsMiddleware(port))
|
||||
api.SetupRoutes(router, db, cfg)
|
||||
|
||||
// 初始化API路由
|
||||
api.SetupRoutes(r, db, cfg)
|
||||
|
||||
apiPort := os.Getenv("API_PORT")
|
||||
if apiPort == "" {
|
||||
apiPort = "1551"
|
||||
}
|
||||
|
||||
// 创建前端服务器(1552端口)- 从嵌入的文件系统提供前端文件
|
||||
frontendRouter := gin.New()
|
||||
frontendRouter.Use(gin.Recovery())
|
||||
frontendRouter.Use(corsMiddleware())
|
||||
|
||||
// 从嵌入的文件系统加载前端文件
|
||||
distRoot, err := fs.Sub(distFS, "dist")
|
||||
if err == nil {
|
||||
// 使用嵌入的文件系统
|
||||
frontendRouter.StaticFS("/static", http.FS(distRoot))
|
||||
|
||||
// 提供favicon
|
||||
frontendRouter.GET("/favicon.ico", func(c *gin.Context) {
|
||||
data, err := distRoot.Open("favicon.ico")
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
log.Fatal("无法加载嵌入的前端文件:", err)
|
||||
}
|
||||
defer data.Close()
|
||||
content, err := io.ReadAll(data)
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "image/x-icon", content)
|
||||
})
|
||||
|
||||
// API代理:将/api请求转发到配置的API端口
|
||||
frontendRouter.Any("/api/*path", proxyAPIRequest(apiPort))
|
||||
|
||||
// /uploads代理
|
||||
frontendRouter.Static("/uploads", cfg.UploadDir)
|
||||
|
||||
// SPA路由支持
|
||||
frontendRouter.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/uploads/") {
|
||||
c.Status(404)
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试打开文件
|
||||
filePath := strings.TrimPrefix(path, "/")
|
||||
if filePath == "" {
|
||||
filePath = "index.html"
|
||||
}
|
||||
file, err := distRoot.Open(filePath)
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
stat, err := file.Stat()
|
||||
if err == nil && !stat.IsDir() {
|
||||
content, err := io.ReadAll(file)
|
||||
if err == nil {
|
||||
contentType := "text/html"
|
||||
if strings.HasSuffix(filePath, ".css") {
|
||||
contentType = "text/css"
|
||||
} else if strings.HasSuffix(filePath, ".js") {
|
||||
contentType = "application/javascript"
|
||||
} else if strings.HasSuffix(filePath, ".json") {
|
||||
contentType = "application/json"
|
||||
} else if strings.HasSuffix(filePath, ".ico") {
|
||||
contentType = "image/x-icon"
|
||||
} else if strings.HasSuffix(filePath, ".png") {
|
||||
contentType = "image/png"
|
||||
} else if strings.HasSuffix(filePath, ".jpg") || strings.HasSuffix(filePath, ".jpeg") {
|
||||
contentType = "image/jpeg"
|
||||
} else if strings.HasSuffix(filePath, ".svg") {
|
||||
contentType = "image/svg+xml"
|
||||
}
|
||||
c.Data(http.StatusOK, contentType, content)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回index.html(SPA路由)
|
||||
indexFile, err := distRoot.Open("index.html")
|
||||
if err == nil {
|
||||
defer indexFile.Close()
|
||||
content, err := io.ReadAll(indexFile)
|
||||
if err == nil {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
|
||||
} else {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
} else {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
router.NoRoute(serveFrontend(distRoot))
|
||||
log.Println("使用嵌入的前端文件(单一可执行文件模式)")
|
||||
} else {
|
||||
// 回退到文件系统(开发模式)
|
||||
log.Println("警告: 无法加载嵌入的前端文件,尝试从文件系统加载")
|
||||
distPath := filepath.Join(exeDir, "dist")
|
||||
if _, err := os.Stat(distPath); err == nil {
|
||||
frontendRouter.Static("/static", filepath.Join(distPath, "static"))
|
||||
frontendRouter.StaticFile("/favicon.ico", filepath.Join(distPath, "favicon.ico"))
|
||||
|
||||
frontendRouter.Any("/api/*path", proxyAPIRequest(apiPort))
|
||||
|
||||
frontendRouter.Static("/uploads", cfg.UploadDir)
|
||||
frontendRouter.NoRoute(func(c *gin.Context) {
|
||||
if !strings.HasPrefix(c.Request.URL.Path, "/api/") && !strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
|
||||
c.File(filepath.Join(distPath, "index.html"))
|
||||
}
|
||||
})
|
||||
log.Printf("使用文件系统前端文件: %s", distPath)
|
||||
} else if _, err := os.Stat("./dist"); err == nil {
|
||||
frontendRouter.Static("/static", "./dist/static")
|
||||
frontendRouter.StaticFile("/favicon.ico", "./dist/favicon.ico")
|
||||
frontendRouter.Any("/api/*path", proxyAPIRequest(apiPort))
|
||||
frontendRouter.Static("/uploads", cfg.UploadDir)
|
||||
frontendRouter.NoRoute(func(c *gin.Context) {
|
||||
if !strings.HasPrefix(c.Request.URL.Path, "/api/") && !strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
|
||||
c.File("./dist/index.html")
|
||||
}
|
||||
})
|
||||
log.Println("使用当前目录的dist文件夹(开发模式)")
|
||||
} else {
|
||||
log.Println("警告: 未找到前端文件,前端功能不可用")
|
||||
}
|
||||
}
|
||||
|
||||
// 启动两个服务器
|
||||
frontendPort := os.Getenv("FRONTEND_PORT")
|
||||
if frontendPort == "" {
|
||||
frontendPort = "1552"
|
||||
}
|
||||
|
||||
// 创建API服务器
|
||||
apiServer := &http.Server{
|
||||
Addr: ":" + apiPort,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// 创建前端服务器
|
||||
frontendServer := &http.Server{
|
||||
Addr: ":" + frontendPort,
|
||||
Handler: frontendRouter,
|
||||
}
|
||||
|
||||
// 只在首次启动时显示默认账号信息
|
||||
firstRunFile := filepath.Join(dataDir, ".first_run")
|
||||
if _, err := os.Stat(firstRunFile); os.IsNotExist(err) {
|
||||
os.WriteFile(firstRunFile, []byte(""), 0644)
|
||||
if err := os.WriteFile(firstRunFile, []byte(""), 0644); err != nil {
|
||||
log.Printf("记录首次启动状态失败: %v", err)
|
||||
}
|
||||
log.Printf("默认管理员账号: admin, 密码: admin123")
|
||||
log.Printf("提示: 首次启动后,请及时修改默认密码以确保安全")
|
||||
}
|
||||
|
||||
log.Printf("========================================")
|
||||
log.Printf("服务器启动成功!")
|
||||
log.Printf("后端API: http://localhost:%s", apiPort)
|
||||
log.Printf("前端界面: http://localhost:%s", frontendPort)
|
||||
log.Printf("访问端点: http://localhost:%s", port)
|
||||
log.Printf("========================================")
|
||||
|
||||
// 在goroutine中启动前端服务器
|
||||
go func() {
|
||||
if err := frontendServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("前端服务器启动失败: %v", err)
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: router,
|
||||
}
|
||||
}()
|
||||
|
||||
// 在主goroutine中启动API服务器
|
||||
if err := apiServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("API服务器启动失败: %v", err)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("服务器启动失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
func serveFrontend(root fs.FS) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestPath := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
if requestPath == "api" || strings.HasPrefix(requestPath, "api/") ||
|
||||
requestPath == "uploads" || strings.HasPrefix(requestPath, "uploads/") {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := requestPath
|
||||
if filePath == "" {
|
||||
filePath = "index.html"
|
||||
}
|
||||
if serveEmbeddedFile(c, root, filePath) {
|
||||
return
|
||||
}
|
||||
if !serveEmbeddedFile(c, root, "index.html") {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveEmbeddedFile(c *gin.Context, root fs.FS, filePath string) bool {
|
||||
file, err := root.Open(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer file.Close()
|
||||
stat, err := file.Stat()
|
||||
if err != nil || stat.IsDir() {
|
||||
return false
|
||||
}
|
||||
content, err := fs.ReadFile(root, filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
contentType := mime.TypeByExtension(filepath.Ext(filePath))
|
||||
if contentType == "" {
|
||||
contentType = http.DetectContentType(content)
|
||||
}
|
||||
c.Data(http.StatusOK, contentType, content)
|
||||
return true
|
||||
}
|
||||
|
||||
func corsMiddleware(port string) gin.HandlerFunc {
|
||||
allowedOrigins := make(map[string]struct{})
|
||||
for _, origin := range strings.Split(os.Getenv("CORS_ALLOWED_ORIGINS"), ",") {
|
||||
if value := strings.TrimSpace(origin); value != "" {
|
||||
@@ -288,8 +144,8 @@ func corsMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
if len(allowedOrigins) == 0 {
|
||||
allowedOrigins["http://localhost:1552"] = struct{}{}
|
||||
allowedOrigins["http://127.0.0.1:1552"] = struct{}{}
|
||||
allowedOrigins["http://localhost:"+port] = struct{}{}
|
||||
allowedOrigins["http://127.0.0.1:"+port] = struct{}{}
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
@@ -301,41 +157,10 @@ func corsMiddleware() gin.HandlerFunc {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func proxyAPIRequest(apiPort string) gin.HandlerFunc {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
return func(c *gin.Context) {
|
||||
target := &url.URL{Scheme: "http", Host: "localhost:" + apiPort, Path: c.Request.URL.Path, RawQuery: c.Request.URL.RawQuery}
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, target.String(), c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建代理请求失败"})
|
||||
return
|
||||
}
|
||||
for key, values := range c.Request.Header {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "代理请求失败"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Writer.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
}
|
||||
|
||||
+44
-23
@@ -1,38 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestProxyAPIRequestUsesConfiguredPort(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"path":"` + r.URL.RequestURI() + `"}`))
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
backendURL, err := url.Parse(backend.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, port, err := net.SplitHostPort(backendURL.Host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
func TestAPIAndFrontendShareRouter(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Any("/api/*path", proxyAPIRequest(port))
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/config?custom=1", nil))
|
||||
router.GET("/api/ping", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
router.NoRoute(serveFrontend(fstest.MapFS{
|
||||
"index.html": {Data: []byte("<html>app</html>")},
|
||||
"assets/app.js": {Data: []byte("console.log('app')")},
|
||||
}))
|
||||
|
||||
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"path":"/api/config?custom=1"}` {
|
||||
t.Fatalf("unexpected proxy response: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
tests := []struct {
|
||||
path string
|
||||
statusCode int
|
||||
body string
|
||||
}{
|
||||
{path: "/api/ping", statusCode: http.StatusOK, body: `{"status":"ok"}`},
|
||||
{path: "/admin", statusCode: http.StatusOK, body: "<html>app</html>"},
|
||||
{path: "/assets/app.js", statusCode: http.StatusOK, body: "console.log('app')"},
|
||||
{path: "/api/missing", statusCode: http.StatusNotFound, body: "404 page not found"},
|
||||
{path: "/uploads/missing.png", statusCode: http.StatusNotFound, body: "404 page not found"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, test.path, nil))
|
||||
if recorder.Code != test.statusCode || recorder.Body.String() != test.body {
|
||||
t.Fatalf("unexpected response: status=%d body=%q", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSMiddlewareUsesServicePort(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(corsMiddleware("8080"))
|
||||
router.GET("/api/ping", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
||||
request.Header.Set("Origin", "http://localhost:8080")
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if origin := recorder.Header().Get("Access-Control-Allow-Origin"); origin != "http://localhost:8080" {
|
||||
t.Fatalf("unexpected allowed origin: %q", origin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,12 +138,13 @@
|
||||
<IconPicker v-model="iconPickerValue" @close="iconPickerOpen = false" />
|
||||
</AdminModal>
|
||||
|
||||
<AdminModal :open="passwordModalOpen" title="修改密码" description="新密码至少八位,建议混合大小写、数字和符号" size="small" @close="closePasswordModal">
|
||||
<AdminModal :open="passwordModalOpen" title="修改密码" description="新密码至少八个字符,并包含字母、数字、符号中的至少两类" size="small" @close="closePasswordModal">
|
||||
<form id="password-form" class="password-form" @submit.prevent="changePassword">
|
||||
<label><span>当前密码</span><div class="password-input"><input v-model="passwordForm.oldPassword" :type="passwordVisibility.old ? 'text' : 'password'" autocomplete="current-password" required /><button type="button" :aria-label="passwordVisibility.old ? '隐藏密码' : '显示密码'" @click="passwordVisibility.old = !passwordVisibility.old"><i :class="passwordVisibility.old ? 'fas fa-eye-slash' : 'fas fa-eye'"></i></button></div></label>
|
||||
<label><span>新密码</span><div class="password-input"><input v-model="passwordForm.newPassword" :type="passwordVisibility.new ? 'text' : 'password'" autocomplete="new-password" minlength="8" required /><button type="button" :aria-label="passwordVisibility.new ? '隐藏密码' : '显示密码'" @click="passwordVisibility.new = !passwordVisibility.new"><i :class="passwordVisibility.new ? 'fas fa-eye-slash' : 'fas fa-eye'"></i></button></div></label>
|
||||
<label><span>新密码</span><div class="password-input"><input v-model="passwordForm.newPassword" :type="passwordVisibility.new ? 'text' : 'password'" autocomplete="new-password" minlength="8" maxlength="72" required /><button type="button" :aria-label="passwordVisibility.new ? '隐藏密码' : '显示密码'" @click="passwordVisibility.new = !passwordVisibility.new"><i :class="passwordVisibility.new ? 'fas fa-eye-slash' : 'fas fa-eye'"></i></button></div></label>
|
||||
<div class="password-strength"><span :style="{ width: `${passwordStrength.percent}%` }" :class="passwordStrength.level"></span></div>
|
||||
<small>{{ passwordStrength.label }}</small>
|
||||
<p v-if="passwordError" class="field-error">{{ passwordError }}</p>
|
||||
<label><span>确认新密码</span><div class="password-input"><input v-model="passwordForm.confirmPassword" :type="passwordVisibility.confirm ? 'text' : 'password'" autocomplete="new-password" required /><button type="button" :aria-label="passwordVisibility.confirm ? '隐藏密码' : '显示密码'" @click="passwordVisibility.confirm = !passwordVisibility.confirm"><i :class="passwordVisibility.confirm ? 'fas fa-eye-slash' : 'fas fa-eye'"></i></button></div></label>
|
||||
<p v-if="passwordMismatch" class="field-error">两次输入的新密码不一致</p>
|
||||
</form>
|
||||
@@ -163,6 +164,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { adminAPI } from '../api'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { loadAndApplyFrontendConfig } from '../utils/frontendConfig'
|
||||
import { passwordMetrics } from '../utils/passwordPolicy'
|
||||
import AppIcon from './AppIcon.vue'
|
||||
import Dashboard from './Dashboard.vue'
|
||||
import IconPicker from './IconPicker.vue'
|
||||
@@ -241,20 +243,11 @@ const colorPickerValue = computed({
|
||||
get: () => /^#[0-9a-fA-F]{6}$/.test(contactForm.hoverColor) ? contactForm.hoverColor : '#555555',
|
||||
set: (value) => { contactForm.hoverColor = value },
|
||||
})
|
||||
const passwordMetricsResult = computed(() => passwordMetrics(passwordForm.newPassword, passwordForm.oldPassword))
|
||||
const passwordMismatch = computed(() => Boolean(passwordForm.newPassword && passwordForm.confirmPassword && passwordForm.newPassword !== passwordForm.confirmPassword))
|
||||
const canChangePassword = computed(() => passwordForm.oldPassword && passwordForm.newPassword.length >= 8 && passwordForm.confirmPassword && !passwordMismatch.value)
|
||||
const passwordStrength = computed(() => {
|
||||
const password = passwordForm.newPassword
|
||||
if (!password) return { percent: 0, level: '', label: '尚未输入新密码' }
|
||||
let score = password.length >= 8 ? 1 : 0
|
||||
if (password.length >= 12) score++
|
||||
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++
|
||||
if (/\d/.test(password)) score++
|
||||
if (/[^a-zA-Z0-9]/.test(password)) score++
|
||||
if (score <= 2) return { percent: 34, level: 'weak', label: '密码强度:弱' }
|
||||
if (score <= 4) return { percent: 68, level: 'medium', label: '密码强度:中' }
|
||||
return { percent: 100, level: 'strong', label: '密码强度:强' }
|
||||
})
|
||||
const passwordError = computed(() => passwordMetricsResult.value.error)
|
||||
const canChangePassword = computed(() => Boolean(passwordForm.oldPassword && passwordForm.confirmPassword && passwordMetricsResult.value.valid && !passwordMismatch.value))
|
||||
const passwordStrength = computed(() => passwordMetricsResult.value)
|
||||
const toastIcon = computed(() => ({ success: 'fas fa-circle-check', error: 'fas fa-circle-exclamation', warning: 'fas fa-triangle-exclamation' }[toast.type] || 'fas fa-circle-info'))
|
||||
const confirmTitle = computed(() => confirmAction.value?.kind === 'logout' ? '退出登录' : `删除${confirmAction.value?.kind === 'site' ? '站点' : '联系方式'}`)
|
||||
const confirmDescription = computed(() => confirmAction.value?.kind === 'logout' ? '确认结束当前管理会话吗?' : `确认删除“${confirmAction.value?.item?.name || confirmAction.value?.item?.type || ''}”吗?`)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export const PASSWORD_MIN_LENGTH = 8
|
||||
export const PASSWORD_MAX_BYTES = 72
|
||||
|
||||
const commonPasswords = new Set(['12345678', 'admin123', 'password', 'password123', 'qwerty123'])
|
||||
|
||||
const byteLength = (value) => {
|
||||
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(value).length
|
||||
return unescape(encodeURIComponent(value)).length
|
||||
}
|
||||
|
||||
export const passwordMetrics = (password = '', oldPassword = '') => {
|
||||
const value = String(password)
|
||||
const length = Array.from(value).length
|
||||
const categories = new Set()
|
||||
for (const char of value) {
|
||||
if (/\p{L}/u.test(char)) categories.add('letter')
|
||||
else if (/\p{N}/u.test(char)) categories.add('number')
|
||||
else if (/[^\p{L}\p{N}\s]/u.test(char)) categories.add('symbol')
|
||||
}
|
||||
|
||||
const score = (length >= PASSWORD_MIN_LENGTH ? 1 : 0)
|
||||
+ (length >= 12 ? 1 : 0)
|
||||
+ categories.size
|
||||
let strength = { percent: 0, level: '', label: '尚未输入新密码' }
|
||||
if (value) {
|
||||
strength = score <= 2
|
||||
? { percent: 34, level: 'weak', label: '密码强度:弱' }
|
||||
: score <= 4
|
||||
? { percent: 68, level: 'medium', label: '密码强度:中' }
|
||||
: { percent: 100, level: 'strong', label: '密码强度:强' }
|
||||
}
|
||||
|
||||
let error = ''
|
||||
if (value && length < PASSWORD_MIN_LENGTH) error = `新密码至少需要${PASSWORD_MIN_LENGTH}个字符`
|
||||
else if (value && byteLength(value) > PASSWORD_MAX_BYTES) error = `新密码不能超过${PASSWORD_MAX_BYTES}字节`
|
||||
else if (value && value.trim() !== value) error = '新密码不能以空格开头或结尾'
|
||||
else if (value && /[\u0000-\u001f\u007f]/.test(value)) error = '新密码不能包含控制字符'
|
||||
else if (value && value === oldPassword) error = '新密码不能与当前密码相同'
|
||||
else if (value && commonPasswords.has(value.toLowerCase())) error = '新密码过于常见,请使用更复杂的密码'
|
||||
else if (value && categories.size < 2) error = '新密码至少需要包含字母、数字、符号中的两类'
|
||||
|
||||
return {
|
||||
length,
|
||||
bytes: byteLength(value),
|
||||
categoryCount: categories.size,
|
||||
error,
|
||||
valid: Boolean(value) && !error,
|
||||
...strength,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { passwordMetrics } from './passwordPolicy'
|
||||
|
||||
describe('passwordPolicy', () => {
|
||||
it('accepts a strong password and reports its strength', () => {
|
||||
const result = passwordMetrics('New-admin-2026!', 'admin123')
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.level).toBe('strong')
|
||||
expect(result.categoryCount).toBe(3)
|
||||
})
|
||||
|
||||
it('rejects weak, repeated, common and padded passwords', () => {
|
||||
expect(passwordMetrics('short1!', 'admin123').valid).toBe(false)
|
||||
expect(passwordMetrics('admin123', 'admin123').error).toContain('相同')
|
||||
expect(passwordMetrics('12345678', 'admin123').error).toContain('常见')
|
||||
expect(passwordMetrics('New-admin-2026! ', 'admin123').error).toContain('空格')
|
||||
})
|
||||
|
||||
it('limits bcrypt-compatible UTF-8 byte length', () => {
|
||||
const result = passwordMetrics(`${'管理'.repeat(36)}1!`, 'admin123')
|
||||
expect(result.bytes).toBeGreaterThan(72)
|
||||
expect(result.error).toContain('72')
|
||||
})
|
||||
})
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
const apiPort = process.env.API_PORT || '1551';
|
||||
const servicePort = process.env.PORT || '1552';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
@@ -17,14 +17,14 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 1552,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: `http://localhost:${apiPort}`,
|
||||
target: `http://localhost:${servicePort}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: `http://localhost:${apiPort}`,
|
||||
target: `http://localhost:${servicePort}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user