Compare commits

..

6 Commits

Author SHA1 Message Date
admin_gitea 18db389beb docs: add v1.0.0 release notes and package naming
Build Packages / Test (push) Has been cancelled
Build Packages / Build macOS package (push) Has been cancelled
Build Packages / Build Linux package (push) Has been cancelled
Build Packages / Build Windows package (push) Has been cancelled
Build Packages / Publish GitHub Release (push) Has been cancelled
2026-08-05 15:28:29 +08:00
admin_gitea 6f07849a53 feat: unify service port and add GitHub packages workflow
Build Packages / Test (push) Has been cancelled
Build Packages / Build macOS package (push) Has been cancelled
Build Packages / Build Linux package (push) Has been cancelled
Build Packages / Build Windows package (push) Has been cancelled
Build Packages / Publish GitHub Release (push) Has been cancelled
2026-08-05 15:13:29 +08:00
admin_gitea 56db1cc642 fix: 完善管理后台密码修改逻辑 2026-08-05 07:24:49 +08:00
admin_gitea 608bff791d Merge remote-tracking branch 'origin/main' 2026-08-05 06:12:55 +08:00
admin_gitea f7f59a7ee4 fix: build all target platforms by default 2026-08-05 06:11:57 +08:00
admin_gitea e3aed70d6c 完善后台的图标库引用逻辑和在线图标库的引用 2026-08-05 05:47:26 +08:00
39 changed files with 4654 additions and 2067 deletions
+46
View File
@@ -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 许可证。
+134
View File
@@ -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/*
+39 -92
View File
@@ -1,8 +1,6 @@
# 构建说明
本项目支持打包为**单一可执行文件**,包含前后端启动后同时提供:
- **1551端口**:后端API服务
- **1552端口**:前端界面服务(自动代理API请求到1551)
本项目支持打包为**单一可执行文件**,包含前后端启动后通过一个端口同时提供前端、API和上传文件,默认端口为 **1552**
所有前端文件已嵌入到二进制文件中,无需额外文件。
@@ -23,35 +21,28 @@
如果你安装了 Git for Windows,make 工具通常已经包含在内,但可能不在 PATH 中。
**方式1使用提供的 make.bat 包装脚本(推荐)**
```bash
make.bat build-linux
make.bat build-windows
make.bat build
```
这个脚本会自动查找 Git 自带的 make 工具。
**方式2:将 Git 的 make 添加到 PATH**
**方式1将 Git 的 make 添加到 PATH**
1. 找到 Git 安装目录(通常是 `C:\Program Files\Git`
2.`C:\Program Files\Git\usr\bin` 添加到系统 PATH 环境变量
3. 重启终端后即可直接使用 `make` 命令
**方式3:使用 Chocolatey 安装 make**
**方式2:使用 Chocolatey 安装 make**
```powershell
choco install make
```
**方式4:使用 WSL**
**方式3:使用 WSL**
在 WSL 中运行 make 命令。
### 构建命令
#### 构建当前平台版本
#### 构建全部平台版本(默认)
```bash
make build
```
该命令会生成 Windows、Linux 和 macOS 的 amd64 可执行文件。
#### 构建 Linux 版本(用于服务器部署)
```bash
make build-linux
@@ -86,7 +77,7 @@ make backend-windows # Windows
make backend-darwin # macOS
```
**生成Ent代码(首次构建前需要):**
**生成 Ent 代码(仅修改 Schema 后需要):**
```bash
make generate
```
@@ -103,16 +94,16 @@ make run
## 构建输出
构建完成后,`dist` 目录将包含**单一可执行文件**
执行 `make build` 后,`dist` 目录将包含三个平台包
```
dist/
── home-vue-go.exe # Windows单一可执行文件(包含前后端)
└── home-vue-go # Linux/macOS单一可执行文件(包含前后端)
── home-vue-go-windows-amd64.exe
├── home-vue-go-linux-amd64
└── home-vue-go-darwin-amd64
```
**注意**所有前端文件(HTML、CSS、JavaScript等)都已嵌入到二进制文件中,构建脚本会自动清理dist目录中的前端源文件。
所有前端文件(HTML、CSS、JavaScript等)都会分别嵌入三个二进制文件中,构建脚本会自动清理 dist 中的前端源文件。
## 运行服务器
@@ -130,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 配置
@@ -149,9 +135,7 @@ cd dist
1. **上传文件**:将 `home-vue-go`Linux版本)上传到服务器
2. **配置端口**
- 后端API端口:`1551`
- 前端服务端口:`1552`
2. **配置端口**只需放行统一服务端口 `1552`
3. **运行命令**
```bash
@@ -162,8 +146,7 @@ cd dist
## 配置说明
- **后端API端口**:默认 `1551`,可通过环境变量 `API_PORT` 修改
- **前端服务端口**:默认 `1552`,可通过环境变量 `FRONTEND_PORT` 修改
- **统一服务端口**:默认 `1552`,可通过环境变量 `PORT` 修改
- **数据目录**:运行时会自动在二进制文件同目录下创建 `data` 目录
- **默认管理员账号**`admin` / `admin123`(首次启动时显示)
@@ -171,46 +154,29 @@ 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
```
## 注意事项
1. **CGO依赖**:本项目使用SQLite数据库,需要启用CGO(`CGO_ENABLED=1`
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版本需要gcc工具链,推荐使用WSL或在Linux系统上直接构建
7. **跨平台编译**Windows、Linux 和 macOS amd64 目标均可直接交叉编译
## Windows 上使用 Make
### 方式1使用提供的 make.bat 包装脚本(最简单
项目根目录提供了 `make.bat` 脚本,它会自动查找 Git 自带的 make 工具:
```bash
# 构建 Linux 版本
make.bat build-linux
# 构建 Windows 版本
make.bat build-windows
# 构建当前平台版本
make.bat build
```
### 方式2:将 Git 的 make 添加到 PATH(推荐)
### 方式1将 Git 的 make 添加到 PATH(推荐
Git for Windows 自带 make 工具,通常位于:
- `C:\Program Files\Git\usr\bin\make.exe`
@@ -223,15 +189,15 @@ Git for Windows 自带 make 工具,通常位于:
4. 点击"确定"保存
5. 重启终端后即可直接使用 `make` 命令
### 方式3:使用 Chocolatey 安装 make
### 方式2:使用 Chocolatey 安装 make
```powershell
choco install make
```
### 方式4:使用 WSL
### 方式3:使用 WSL
在 WSL 中运行 make 命令。
### 方式5:手动下载 make for Windows
### 方式4:手动下载 make for Windows
从 https://sourceforge.net/projects/gnuwin32/files/make/ 下载并安装
## 架构说明
@@ -244,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 路由
## 完整构建流程示例
@@ -312,13 +267,8 @@ make build-linux
# 构建完成后,dist/home-vue-go 就是Linux可执行文件
```
**方式2:直接在Windows上构建(需要gcc工具链)**
**方式2:直接在 Windows 上交叉构建**
```bash
# 如果遇到交叉编译错误,需要安装gcc工具链
# 使用MSYS2安装:
# pacman -S mingw-w64-x86_64-gcc
# 然后运行
make build-linux
```
@@ -358,10 +308,10 @@ make build-linux
**1Panel配置:**
- 运行命令:`./home-vue-go`(或完整路径)
- 端口映射:1551(后端API)、1552(前端界面
- 端口映射:1552(统一服务端口
- 工作目录:可执行文件所在目录
**注意**由于项目使用SQLite(需要CGO),在Windows交叉编译Linux版本需要额外的工具链。推荐使用WSL或在Linux系统上直接构建
**注意**SQLite 使用纯 Go 驱动,因此从 Windows 交叉构建 Linux 版本需要额外的 C 工具链。
### 在 Linux 上构建 Windows 版本
@@ -392,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:5173API代理到统一服务端口
```
## 部署优势
**单一可执行文件**:前后端一体化,所有文件嵌入在二进制中
**无需依赖**:不需要Node.js、npm或其他运行时
**端口分离**API和前端服务分离,便于管理和扩展
**自动代理**:前端自动代理API请求,无需额外配置
**1Panel友好**:只需配置两个端口,运行一个命令即可
**端口服务**前端、API和上传文件共用一个端口
**1Panel友好**:只需配置一个端口,运行一个命令即可
**部署简单**:上传一个文件,配置端口,即可运行
**跨平台构建**:使用make统一构建流程,支持多平台
+162 -167
View File
@@ -1,189 +1,184 @@
.PHONY: generate build build-linux build-windows build-darwin clean run dist frontend backend
.PHONY: generate frontend backend backend-linux backend-windows backend-darwin \
build build-linux build-windows build-darwin clean clean-all dist run
# 生成Ent代码
.NOTPARALLEL:
GO ?= go
NPM ?= npm
GOPROXY ?= https://goproxy.cn,direct
DIST_DIR := dist
BINARY_NAME := home-vue-go
WINDOWS_TEMP := $(BINARY_NAME)-windows-amd64.exe
LINUX_TEMP := $(BINARY_NAME)-linux-amd64
DARWIN_TEMP := $(BINARY_NAME)-darwin-amd64
ALL_WINDOWS_TEMP := $(BINARY_NAME)-all-windows-amd64.exe
ALL_LINUX_TEMP := $(BINARY_NAME)-all-linux-amd64
ALL_DARWIN_TEMP := $(BINARY_NAME)-all-darwin-amd64
ifeq ($(OS),Windows_NT)
NPM_CMD := npm.cmd
else
NPM_CMD := $(NPM)
endif
# Generated Ent sources are committed. Run this target only after schema changes.
generate:
cd internal/ent && go generate ./...
@echo [generate] Updating Ent sources...
ifeq ($(OS),Windows_NT)
@cd internal\ent && set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOARCH=amd64" && $(GO) generate ./...
else
@cd internal/ent && GOPROXY="$(GOPROXY)" $(GO) generate ./...
endif
@echo [generate] Done.
# 构建前端
# Install missing packages without deleting a usable local dependency tree.
frontend:
npm install
npm run build
@echo [frontend] Installing dependencies...
@$(NPM_CMD) install --prefer-offline --no-audit --no-fund
@echo [frontend] Building production assets...
@$(NPM_CMD) run build
# 构建后端(Linux
backend-linux:
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o dist/home-vue-go main.go
chmod +x dist/home-vue-go
# 构建后端(Windows
backend-windows:
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o dist/home-vue-go.exe main.go
# 构建后端(macOS
backend-darwin:
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o dist/home-vue-go main.go
chmod +x dist/home-vue-go
# 构建后端(当前平台)
backend:
CGO_ENABLED=1 go build -ldflags="-s -w" -o dist/home-vue-go main.go
chmod +x dist/home-vue-go 2>/dev/null || true
# 完整构建到dist目录(Linux)- 单一可执行文件
build-linux: clean generate
@echo "========================================"
@echo "构建 Linux 版本"
@echo "========================================"
# Backend-only targets keep frontend files beside the executable for debugging.
backend-windows: frontend
@echo [backend] Building Windows amd64 executable...
ifeq ($(OS),Windows_NT)
@if not exist dist mkdir dist
@call npm install && call npm run build
@if not exist dist\index.html (echo 错误: 前端构建失败 && exit 1)
@echo [提示] 在Windows上交叉编译Linux版本需要gcc工具链
@echo [提示] 如果遇到错误,推荐使用WSL或在Linux系统上直接构建
@echo [开始编译]...
@set CGO_ENABLED=1
@set GOOS=linux
@set GOARCH=amd64
@go build -ldflags="-s -w" -o dist\home-vue-go main.go
@if errorlevel 1 (
@echo.
@echo [错误] 交叉编译失败
@echo [原因] 在Windows上交叉编译Linux版本需要Linux的gcc工具链
@echo.
@echo [解决方案1] 使用WSL(推荐):
@echo wsl
@echo cd /mnt/d/Desktop/Home-Vue-go
@echo make build-linux
@echo.
@echo [解决方案2] 在Linux服务器上直接构建:
@echo git clone ^<your-repo^>
@echo cd Home-Vue-go
@echo make build-linux
@echo.
@echo [解决方案3] 安装gcc工具链(复杂):
@echo - 使用MSYS2: pacman -S mingw-w64-x86_64-gcc
@echo - 或使用TDM-GCC
@exit /b 1
)
@if exist dist\static rmdir /s /q dist\static 2>nul
@if exist dist\index.html del /f /q dist\index.html 2>nul
@if exist dist\favicon.ico del /f /q dist\favicon.ico 2>nul
@echo.
@echo 构建完成!单一可执行文件: dist\home-vue-go
@echo 后端API: http://localhost:1551
@echo 前端界面: http://localhost:1552
@echo 1Panel配置: 只需配置1551和1552端口,运行命令: ./home-vue-go
@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
@mkdir -p dist
@npm install && npm run build
@if [ ! -f "dist/index.html" ]; then echo "错误: 前端构建失败"; exit 1; fi
@CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o dist/home-vue-go main.go
@chmod +x dist/home-vue-go
@rm -rf dist/static dist/index.html dist/favicon.ico 2>/dev/null || true
@echo ""
@echo "构建完成!单一可执行文件: dist/home-vue-go"
@echo "后端API: http://localhost:1551"
@echo "前端界面: http://localhost:1552"
@echo "1Panel配置: 只需配置1551和1552端口,运行命令: ./home-vue-go"
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME).exe .
endif
# 完整构建到dist目录(Windows)- 单一可执行文件
build-windows: clean generate
@echo ========================================
@echo 构建 Windows 版本
@echo ========================================
@if not exist dist mkdir dist
@npm install && npm run build
@if not exist dist\index.html (echo 错误: 前端构建失败 && exit 1)
@set CGO_ENABLED=1 && go build -ldflags="-s -w" -o dist\home-vue-go.exe main.go
@if exist dist\static rmdir /s /q dist\static
@if exist dist\index.html del /f /q dist\index.html
@if exist dist\favicon.ico del /f /q dist\favicon.ico
@echo.
@echo 构建完成!单一可执行文件: dist\home-vue-go.exe
@echo 后端API: http://localhost:1551
@echo 前端界面: http://localhost:1552
@echo 1Panel配置: 只需配置1551和1552端口,运行命令: ./home-vue-go.exe
# 完整构建到dist目录(macOS)- 单一可执行文件
build-darwin: clean generate
@echo "========================================"
@echo "构建 macOS 版本"
@echo "========================================"
backend-linux: frontend
@echo [backend] Building Linux amd64 executable...
ifeq ($(OS),Windows_NT)
@if not exist dist mkdir dist
@call npm install && call npm run build
@if not exist dist\index.html (echo 错误: 前端构建失败 && exit 1)
@set CGO_ENABLED=1 && set GOOS=darwin && set GOARCH=amd64 && go build -ldflags="-s -w" -o dist\home-vue-go main.go
@if exist dist\static rmdir /s /q dist\static 2>nul
@if exist dist\index.html del /f /q dist\index.html 2>nul
@if exist dist\favicon.ico del /f /q dist\favicon.ico 2>nul
@echo.
@echo 构建完成!单一可执行文件: dist\home-vue-go
@echo 后端API: http://localhost:1551
@echo 前端界面: http://localhost:1552
@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
@mkdir -p dist
@npm install && npm run build
@if [ ! -f "dist/index.html" ]; then echo "错误: 前端构建失败"; exit 1; fi
@CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o dist/home-vue-go main.go
@chmod +x dist/home-vue-go
@rm -rf dist/static dist/index.html dist/favicon.ico 2>/dev/null || true
@echo ""
@echo "构建完成!单一可执行文件: dist/home-vue-go"
@echo "后端API: http://localhost:1551"
@echo "前端界面: http://localhost:1552"
@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
# 完整构建到dist目录(当前平台)- 单一可执行文件
build: clean generate
@echo "========================================"
@echo "构建当前平台版本"
@echo "========================================"
backend-darwin: frontend
@echo [backend] Building macOS amd64 executable...
ifeq ($(OS),Windows_NT)
@if not exist dist mkdir dist
@call npm install && call npm run build
@if not exist dist\index.html (echo 错误: 前端构建失败 && exit 1)
@set CGO_ENABLED=1 && go build -ldflags="-s -w" -o dist\home-vue-go.exe main.go
@if exist dist\static rmdir /s /q dist\static 2>nul
@if exist dist\index.html del /f /q dist\index.html 2>nul
@if exist dist\favicon.ico del /f /q dist\favicon.ico 2>nul
@echo.
@echo 构建完成!单一可执行文件: dist\home-vue-go.exe
@echo 后端API: http://localhost:1551
@echo 前端界面: http://localhost:1552
@echo 1Panel配置: 只需配置1551和1552端口,运行命令: ./home-vue-go.exe
@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
@mkdir -p dist
@npm install && npm run build
@if [ ! -f "dist/index.html" ]; then echo "错误: 前端构建失败"; exit 1; fi
@CGO_ENABLED=1 go build -ldflags="-s -w" -o dist/home-vue-go main.go
@chmod +x dist/home-vue-go 2>/dev/null || true
@rm -rf dist/static dist/index.html dist/favicon.ico 2>/dev/null || true
@echo ""
@echo "构建完成!单一可执行文件: dist/home-vue-go"
@echo "后端API: http://localhost:1551"
@echo "前端界面: http://localhost:1552"
@echo "1Panel配置: 只需配置1551和1552端口,运行命令: ./home-vue-go"
@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
# 打包到dist目录(推荐使用)
backend: frontend
@echo [backend] Building for the current platform...
ifeq ($(OS),Windows_NT)
@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 -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) .
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
endif
# Full builds embed frontend assets, then remove the duplicate loose files.
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 -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 -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
@echo [build] Output: $(DIST_DIR)/$(BINARY_NAME).exe
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 -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 -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)
endif
@echo [build] Output: $(DIST_DIR)/$(BINARY_NAME)
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 -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 -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)
endif
@echo [build] Output: $(DIST_DIR)/$(BINARY_NAME)
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 -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 -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 -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 -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 -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 -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
@mv $(ALL_DARWIN_TEMP) $(DIST_DIR)/$(BINARY_NAME)-darwin-amd64
@chmod +x $(DIST_DIR)/$(BINARY_NAME)-linux-amd64 $(DIST_DIR)/$(BINARY_NAME)-darwin-amd64
endif
@echo [build] Output: $(DIST_DIR)/$(BINARY_NAME)-windows-amd64.exe
@echo [build] Output: $(DIST_DIR)/$(BINARY_NAME)-linux-amd64
@echo [build] Output: $(DIST_DIR)/$(BINARY_NAME)-darwin-amd64
dist: build
# 运行开发服务器
run:
go run main.go
# 清理
clean:
@echo "清理构建文件..."
run: frontend
ifeq ($(OS),Windows_NT)
@if exist home-vue-go.exe del /f /q home-vue-go.exe 2>nul
@if exist home-vue-go del /f /q home-vue-go 2>nul
@if exist dist rmdir /s /q dist 2>nul
@if exist node_modules rmdir /s /q node_modules 2>nul
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && $(GO) run .
else
@rm -f home-vue-go home-vue-go.exe 2>/dev/null || true
@rm -rf dist 2>/dev/null || true
@rm -rf node_modules 2>/dev/null || true
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 $(GO) run .
endif
@echo "清理完成"
clean:
@echo [clean] Removing build artifacts...
ifeq ($(OS),Windows_NT)
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
@if exist $(BINARY_NAME).exe del /f /q $(BINARY_NAME).exe
@if exist $(WINDOWS_TEMP) del /f /q $(WINDOWS_TEMP)
@if exist $(LINUX_TEMP) del /f /q $(LINUX_TEMP)
@if exist $(DARWIN_TEMP) del /f /q $(DARWIN_TEMP)
@if exist $(ALL_WINDOWS_TEMP) del /f /q $(ALL_WINDOWS_TEMP)
@if exist $(ALL_LINUX_TEMP) del /f /q $(ALL_LINUX_TEMP)
@if exist $(ALL_DARWIN_TEMP) del /f /q $(ALL_DARWIN_TEMP)
else
@rm -rf $(DIST_DIR)
@rm -f $(BINARY_NAME) $(BINARY_NAME).exe $(WINDOWS_TEMP) $(LINUX_TEMP) $(DARWIN_TEMP) $(ALL_WINDOWS_TEMP) $(ALL_LINUX_TEMP) $(ALL_DARWIN_TEMP)
endif
@echo [clean] Done.
clean-all: clean
@echo [clean] Removing frontend dependencies...
ifeq ($(OS),Windows_NT)
@if exist node_modules rmdir /s /q node_modules
else
@rm -rf node_modules
endif
@echo [clean] Dependency cleanup complete.
+46 -46
View File
@@ -57,9 +57,9 @@ npm install
go mod download
```
#### 3. 生成Ent代码(必须
#### 3. 生成 Ent 代码(仅修改 Schema 后
**重要:** 在运行项目之前,必须先生成Ent代码,否则Go代码无法编译。
生成后的 Ent 源码已提交到仓库,普通运行和构建不需要重复生成。只有修改 `internal/ent/schema` 后才运行:
```bash
# 进入ent目录
@@ -72,32 +72,22 @@ go generate ./...
cd ../..
```
**如果遇到版本兼容性错误**(如 `invalid array length``Deprecated undefined`),使用:
```bash
cd internal/ent
# 使用与go.mod一致的版本(当前是v0.13.1
go run -mod=mod entgo.io/ent/cmd/ent@v0.13.1 generate ./schema
cd ../..
```
**重要**:版本号必须与 `go.mod` 中的 `entgo.io/ent` 版本一致。
**说明:**
- `go generate` 是Go的内置命令,用于运行代码生成工具
- `./...` 表示当前目录及所有子目录
- 这会根据 `schema/` 目录中的定义生成Ent ORM代码
- 如果Go版本较新(如go1.25+),可能需要使用 `go run` 方式
- 生成器使用 `go.mod` 中固定的 Ent 版本,不会使用 `@latest`
#### 4. 运行项目
**开发模式:**
需要打开两个终端窗口
生产运行只需要启动一个服务。需要前端热更新时,可额外启动 Vite 开发服务器
**终端1 - 启动Go后端**
**终端1 - 启动统一服务**
```bash
# 在项目根目录运行
go run main.go
make run
```
**终端2 - 启动前端开发服务器:**
@@ -107,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
@@ -123,6 +113,7 @@ chcp 65001
- 前端:http://localhost:1552
- 管理界面:http://localhost:1552/admin
- 登录页面:http://localhost:1552/login
- APIhttp://localhost:1552/api
**默认管理员账号:**
- 用户名:`admin`
@@ -132,28 +123,23 @@ chcp 65001
#### 5. 构建部署
**构建Go后端(Linux**
**构建 Linux 单文件程序**
```bash
# 生成Ent代码(必须)
cd internal/ent
go generate ./...
cd ../..
# 构建Linux二进制文件
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o home-vue-go main.go
make build-linux
```
**构建Go后端(Windows**
```bash
# 生成Ent代码(必须)
cd internal\ent
go generate ./...
cd ..\..
# 构建Windows二进制文件
go build -o home-vue-go.exe main.go
**构建 Windows 单文件程序**
```powershell
make build-windows
```
**构建全部平台:**
```bash
make build
```
该命令会生成 Windows、Linux 和 macOS amd64 可执行文件;需要单独构建某个平台时使用对应的 `make build-windows``make build-linux``make build-darwin`
**构建前端:**
```bash
npm install
@@ -161,12 +147,12 @@ npm run build
```
构建完成后:
- Go二进制文件:`./home-vue-go` (Linux) 或 `./home-vue-go.exe` (Windows)
- 前端构建文件:`./dist`
- `make build` 的发布包位于 `./dist/`
- 前端资源已经嵌入每个平台的二进制文件,部署时只需要对应的可执行文件
**说明:**
- `go build` 编译Go程序为二进制文件
- `CGO_ENABLED=1` 启用CGOSQLite需要)
- SQLite 使用纯 Go 驱动,构建不需要 CGO 或 GCC
- `GOOS=linux GOARCH=amd64` 指定目标平台和架构
- `-o` 指定输出文件名
@@ -174,7 +160,6 @@ npm run build
1. **上传文件到服务器:**
- 上传 `home-vue-go` 二进制文件
- 上传 `dist` 目录(前端构建文件)
2. **运行二进制文件:**
```bash
@@ -188,7 +173,7 @@ npm run build
4. **环境变量(可选):**
```bash
export PORT=1551 # 服务端口,默认1551
export PORT=1552 # 统一服务端口,默认1552
export JWT_SECRET=your-secret-key # JWT密钥,建议修改
```
@@ -254,13 +239,13 @@ go mod download
cd internal/ent && go generate ./... && cd ../..
# 运行后端(开发模式)
go run main.go
make run
# 构建后端(当前平台)
go build -o home-vue-go main.go
CGO_ENABLED=0 go build -o home-vue-go .
# 构建后端(Linux
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o home-vue-go main.go
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o home-vue-go .
# 查看Go版本
go version
@@ -284,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` 文件。原项目版权声明及许可条款予以保留。
+22 -7
View File
@@ -1,13 +1,13 @@
module home-vue-go
go 1.23
go 1.23.0
require (
entgo.io/ent v0.14.5
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/mattn/go-sqlite3 v1.14.22
golang.org/x/crypto v0.23.0
golang.org/x/crypto v0.33.0
modernc.org/sqlite v1.36.3
)
require (
@@ -19,6 +19,7 @@ require (
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-openapi/inflect v0.19.0 // indirect
@@ -27,27 +28,41 @@ require (
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.8.0 // indirect
github.com/spf13/cobra v1.7.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
golang.org/x/mod v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/tools v0.30.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
)
+114 -4
View File
@@ -6,6 +6,10 @@ github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20O
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhiM5J5RFxEaFvMZVEAM1KvT1YzbEOwB2EAGjA=
github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
@@ -14,13 +18,19 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -43,23 +53,42 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo=
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465 h1:KwWnWVWCNtNq/ewIX7HIKnELmEx2nDP42yskD/pi7QE=
github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1 h1:0pHpWtx9vcvC0xGZqEQlQdfSQs7WRlAjuPvk3fOZDCo=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -70,27 +99,47 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -104,34 +153,95 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8=
github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b h1:FosyBZYxY34Wul7O/MSKey3txpPYyCqVO5ZyceuQJEI=
github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8=
github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v3 v3.17.0 h1:o3OmOqx4/OFnl4Vm3G8Bgmqxnvxnh0nbxeT5p/dWChA=
modernc.org/ccgo/v3 v3.17.0/go.mod h1:Sg3fwVpmLvCUTaqEUjiBDAvshIaKDB0RXaf+zgqFu8I=
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.36.3 h1:qYMYlFR+rtLDUzuXoST1SDIdEPbX8xzuhdF90WsX1ss=
modernc.org/sqlite v1.36.3/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0 h1:xGFgVi5ZaWOnYdac2foDT3vg0ZZC9ErXFV57mr4OHrI=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+22 -9
View File
@@ -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 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
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
}
+132
View File
@@ -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)
}
}
+3
View File
@@ -279,6 +279,9 @@ func validateSiteSettings(settings *config.SiteSettings) error {
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return fmt.Errorf("关于链接必须是有效的 http/https 地址")
}
if strings.TrimSpace(link.Icon) != "" && !validIconValue(link.Icon) {
return fmt.Errorf("关于链接图标格式不正确")
}
}
return nil
}
+2 -2
View File
@@ -149,7 +149,7 @@ func CreateContact(db *database.Database) gin.HandlerFunc {
req.URL = strings.TrimSpace(req.URL)
req.QrCode = strings.TrimSpace(req.QrCode)
req.HoverColor = strings.TrimSpace(req.HoverColor)
if req.Type == "" || req.Icon == "" {
if req.Type == "" || !validIconValue(req.Icon) {
c.JSON(http.StatusBadRequest, gin.H{"error": "类型和图标不能为空"})
return
}
@@ -231,7 +231,7 @@ func UpdateContact(db *database.Database) gin.HandlerFunc {
req.URL = strings.TrimSpace(req.URL)
req.QrCode = strings.TrimSpace(req.QrCode)
req.HoverColor = strings.TrimSpace(req.HoverColor)
if req.Type == "" || req.Icon == "" {
if req.Type == "" || !validIconValue(req.Icon) {
c.JSON(http.StatusBadRequest, gin.H{"error": "类型和图标不能为空"})
return
}
+72
View File
@@ -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 -2
View File
@@ -139,7 +139,7 @@ func CreateSite(db *database.Database) gin.HandlerFunc {
return
}
req.Name, req.URL, req.Icon = strings.TrimSpace(req.Name), strings.TrimSpace(req.URL), strings.TrimSpace(req.Icon)
if req.Name == "" || req.URL == "" || req.Icon == "" || !validHTTPURL(req.URL) {
if req.Name == "" || req.URL == "" || !validIconValue(req.Icon) || !validHTTPURL(req.URL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称、图标不能为空,URL必须是有效的http/https地址"})
return
}
@@ -187,7 +187,7 @@ func UpdateSite(db *database.Database) gin.HandlerFunc {
return
}
req.Name, req.URL, req.Icon = strings.TrimSpace(req.Name), strings.TrimSpace(req.URL), strings.TrimSpace(req.Icon)
if req.Name == "" || req.URL == "" || req.Icon == "" || !validHTTPURL(req.URL) {
if req.Name == "" || req.URL == "" || !validIconValue(req.Icon) || !validHTTPURL(req.URL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称、图标不能为空,URL必须是有效的http/https地址"})
return
}
+19
View File
@@ -3,9 +3,17 @@ package api
import (
"net/url"
"path"
"regexp"
"strings"
)
const maxIconValueLength = 160
var (
legacyIconPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+(?:\s+[A-Za-z0-9_-]+)*$`)
iconifyValuePattern = regexp.MustCompile(`^iconify:[a-z0-9]+(?:-[a-z0-9]+)*:[a-z0-9]+(?:-[a-z0-9]+)*$`)
)
func validHTTPURL(value string) bool {
parsed, err := url.ParseRequestURI(strings.TrimSpace(value))
return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
@@ -30,3 +38,14 @@ func validQRCode(value string) bool {
}
return validHTTPURL(value)
}
func validIconValue(value string) bool {
value = strings.TrimSpace(value)
if value == "" || len(value) > maxIconValueLength {
return false
}
if strings.HasPrefix(value, "iconify:") {
return iconifyValuePattern.MatchString(value)
}
return legacyIconPattern.MatchString(value)
}
+49 -1
View File
@@ -1,6 +1,11 @@
package api
import "testing"
import (
"strings"
"testing"
"home-vue-go/internal/config"
)
func TestURLValidation(t *testing.T) {
for _, value := range []string{"https://example.com", "http://localhost:8080"} {
@@ -30,6 +35,49 @@ func TestURLValidation(t *testing.T) {
}
}
func TestIconValueValidation(t *testing.T) {
for _, value := range []string{
"fas fa-home",
"fab fa-github",
"iconify:mdi:home",
"iconify:material-symbols:add-home-outline",
} {
if !validIconValue(value) {
t.Errorf("expected valid icon value: %s", value)
}
}
for _, value := range []string{
"",
"iconify:MDI:home",
"iconify:mdi:",
"iconify:mdi:home/../../x",
"fas fa-home\"><script>",
strings.Repeat("a", maxIconValueLength+1),
} {
if validIconValue(value) {
t.Errorf("expected invalid icon value: %s", value)
}
}
}
func TestAboutLinkIconValidation(t *testing.T) {
settings := config.DefaultSiteSettings()
settings.AboutLinks[0].Icon = ""
if err := validateSiteSettings(settings); err != nil {
t.Fatalf("expected empty about link icon to use the frontend fallback: %v", err)
}
settings.AboutLinks[0].Icon = "iconify:tabler:brand-github"
if err := validateSiteSettings(settings); err != nil {
t.Fatalf("expected valid Iconify about link: %v", err)
}
settings.AboutLinks[0].Icon = "<script>"
if err := validateSiteSettings(settings); err == nil {
t.Fatal("expected invalid about link icon to be rejected")
}
}
func TestPercentageCountsSumsToOneHundred(t *testing.T) {
values := percentageCounts(map[string]int{"direct": 1, "search": 1, "other": 1}, 3)
total := 0
+2 -2
View File
@@ -16,8 +16,8 @@ import (
"home-vue-go/internal/ent"
"home-vue-go/internal/ent/migrate"
_ "github.com/mattn/go-sqlite3"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
const siteConfigPayloadColumn = "config_json"
@@ -28,7 +28,7 @@ type Database struct {
}
func Init(dbPath string, cfg *config.Config) (*Database, error) {
db, err := sql.Open("sqlite3", dbPath+"?_fk=1")
db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)")
if err != nil {
return nil, err
}
-4
View File
@@ -4,7 +4,6 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"home-vue-go/internal/config"
@@ -25,9 +24,6 @@ func TestSiteSettingsMigrationAndBooleanPersistence(t *testing.T) {
cfg := config.New(dataDir)
db, err := Init(cfg.DatabasePath, cfg)
if err != nil {
if strings.Contains(err.Error(), "CGO_ENABLED=0") {
t.Skip("go-sqlite3 requires cgo for the database integration test")
}
t.Fatal(err)
}
defer db.Close()
+1 -1
View File
@@ -1,3 +1,3 @@
package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent@v0.14.5 generate ./schema
//go:generate go run -mod=readonly entgo.io/ent/cmd/ent generate ./schema
+76 -250
View File
@@ -2,15 +2,15 @@ package main
import (
"embed"
"io"
"io/fs"
"log"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
_ "time/tzdata"
"home-vue-go/internal/api"
"home-vue-go/internal/config"
@@ -23,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.htmlSPA路由支持)
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
}
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.htmlSPA路由)
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)
}
})
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("警告: 未找到前端文件,前端功能不可用")
}
if err != nil {
log.Fatal("无法加载嵌入的前端文件:", err)
}
router.NoRoute(serveFrontend(distRoot))
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)
}
}()
// 在主goroutine中启动API服务器
if err := apiServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("API服务器启动失败: %v", err)
server := &http.Server{
Addr: ":" + port,
Handler: router,
}
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 != "" {
@@ -287,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")
@@ -300,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
View File
@@ -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)
}
}
+1503 -59
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -6,10 +6,12 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@iconify/vue": "5.0.1",
"@vueuse/motion": "^2.2.5",
"axios": "^1.7.7",
"less": "^4.2.0",
@@ -20,6 +22,9 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"vite": "^5.4.1"
"@vue/test-utils": "2.4.11",
"jsdom": "25.0.1",
"vite": "^5.4.1",
"vitest": "2.1.9"
}
}
+3 -2
View File
@@ -26,7 +26,7 @@
:rel="config.openLinksInNewTab ? 'noopener noreferrer' : undefined"
class="github-link"
>
<i :class="link.icon || 'fas fa-link'" aria-hidden="true"></i>
<AppIcon :icon="link.icon || 'fas fa-link'" fallback="fas fa-link" aria-hidden="true" />
<div class="link-content">
<span class="link-title">{{ link.title }}</span>
<span class="link-desc">{{ link.description }}</span>
@@ -55,6 +55,7 @@ import sqliteLogo from '@fortawesome/fontawesome-free/svgs/solid/database.svg?ra
import entLogo from '@fortawesome/fontawesome-free/svgs/solid/code-branch.svg?raw';
import jwtLogo from '@fortawesome/fontawesome-free/svgs/solid/key.svg?raw';
import { loadPublicConfig, publicConfig as config } from '../composables/usePublicConfig';
import AppIcon from './AppIcon.vue'
const emit = defineEmits(['close']);
@@ -221,7 +222,7 @@ h3 {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
i {
:deep(.app-icon) {
font-size: 1.5em;
flex-shrink: 0;
}
+30 -22
View File
@@ -72,6 +72,7 @@
@remove-text="removeRotatingText"
@save-texts="saveRotatingTexts"
@test-analytics="testAnalytics"
@pick-icon="openIconPicker('about', $event)"
/>
<AdminCollection
v-else-if="activeTab === '站点管理'"
@@ -115,7 +116,7 @@
<form id="site-form" class="modal-form" @submit.prevent="saveSite">
<label><span>名称 <b>*</b></span><input v-model.trim="siteForm.name" type="text" required placeholder="例如:个人博客" /></label>
<label><span>URL <b>*</b></span><input v-model.trim="siteForm.url" type="url" required placeholder="https://example.com" /></label>
<label class="field-full"><span>图标类名 <b>*</b></span><div class="icon-input"><i :class="siteForm.icon || 'fas fa-icons'"></i><input v-model.trim="siteForm.icon" type="text" required placeholder="fas fa-link" /><button type="button" @click="openIconPicker('site')"><i class="fas fa-icons"></i>选择</button></div></label>
<label class="field-full"><span>图标标识 <b>*</b></span><div class="icon-input"><AppIcon class="icon-input-preview" :icon="siteForm.icon" /><input v-model.trim="siteForm.icon" type="text" required placeholder="fas fa-link 或 iconify:mdi:home" /><button type="button" @click="openIconPicker('site')"><i class="fas fa-icons"></i>选择</button></div></label>
<label><span>排序</span><input v-model.number="siteForm.sortOrder" type="number" min="0" /></label>
</form>
<template #footer><button type="button" class="secondary-button" @click="closeSiteForm">取消</button><button form="site-form" type="submit" class="primary-button" :disabled="actionLoading">{{ actionLoading ? '正在保存' : '保存站点' }}</button></template>
@@ -125,7 +126,7 @@
<form id="contact-form" class="modal-form" @submit.prevent="saveContact">
<label><span>类型 <b>*</b></span><select v-model="contactForm.type"><option v-for="type in contactTypes" :key="type">{{ type }}</option></select></label>
<label><span>排序</span><input v-model.number="contactForm.sortOrder" type="number" min="0" /></label>
<label class="field-full"><span>图标类名 <b>*</b></span><div class="icon-input"><i :class="contactForm.icon || 'fas fa-icons'" :style="{ color: contactForm.hoverColor }"></i><input v-model.trim="contactForm.icon" type="text" required placeholder="fas fa-envelope" /><button type="button" @click="openIconPicker('contact')"><i class="fas fa-icons"></i>选择</button></div></label>
<label class="field-full"><span>图标标识 <b>*</b></span><div class="icon-input"><AppIcon class="icon-input-preview" :icon="contactForm.icon" :style="{ color: contactForm.hoverColor }" /><input v-model.trim="contactForm.icon" type="text" required placeholder="fas fa-envelope 或 iconify:mdi:email" /><button type="button" @click="openIconPicker('contact')"><i class="fas fa-icons"></i>选择</button></div></label>
<label v-if="!contactUsesQr" class="field-full"><span>链接 <b>*</b></span><input v-model.trim="contactForm.url" type="text" required :placeholder="contactForm.type === 'Email' ? 'mailto:name@example.com' : 'https://example.com'" /><small v-if="contactForm.type === 'Email'">Email 必须使用 mailto: 格式</small></label>
<div v-else class="field-full selector-field"><span>二维码图片 <b>*</b></span><IconSelector v-model="contactForm.qrCode" :default-icon-path="''" /></div>
<label><span>悬停颜色</span><div class="color-input"><input v-model="colorPickerValue" type="color" /><input v-model.trim="contactForm.hoverColor" type="text" pattern="#[0-9a-fA-F]{6}" placeholder="#555555" /><button type="button" title="清空颜色" aria-label="清空颜色" @click="contactForm.hoverColor = ''"><i class="fas fa-eraser" aria-hidden="true"></i></button></div></label>
@@ -133,16 +134,17 @@
<template #footer><button type="button" class="secondary-button" @click="closeContactForm">取消</button><button form="contact-form" type="submit" class="primary-button" :disabled="actionLoading">{{ actionLoading ? '正在保存' : '保存联系方式' }}</button></template>
</AdminModal>
<AdminModal :open="iconPickerOpen" title="选择 Font Awesome 图标" size="large" @close="iconPickerOpen = false">
<AdminModal :open="iconPickerOpen" title="选择图标" size="large" @close="iconPickerOpen = false">
<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>
@@ -162,6 +164,8 @@ 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'
import IconSelector from './IconSelector.vue'
@@ -196,6 +200,7 @@ const contactModalOpen = ref(false)
const iconPickerOpen = ref(false)
const passwordModalOpen = ref(false)
const iconPickerTarget = ref('site')
const iconPickerAboutLink = ref(null)
const editingSite = ref(null)
const editingContact = ref(null)
const confirmAction = ref(null)
@@ -223,27 +228,26 @@ const currentTab = computed(() => tabs.find((tab) => tab.name === activeTab.valu
const brandIcon = computed(() => siteConfig.siteIcon || siteConfig.favicon || '/favicon.ico')
const contactUsesQr = computed(() => ['支付宝', '微信'].includes(contactForm.type))
const iconPickerValue = computed({
get: () => iconPickerTarget.value === 'site' ? siteForm.icon : contactForm.icon,
set: (value) => { if (iconPickerTarget.value === 'site') siteForm.icon = value; else contactForm.icon = value },
get: () => iconPickerTarget.value === 'site'
? siteForm.icon
: iconPickerTarget.value === 'contact'
? contactForm.icon
: iconPickerAboutLink.value?.icon || '',
set: (value) => {
if (iconPickerTarget.value === 'site') siteForm.icon = value
else if (iconPickerTarget.value === 'contact') contactForm.icon = value
else if (iconPickerAboutLink.value) iconPickerAboutLink.value.icon = value
},
})
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 || ''}”吗?`)
@@ -381,7 +385,11 @@ const saveContactOrder = async (ids) => {
} finally { actionLoading.value = false }
}
const openIconPicker = (target) => { iconPickerTarget.value = target; iconPickerOpen.value = true }
const openIconPicker = (target, link = null) => {
iconPickerTarget.value = target
iconPickerAboutLink.value = link
iconPickerOpen.value = true
}
const openSiteForm = (site = null) => {
editingSite.value = site
Object.assign(siteForm, site ? { name: site.name, url: site.url, icon: site.icon, sortOrder: site.sortOrder } : { name: '', url: '', icon: '', sortOrder: nextSortOrder(sites.value) })
@@ -556,7 +564,7 @@ onUnmounted(() => { window.clearTimeout(toastTimer); configChannel?.close() })
.modal-form small { color: var(--text-muted); font-size: 11px; }
.field-full { grid-column: 1 / -1; }
.icon-input { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; }
.icon-input > i { height: 40px; display: grid; place-items: center; border: 1px solid var(--border-color); border-right: 0; border-radius: 6px 0 0 6px; background: var(--surface-muted); }
.icon-input > .app-icon { height: 40px; display: grid; place-items: center; border: 1px solid var(--border-color); border-right: 0; border-radius: 6px 0 0 6px; background: var(--surface-muted); }
.icon-input input { border-radius: 0; }
.icon-input button { height: 40px; display: inline-flex; align-items: center; gap: 6px; padding: 0 11px; border: 1px solid var(--border-color); border-left: 0; border-radius: 0 6px 6px 0; color: var(--text-color); background: var(--surface-solid); cursor: pointer; }
.color-input { display: grid; grid-template-columns: 48px minmax(0, 1fr) 40px; gap: 7px; }
+36
View File
@@ -0,0 +1,36 @@
import { nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
vi.mock('@iconify/vue', () => ({
Icon: { props: ['icon'], template: '<svg data-iconify="true" :data-name="icon"></svg>' },
loadIcon: vi.fn(() => Promise.resolve({ body: '<path />' })),
}))
import AppIcon from './AppIcon.vue'
import { loadIcon } from '@iconify/vue'
describe('AppIcon', () => {
it('renders legacy Font Awesome classes', () => {
const wrapper = mount(AppIcon, { props: { icon: 'fas fa-home' } })
expect(wrapper.find('i').classes()).toContain('fas')
expect(wrapper.find('i').classes()).toContain('fa-home')
})
it('renders Iconify after icon data is available', async () => {
const wrapper = mount(AppIcon, { props: { icon: 'iconify:mdi:home' } })
await nextTick()
await nextTick()
expect(wrapper.find('svg').attributes('data-name')).toBe('mdi:home')
})
it('uses the bundled fallback when Iconify loading fails', async () => {
loadIcon.mockRejectedValueOnce(new Error('offline'))
const wrapper = mount(AppIcon, { props: { icon: 'iconify:mdi:missing', fallback: 'fas fa-link' } })
await nextTick()
await nextTick()
expect(wrapper.find('svg').exists()).toBe(false)
expect(wrapper.find('i').classes()).toEqual(expect.arrayContaining(['fas', 'fa-link']))
})
})
+78
View File
@@ -0,0 +1,78 @@
<template>
<Icon
v-if="parsed.type === 'iconify' && loaded && !failed"
:icon="parsed.name"
:class="['app-icon', attrs.class]"
v-bind="forwardedAttrs"
/>
<i
v-else
:class="['app-icon', fallbackClass, attrs.class]"
v-bind="forwardedAttrs"
></i>
</template>
<script setup>
import { Icon, loadIcon } from '@iconify/vue'
import { computed, onUnmounted, ref, useAttrs, watch } from 'vue'
import { DEFAULT_ICON, parseIconValue } from '../utils/iconValue'
defineOptions({ inheritAttrs: false })
const props = defineProps({
icon: { type: String, default: '' },
fallback: { type: String, default: DEFAULT_ICON },
})
const attrs = useAttrs()
const loaded = ref(false)
const failed = ref(false)
const parsed = computed(() => parseIconValue(props.icon))
const fallbackClass = computed(() => parsed.value.type === 'class' && parsed.value.className
? parsed.value.className
: props.fallback)
const forwardedAttrs = computed(() => {
const { class: _class, ...rest } = attrs
return { 'aria-hidden': 'true', ...rest }
})
let loadSequence = 0
let timeoutId = 0
watch(parsed, async (next) => {
loadSequence += 1
const sequence = loadSequence
window.clearTimeout(timeoutId)
loaded.value = next.type !== 'iconify'
failed.value = next.type === 'invalid'
if (next.type !== 'iconify') return
timeoutId = window.setTimeout(() => {
if (sequence === loadSequence) failed.value = true
}, 7000)
try {
await loadIcon(next.name)
if (sequence === loadSequence) loaded.value = true
} catch {
if (sequence === loadSequence) failed.value = true
} finally {
if (sequence === loadSequence) window.clearTimeout(timeoutId)
}
}, { immediate: true })
onUnmounted(() => {
loadSequence += 1
window.clearTimeout(timeoutId)
})
</script>
<style scoped>
.app-icon {
display: inline-block;
flex: 0 0 auto;
width: 1em;
height: 1em;
vertical-align: -0.125em;
}
</style>
+3 -2
View File
@@ -46,7 +46,7 @@
:style="{ '--hover-color': contact.hoverColor || 'var(--hover-link-color)' }"
:aria-label="contact.type"
>
<i :class="contact.icon" aria-hidden="true"></i>
<AppIcon :icon="contact.icon" aria-hidden="true" />
<span class="tooltip">{{ contact.type }}</span>
</a>
<button
@@ -57,7 +57,7 @@
:aria-label="`查看${contact.type}二维码`"
@click="showQRCode(contact.qrCode)"
>
<i :class="contact.icon" aria-hidden="true"></i>
<AppIcon :icon="contact.icon" aria-hidden="true" />
<span class="tooltip">{{ contact.type }}</span>
</button>
</template>
@@ -95,6 +95,7 @@ import { useTheme } from '../composables/useTheme'
import { applyAnalytics, recordHomePageView } from '../composables/useAnalytics'
import { loadPublicConfig, publicConfig as config } from '../composables/usePublicConfig'
import AboutPage from './AboutPage.vue'
import AppIcon from './AppIcon.vue'
import VisitTimer from './VisitTimer.vue'
import Website from './Website.vue'
+148
View File
@@ -0,0 +1,148 @@
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
const iconifyMocks = vi.hoisted(() => ({
getIconCollections: vi.fn(),
getIconCollection: vi.fn(),
getRecentIcons: vi.fn(),
rememberIcon: vi.fn(),
searchIcons: vi.fn(),
}))
vi.mock('../services/iconify', () => iconifyMocks)
vi.mock('@iconify/vue', () => ({
Icon: { props: ['icon'], template: '<svg :data-name="icon"></svg>' },
loadIcon: vi.fn(() => Promise.resolve({ body: '<path />' })),
}))
import IconPicker from './IconPicker.vue'
const collections = [{
prefix: 'lucide',
name: 'Lucide',
total: 1,
category: 'UI',
samples: ['home'],
author: { name: 'Lucide', url: '' },
license: { title: 'ISC', url: '' },
}]
const deferred = () => {
let resolve
let reject
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
describe('IconPicker', () => {
beforeEach(() => {
iconifyMocks.getIconCollections.mockReset().mockResolvedValue(collections)
iconifyMocks.getIconCollection.mockReset().mockResolvedValue({ info: collections[0], icons: ['lucide:home'] })
iconifyMocks.getRecentIcons.mockReset().mockReturnValue([])
iconifyMocks.rememberIcon.mockReset().mockImplementation((value) => [value])
iconifyMocks.searchIcons.mockReset().mockResolvedValue({ icons: [], total: 0, start: 0, limit: 64, collections: {} })
})
afterEach(() => {
vi.useRealTimers()
})
it('selects a local icon and emits the persisted value', async () => {
const wrapper = mount(IconPicker, { props: { modelValue: '' } })
await nextTick()
const button = wrapper.find('button[aria-label="选择图标 fas fa-house"]')
expect(button.exists()).toBe(true)
await button.trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['fas fa-house'])
})
it('debounces online searches for 300 milliseconds', async () => {
vi.useFakeTimers()
const wrapper = mount(IconPicker)
await wrapper.find('input[aria-label="搜索全部图标"]').setValue('home')
vi.advanceTimersByTime(299)
expect(iconifyMocks.searchIcons).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
await nextTick()
expect(iconifyMocks.searchIcons).toHaveBeenCalledTimes(1)
expect(iconifyMocks.searchIcons.mock.calls[0][0]).toBe('home')
})
it('ignores a stale search response after a newer query wins', async () => {
vi.useFakeTimers()
const first = deferred()
const second = deferred()
iconifyMocks.searchIcons
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => second.promise)
const wrapper = mount(IconPicker)
const input = wrapper.find('input[aria-label="搜索全部图标"]')
await input.setValue('home')
vi.advanceTimersByTime(300)
await nextTick()
await input.setValue('user')
vi.advanceTimersByTime(300)
await nextTick()
second.resolve({ icons: ['mdi:account'], total: 1, start: 0, limit: 64, collections: {} })
await flushPromises()
first.resolve({ icons: ['mdi:home'], total: 1, start: 0, limit: 64, collections: {} })
await flushPromises()
expect(wrapper.find('button[aria-label="选择图标 mdi:account"]').exists()).toBe(true)
expect(wrapper.find('button[aria-label="选择图标 mdi:home"]').exists()).toBe(false)
})
it('loads the next search page and keeps the first page results', async () => {
vi.useFakeTimers()
const firstPage = Array.from({ length: 64 }, (_, index) => `mdi:test-${index}`)
iconifyMocks.searchIcons
.mockResolvedValueOnce({ icons: firstPage, total: 65, start: 0, limit: 64, collections: {} })
.mockResolvedValueOnce({ icons: ['mdi:test-more'], total: 65, start: 64, limit: 999, collections: {} })
const wrapper = mount(IconPicker)
await wrapper.find('input[aria-label="搜索全部图标"]').setValue('test')
vi.advanceTimersByTime(300)
await flushPromises()
await wrapper.find('button.load-more').trigger('click')
await flushPromises()
expect(iconifyMocks.searchIcons.mock.calls[1][1]).toMatchObject({ start: 64, limit: 999 })
expect(wrapper.find('button[aria-label="选择图标 mdi:test-0"]').exists()).toBe(true)
expect(wrapper.find('button[aria-label="选择图标 mdi:test-more"]').exists()).toBe(true)
})
it('keeps local results available when every online host fails', async () => {
vi.useFakeTimers()
iconifyMocks.searchIcons.mockRejectedValueOnce(new Error('网络不可用'))
const wrapper = mount(IconPicker)
await wrapper.find('input[aria-label="搜索全部图标"]').setValue('house')
vi.advanceTimersByTime(300)
await flushPromises()
expect(wrapper.text()).toContain('无法读取在线图标')
expect(wrapper.find('button[aria-label="选择图标 fas fa-house"]').exists()).toBe(true)
})
it('stores an Iconify result using the iconify prefix', async () => {
vi.useFakeTimers()
iconifyMocks.searchIcons.mockResolvedValueOnce({ icons: ['mdi:home'], total: 1, start: 0, limit: 64, collections: {} })
const wrapper = mount(IconPicker)
await wrapper.find('input[aria-label="搜索全部图标"]').setValue('home')
vi.advanceTimersByTime(300)
await flushPromises()
await wrapper.find('button[aria-label="选择图标 mdi:home"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['iconify:mdi:home'])
})
})
+485 -400
View File
@@ -1,442 +1,527 @@
<template>
<div class="icon-picker">
<div class="icon-picker-header">
<input
v-model="searchQuery"
type="text"
placeholder="搜索图标..."
class="icon-search"
@input="filterIcons"
/>
</div>
<div class="icon-picker-body">
<div class="icon-categories">
<button
v-for="category in categories"
:key="category.name"
@click="activeCategory = category.name"
:class="['category-btn', { active: activeCategory === category.name }]"
>
<i :class="category.icon"></i>
<span>{{ category.label }}</span>
<div class="picker-toolbar">
<label class="search-box">
<i class="fas fa-magnifying-glass" aria-hidden="true"></i>
<input
v-model="searchQuery"
type="search"
autocomplete="off"
placeholder="搜索全部图标,例如 home、微信、mdi:account"
aria-label="搜索全部图标"
/>
<button v-if="searchQuery" type="button" title="清空搜索" aria-label="清空搜索" @click="searchQuery = ''">
<i class="fas fa-xmark" aria-hidden="true"></i>
</button>
</div>
<div class="icon-grid" ref="iconGrid">
<div
v-for="icon in filteredIcons"
:key="icon"
@click="selectIcon(icon)"
:class="['icon-item', { active: modelValue === icon }]"
:title="icon"
>
<i :class="icon"></i>
<span class="icon-name">{{ getIconName(icon) }}</span>
</label>
<span class="library-status" :class="{ offline: collectionsError }">
<i :class="collectionsError ? 'fas fa-triangle-exclamation' : 'fas fa-circle-nodes'" aria-hidden="true"></i>
{{ collectionsError ? '本地图标可用' : `${collections.length || '…'} 个图标集` }}
</span>
</div>
<div v-if="!isSearching" class="picker-tabs" role="tablist" aria-label="图标来源">
<button v-for="tab in tabs" :key="tab.name" type="button" role="tab" :aria-selected="activeTab === tab.name" :class="{ active: activeTab === tab.name }" @click="openTab(tab.name)">
<i :class="tab.icon" aria-hidden="true"></i>
<span>{{ tab.label }}</span>
<small v-if="tab.name === 'recent'">{{ recentIcons.length }}</small>
</button>
</div>
<main class="picker-content" :aria-busy="loading">
<section v-if="isSearching" class="result-section" aria-label="搜索结果">
<header class="content-heading">
<div><strong>搜索结果</strong><span>{{ searchSummary }}</span></div>
<button type="button" class="icon-button" title="重新搜索" aria-label="重新搜索" :disabled="loading || searchQuery.trim().length < 2" @click="runSearch(true)">
<i class="fas fa-rotate" :class="{ 'fa-spin': loading }" aria-hidden="true"></i>
</button>
</header>
<div v-if="localSearchResults.length" class="result-group">
<h3>本地匹配 <span>{{ localSearchResults.length }}</span></h3>
<IconGrid :icons="localSearchResults" :selected="modelValue" @select="selectIcon" />
</div>
<div class="result-group">
<h3>Iconify <span>{{ remoteIcons.length }}</span></h3>
<div v-if="loading && !remoteIcons.length" class="loading-grid" aria-label="正在搜索图标"><span v-for="index in 18" :key="index"></span></div>
<div v-else-if="searchError" class="state-panel state-panel--error">
<i class="fas fa-cloud-arrow-down" aria-hidden="true"></i>
<strong>无法读取在线图标</strong>
<span>{{ searchError }}</span>
<button type="button" class="secondary-action" @click="runSearch(true)"><i class="fas fa-rotate" aria-hidden="true"></i>重试</button>
</div>
<div v-else-if="searchQuery.trim().length < 2" class="state-panel">
<i class="fas fa-keyboard" aria-hidden="true"></i><strong>继续输入关键词</strong><span>在线搜索至少需要 2 个字符</span>
</div>
<IconGrid v-else-if="remoteIcons.length" :icons="remoteIcons" :selected="modelValue" :collections="searchCollections" @select="selectIcon" />
<div v-else-if="!loading" class="state-panel"><i class="fas fa-magnifying-glass" aria-hidden="true"></i><strong>没有找到匹配图标</strong></div>
<button v-if="searchHasMore && !searchError" type="button" class="load-more" :disabled="loading" @click="loadMoreSearch">
<i :class="loading ? 'fas fa-spinner fa-spin' : 'fas fa-chevron-down'" aria-hidden="true"></i>{{ loading ? '加载中' : '加载更多' }}
</button>
</div>
</section>
<section v-else-if="activeTab === 'local'" class="local-section" aria-label="本地常用图标">
<div class="local-categories" role="tablist" aria-label="本地图标分类">
<button v-for="category in localCategories" :key="category.name" type="button" :class="{ active: localCategory === category.name }" @click="localCategory = category.name">
<i :class="category.icon" aria-hidden="true"></i>{{ category.label }}
</button>
</div>
<IconGrid :icons="visibleLocalIcons" :selected="modelValue" @select="selectIcon" />
</section>
<section v-else-if="activeTab === 'recent'" class="recent-section" aria-label="最近使用图标">
<header class="content-heading"><div><strong>最近使用</strong><span>仅保存在当前浏览器</span></div></header>
<IconGrid v-if="recentIcons.length" :icons="recentIcons" :selected="modelValue" @select="selectIcon" />
<div v-else class="state-panel"><i class="fas fa-clock-rotate-left" aria-hidden="true"></i><strong>暂无最近使用图标</strong></div>
</section>
<section v-else class="collections-section" aria-label="Iconify 图标集">
<template v-if="selectedCollection">
<header class="collection-detail-heading">
<button type="button" class="back-button" @click="closeCollection"><i class="fas fa-arrow-left" aria-hidden="true"></i>全部图标集</button>
<div class="collection-title">
<div><strong>{{ selectedCollection.name }}</strong><code>{{ selectedCollection.prefix }}</code></div>
<span>{{ selectedCollection.total.toLocaleString() }} 个图标</span>
</div>
<div class="collection-links">
<a v-if="selectedCollection.author.url" :href="selectedCollection.author.url" target="_blank" rel="noopener noreferrer">{{ selectedCollection.author.name }}</a>
<span v-else>{{ selectedCollection.author.name }}</span>
<a v-if="selectedCollection.license.url" :href="selectedCollection.license.url" target="_blank" rel="noopener noreferrer">{{ selectedCollection.license.title }}</a>
<span v-else>{{ selectedCollection.license.title }}</span>
</div>
</header>
<label class="collection-filter"><i class="fas fa-filter" aria-hidden="true"></i><input v-model="collectionIconQuery" type="search" autocomplete="off" placeholder="在当前图标集中筛选" /></label>
<div v-if="loading && !collectionIcons.length" class="loading-grid"><span v-for="index in 24" :key="index"></span></div>
<div v-else-if="collectionError" class="state-panel state-panel--error"><i class="fas fa-cloud-arrow-down" aria-hidden="true"></i><strong>图标集加载失败</strong><span>{{ collectionError }}</span><button type="button" class="secondary-action" @click="openCollection(selectedCollection, true)"><i class="fas fa-rotate" aria-hidden="true"></i>重试</button></div>
<IconGrid v-else-if="visibleCollectionIcons.length" :icons="visibleCollectionIcons" :selected="modelValue" @select="selectIcon" />
<div v-else-if="!loading" class="state-panel"><i class="fas fa-filter-circle-xmark" aria-hidden="true"></i><strong>当前筛选没有结果</strong></div>
<button v-if="canShowMoreCollectionIcons" type="button" class="load-more" @click="collectionIconLimit += 96"><i class="fas fa-chevron-down" aria-hidden="true"></i>显示更多</button>
</template>
<template v-else>
<header class="collection-browser-tools">
<label><i class="fas fa-filter" aria-hidden="true"></i><input v-model="collectionQuery" type="search" autocomplete="off" placeholder="筛选图标集" /></label>
<select v-model="collectionCategory" aria-label="按图标集分类筛选">
<option value="">全部分类</option>
<option v-for="category in collectionCategories" :key="category" :value="category">{{ category }}</option>
</select>
<button type="button" class="icon-button" title="刷新图标集" aria-label="刷新图标集" :disabled="loading" @click="loadCollections(true)"><i class="fas fa-rotate" :class="{ 'fa-spin': loading }" aria-hidden="true"></i></button>
</header>
<div v-if="loading && !collections.length" class="collection-skeletons"><span v-for="index in 12" :key="index"></span></div>
<div v-else-if="collectionsError && !collections.length" class="state-panel state-panel--error"><i class="fas fa-cloud-arrow-down" aria-hidden="true"></i><strong>在线图标集暂时不可用</strong><span>{{ collectionsError }}</span><button type="button" class="secondary-action" @click="loadCollections(true)"><i class="fas fa-rotate" aria-hidden="true"></i>重试</button></div>
<div v-else class="collection-grid">
<article v-for="collection in visibleCollections" :key="collection.prefix" class="collection-card">
<button type="button" class="collection-open" @click="openCollection(collection)">
<span class="sample-icons">
<AppIcon v-for="sample in collection.samples" :key="sample" :icon="toStoredIcon(`${collection.prefix}:${sample}`)" fallback="fas fa-shapes" />
</span>
<span><strong>{{ collection.name }}</strong><code>{{ collection.prefix }}</code></span>
<small>{{ collection.total.toLocaleString() }}</small>
</button>
<footer>
<a v-if="collection.author.url" :href="collection.author.url" target="_blank" rel="noopener noreferrer">{{ collection.author.name }}</a><span v-else>{{ collection.author.name }}</span>
<a v-if="collection.license.url" :href="collection.license.url" target="_blank" rel="noopener noreferrer">{{ collection.license.title }}</a><span v-else>{{ collection.license.title }}</span>
</footer>
</article>
</div>
<div v-if="!loading && !filteredCollections.length" class="state-panel"><i class="fas fa-filter-circle-xmark" aria-hidden="true"></i><strong>没有匹配的图标集</strong></div>
<button v-if="canShowMoreCollections" type="button" class="load-more" @click="collectionLimit += 30"><i class="fas fa-chevron-down" aria-hidden="true"></i>显示更多图标集</button>
</template>
</section>
</main>
<footer class="picker-footer">
<div class="selected-icon" :class="{ empty: !modelValue }">
<span class="selected-preview"><AppIcon :icon="modelValue" /></span>
<span><small>当前选择</small><code>{{ modelValue || '尚未选择' }}</code></span>
</div>
</div>
<div class="icon-picker-footer" v-if="modelValue">
<div class="selected-icon">
<span>已选择</span>
<i :class="modelValue"></i>
<code>{{ modelValue }}</code>
<div class="footer-actions">
<button type="button" class="secondary-action" :disabled="!modelValue" @click="clearIcon"><i class="fas fa-eraser" aria-hidden="true"></i>清空</button>
<button type="button" class="primary-action" @click="emit('close')"><i class="fas fa-check" aria-hidden="true"></i>完成</button>
</div>
<div class="icon-picker-actions">
<button @click="clearIcon" class="btn-clear">
<i class="fas fa-times"></i>
清除
</button>
<button @click="closePicker" class="btn-close">
<i class="fas fa-check"></i>
确定
</button>
</div>
</div>
</footer>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: '',
},
})
import { computed, defineComponent, h, onMounted, onUnmounted, ref, watch } from 'vue'
import AppIcon from './AppIcon.vue'
import { getIconCollection, getIconCollections, getRecentIcons, rememberIcon, searchIcons } from '../services/iconify'
import { iconDisplayName, toIconValue } from '../utils/iconValue'
const props = defineProps({ modelValue: { type: String, default: '' } })
const emit = defineEmits(['update:modelValue', 'close'])
const searchQuery = ref('')
const activeCategory = ref('all')
// Font Awesome
const categories = [
const localLibrary = {
web: ['fas fa-house', 'fas fa-globe', 'fas fa-link', 'fas fa-arrow-up-right-from-square', 'fas fa-bookmark', 'fas fa-star', 'fas fa-heart', 'fas fa-thumbs-up', 'fas fa-rss', 'fas fa-compass', 'fas fa-sitemap', 'fas fa-language'],
social: ['fab fa-github', 'fab fa-gitlab', 'fab fa-twitter', 'fab fa-x-twitter', 'fab fa-facebook', 'fab fa-instagram', 'fab fa-linkedin', 'fab fa-youtube', 'fab fa-telegram', 'fab fa-discord', 'fab fa-weixin', 'fab fa-qq', 'fab fa-weibo', 'fab fa-bilibili'],
media: ['fas fa-image', 'fas fa-images', 'fas fa-video', 'fas fa-music', 'fas fa-film', 'fas fa-camera', 'fas fa-microphone', 'fas fa-headphones', 'fas fa-podcast', 'fas fa-play', 'fas fa-pause', 'fas fa-photo-film'],
business: ['fas fa-briefcase', 'fas fa-building', 'fas fa-chart-line', 'fas fa-chart-column', 'fas fa-dollar-sign', 'fas fa-cart-shopping', 'fas fa-credit-card', 'fas fa-handshake', 'fas fa-wallet', 'fas fa-receipt', 'fas fa-shop', 'fas fa-coins'],
tech: ['fas fa-code', 'fas fa-terminal', 'fas fa-server', 'fas fa-database', 'fas fa-cloud', 'fas fa-mobile-screen-button', 'fas fa-laptop', 'fas fa-keyboard', 'fas fa-microchip', 'fas fa-network-wired', 'fas fa-bug', 'fas fa-shield-halved', 'fab fa-vuejs', 'fab fa-js', 'fab fa-golang'],
contact: ['fas fa-envelope', 'fas fa-at', 'fas fa-phone', 'fas fa-location-dot', 'fas fa-map', 'fas fa-calendar', 'fas fa-clock', 'fas fa-bell', 'fas fa-message', 'fas fa-comments', 'fas fa-qrcode', 'fas fa-address-card'],
other: ['fas fa-gear', 'fas fa-user', 'fas fa-users', 'fas fa-circle-info', 'fas fa-circle-question', 'fas fa-circle-check', 'fas fa-triangle-exclamation', 'fas fa-lock', 'fas fa-key', 'fas fa-bolt', 'fas fa-fire', 'fas fa-leaf', 'fas fa-gift', 'fas fa-icons'],
}
const localCategories = [
{ name: 'all', label: '全部', icon: 'fas fa-table-cells-large' },
{ name: 'web', label: '网页', icon: 'fas fa-globe' },
{ name: 'social', label: '社交', icon: 'fas fa-share-nodes' },
{ name: 'media', label: '媒体', icon: 'fas fa-photo-film' },
{ name: 'business', label: '商业', icon: 'fas fa-briefcase' },
{ name: 'tech', label: '技术', icon: 'fas fa-code' },
{ name: 'other', label: '其他', icon: 'fas fa-ellipsis-h' },
{ name: 'contact', label: '联系', icon: 'fas fa-address-book' },
{ name: 'other', label: '其他', icon: 'fas fa-shapes' },
]
const tabs = [
{ name: 'local', label: '本地常用', icon: 'fas fa-box' },
{ name: 'recent', label: '最近使用', icon: 'fas fa-clock-rotate-left' },
{ name: 'collections', label: '全部图标集', icon: 'fas fa-layer-group' },
]
//
const iconLibrary = {
web: [
'fas fa-home', 'fas fa-globe', 'fas fa-link', 'fas fa-up-right-from-square',
'fas fa-bookmark', 'fas fa-star', 'fas fa-heart', 'fas fa-thumbs-up',
],
social: [
'fab fa-github', 'fab fa-twitter', 'fab fa-facebook', 'fab fa-instagram',
'fab fa-linkedin', 'fab fa-youtube', 'fab fa-telegram', 'fab fa-discord',
'fab fa-weixin', 'fab fa-qq', 'fab fa-weibo', 'fab fa-bilibili',
],
media: [
'fas fa-image', 'fas fa-video', 'fas fa-music', 'fas fa-film',
'fas fa-camera', 'fas fa-microphone', 'fas fa-headphones',
],
business: [
'fas fa-briefcase', 'fas fa-building', 'fas fa-chart-line', 'fas fa-dollar-sign',
'fas fa-shopping-cart', 'fas fa-credit-card', 'fas fa-handshake',
],
tech: [
'fas fa-code', 'fas fa-terminal', 'fas fa-server', 'fas fa-database',
'fas fa-cloud', 'fas fa-mobile-screen-button', 'fas fa-laptop', 'fas fa-keyboard',
],
other: [
'fas fa-envelope', 'fas fa-phone', 'fas fa-location-dot', 'fas fa-calendar',
'fas fa-clock', 'fas fa-bell', 'fas fa-gear', 'fas fa-user', 'fas fa-users',
],
const activeTab = ref('local')
const localCategory = ref('all')
const searchQuery = ref('')
const loading = ref(false)
const collections = ref([])
const collectionsError = ref('')
const selectedCollection = ref(null)
const collectionIcons = ref([])
const collectionError = ref('')
const collectionIconQuery = ref('')
const collectionIconLimit = ref(96)
const collectionQuery = ref('')
const collectionCategory = ref('')
const collectionLimit = ref(30)
const remoteIcons = ref([])
const searchCollections = ref({})
const searchError = ref('')
const searchHasMore = ref(false)
const recentIcons = ref(getRecentIcons())
let searchTimer = 0
let searchController = null
let collectionController = null
let collectionListController = null
let searchSequence = 0
const allLocalIcons = computed(() => [...new Set(Object.values(localLibrary).flat())])
const visibleLocalIcons = computed(() => localCategory.value === 'all' ? allLocalIcons.value : localLibrary[localCategory.value] || [])
const isSearching = computed(() => Boolean(searchQuery.value.trim()))
const localSearchResults = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
if (!query) return []
return allLocalIcons.value.filter((icon) => icon.toLowerCase().includes(query) || iconDisplayName(icon).includes(query)).slice(0, 24)
})
const searchSummary = computed(() => loading.value ? '正在检索在线图标' : `${localSearchResults.value.length + remoteIcons.value.length} 个结果`)
const collectionCategories = computed(() => [...new Set(collections.value.map((item) => item.category).filter(Boolean))].sort((a, b) => a.localeCompare(b)))
const filteredCollections = computed(() => {
const query = collectionQuery.value.trim().toLowerCase()
return collections.value.filter((item) => {
const categoryMatches = !collectionCategory.value || item.category === collectionCategory.value
const queryMatches = !query || `${item.name} ${item.prefix} ${item.author.name}`.toLowerCase().includes(query)
return categoryMatches && queryMatches
})
})
const visibleCollections = computed(() => filteredCollections.value.slice(0, collectionLimit.value))
const canShowMoreCollections = computed(() => visibleCollections.value.length < filteredCollections.value.length)
const filteredCollectionIcons = computed(() => {
const query = collectionIconQuery.value.trim().toLowerCase()
return query ? collectionIcons.value.filter((name) => name.toLowerCase().includes(query)) : collectionIcons.value
})
const visibleCollectionIcons = computed(() => filteredCollectionIcons.value.slice(0, collectionIconLimit.value).map(toStoredIcon))
const canShowMoreCollectionIcons = computed(() => visibleCollectionIcons.value.length < filteredCollectionIcons.value.length)
const toStoredIcon = (name) => toIconValue(name)
const openTab = (tab) => {
activeTab.value = tab
selectedCollection.value = null
if (tab === 'collections' && !collections.value.length) loadCollections()
}
//
const allIcons = computed(() => {
const icons = []
Object.values(iconLibrary).forEach(categoryIcons => {
icons.push(...categoryIcons)
})
return icons
})
//
const filteredIcons = computed(() => {
let icons = activeCategory.value === 'all'
? allIcons.value
: iconLibrary[activeCategory.value] || []
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase()
icons = icons.filter(icon =>
icon.toLowerCase().includes(query) ||
getIconName(icon).toLowerCase().includes(query)
)
const loadCollections = async (force = false) => {
collectionListController?.abort()
collectionListController = new AbortController()
loading.value = true
collectionsError.value = ''
try {
collections.value = await getIconCollections({ signal: collectionListController.signal, force })
} catch (error) {
if (error?.name !== 'AbortError') collectionsError.value = error?.message || '请求失败'
} finally {
if (!collectionListController.signal.aborted) loading.value = false
}
}
return icons
})
const openCollection = async (collection, force = false) => {
selectedCollection.value = collection
collectionIcons.value = []
collectionIconQuery.value = ''
collectionIconLimit.value = 96
collectionError.value = ''
collectionController?.abort()
collectionController = new AbortController()
loading.value = true
try {
const result = await getIconCollection(collection.prefix, { signal: collectionController.signal, force })
if (selectedCollection.value?.prefix !== collection.prefix) return
selectedCollection.value = result.info
collectionIcons.value = result.icons
} catch (error) {
if (error?.name !== 'AbortError') collectionError.value = error?.message || '请求失败'
} finally {
if (!collectionController.signal.aborted) loading.value = false
}
}
const getIconName = (icon) => {
// "fas fa-home" "home"
const parts = icon.split(' ')
return parts[parts.length - 1] || icon
const closeCollection = () => {
collectionController?.abort()
selectedCollection.value = null
collectionIcons.value = []
collectionError.value = ''
}
const runSearch = async (force = false) => {
window.clearTimeout(searchTimer)
const query = searchQuery.value.trim()
searchController?.abort()
searchSequence += 1
const sequence = searchSequence
remoteIcons.value = []
searchCollections.value = {}
searchError.value = ''
searchHasMore.value = false
if (query.length < 2) return
searchController = new AbortController()
loading.value = true
try {
const result = await searchIcons(query, { signal: searchController.signal, limit: 64, start: 0, force })
if (sequence !== searchSequence) return
remoteIcons.value = result.icons.map(toStoredIcon).filter(Boolean)
searchCollections.value = result.collections
searchHasMore.value = result.icons.length >= result.limit && result.limit < 999
} catch (error) {
if (error?.name !== 'AbortError' && sequence === searchSequence) searchError.value = error?.message || '请求失败'
} finally {
if (sequence === searchSequence) loading.value = false
}
}
const loadMoreSearch = async () => {
const query = searchQuery.value.trim()
if (loading.value || query.length < 2) return
searchController?.abort()
searchController = new AbortController()
const sequence = ++searchSequence
loading.value = true
try {
const result = await searchIcons(query, { signal: searchController.signal, limit: 999, start: remoteIcons.value.length })
if (sequence !== searchSequence) return
const next = result.icons.map(toStoredIcon).filter(Boolean)
remoteIcons.value = [...new Set([...remoteIcons.value, ...next])]
searchCollections.value = { ...searchCollections.value, ...result.collections }
searchHasMore.value = false
} catch (error) {
if (error?.name !== 'AbortError' && sequence === searchSequence) searchError.value = error?.message || '请求失败'
} finally {
if (sequence === searchSequence) loading.value = false
}
}
const selectIcon = (icon) => {
emit('update:modelValue', icon)
recentIcons.value = rememberIcon(icon)
}
const clearIcon = () => emit('update:modelValue', '')
const clearIcon = () => {
emit('update:modelValue', '')
}
watch(searchQuery, () => {
window.clearTimeout(searchTimer)
searchController?.abort()
searchSequence += 1
remoteIcons.value = []
searchCollections.value = {}
searchError.value = ''
searchHasMore.value = false
const query = searchQuery.value.trim()
if (!query) return
searchTimer = window.setTimeout(runSearch, 300)
})
watch([collectionQuery, collectionCategory], () => { collectionLimit.value = 30 })
watch(collectionIconQuery, () => { collectionIconLimit.value = 96 })
const closePicker = () => {
emit('close')
}
onMounted(() => loadCollections())
onUnmounted(() => {
window.clearTimeout(searchTimer)
searchController?.abort()
collectionController?.abort()
collectionListController?.abort()
})
const filterIcons = () => {
// ""
if (searchQuery.value.trim() && activeCategory.value !== 'all') {
activeCategory.value = 'all'
}
}
watch(() => props.modelValue, (newVal) => {
if (newVal) {
//
}
const IconGrid = defineComponent({
props: {
icons: { type: Array, default: () => [] },
selected: { type: String, default: '' },
collections: { type: Object, default: () => ({}) },
},
emits: ['select'],
setup(childProps, { emit: childEmit }) {
return () => h('div', { class: 'icon-grid' }, childProps.icons.map((icon) => {
const iconName = icon.startsWith('iconify:') ? icon.slice(8) : icon
const [prefix] = iconName.split(':')
const collectionName = childProps.collections?.[prefix]?.name || (icon.startsWith('iconify:') ? prefix : 'Font Awesome')
return h('button', {
type: 'button',
class: ['icon-item', { active: childProps.selected === icon }],
title: `${iconName} · ${collectionName}`,
'aria-label': `选择图标 ${iconName}`,
'aria-pressed': childProps.selected === icon,
onClick: () => childEmit('select', icon),
onDblclick: () => childEmit('select', icon),
}, [
h(AppIcon, { icon, fallback: 'fas fa-shapes', 'aria-hidden': 'true' }),
h('span', iconDisplayName(icon)),
h('small', collectionName),
])
}))
},
})
</script>
<style scoped>
.icon-picker {
display: flex;
flex-direction: column;
height: 100%;
max-height: 100%;
background: rgba(var(--background-color-rgb), 0.98);
border-radius: var(--border-radius);
overflow: hidden;
.icon-picker { height: min(680px, calc(88vh - 110px)); min-height: 500px; display: flex; flex-direction: column; margin: -20px; color: var(--text-color); }
.picker-toolbar { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border-color); }
.search-box { min-width: 0; flex: 1; height: 42px; display: grid; grid-template-columns: 34px minmax(0, 1fr) 34px; align-items: center; border: 1px solid var(--border-color); border-radius: 7px; color: var(--text-muted); background: var(--surface-muted); }
.search-box:focus-within { border-color: var(--hover-link-color); box-shadow: 0 0 0 3px var(--focus-ring); }
.search-box > i { text-align: center; }
.search-box input { min-width: 0; height: 100%; padding: 0; border: 0; outline: 0; color: var(--text-color); background: transparent; }
.search-box button,
.icon-button { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid transparent; border-radius: 6px; color: var(--text-muted); background: transparent; cursor: pointer; }
.search-box button:hover,
.icon-button:hover:not(:disabled) { border-color: var(--border-color); color: var(--text-color); background: var(--surface-solid); }
.library-status { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 12px; }
.library-status i { color: var(--success-color); }
.library-status.offline i { color: var(--warning-color); }
.picker-tabs { display: flex; gap: 4px; padding: 8px 16px 0; border-bottom: 1px solid var(--border-color); }
.picker-tabs button { min-height: 39px; display: inline-flex; align-items: center; gap: 7px; padding: 0 12px; border: 0; border-bottom: 3px solid transparent; color: var(--text-muted); background: transparent; cursor: pointer; }
.picker-tabs button:hover { color: var(--text-color); }
.picker-tabs button.active { border-bottom-color: var(--hover-link-color); color: var(--text-color); font-weight: 700; }
.picker-tabs small { min-width: 20px; padding: 1px 5px; border-radius: 8px; background: var(--surface-muted); font-size: 10px; text-align: center; }
.picker-content { min-height: 0; flex: 1; overflow-y: auto; padding: 16px; scrollbar-gutter: stable; }
.content-heading,
.collection-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
.content-heading > div { min-width: 0; display: flex; align-items: baseline; gap: 9px; }
.content-heading strong { font-size: 15px; }
.content-heading span { color: var(--text-muted); font-size: 12px; }
.result-group + .result-group { margin-top: 18px; padding-top: 17px; border-top: 1px solid var(--border-color); }
.result-group h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 10px; font-size: 13px; }
.result-group h3 span { color: var(--text-muted); font-weight: 400; }
.local-categories { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.local-categories button { min-height: 34px; display: inline-flex; align-items: center; gap: 6px; padding: 0 10px; border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-muted); background: var(--surface-muted); cursor: pointer; }
.local-categories button:hover,
.local-categories button.active { border-color: rgba(var(--hover-link-color-rgb), 0.7); color: var(--text-color); }
.local-categories button.active { box-shadow: inset 0 -2px var(--hover-link-color); background: var(--surface-solid); font-weight: 700; }
:deep(.icon-grid) { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 8px; align-content: start; }
:deep(.icon-item) { min-width: 0; min-height: 92px; display: grid; grid-template-rows: 30px 18px 15px; place-items: center; gap: 3px; padding: 10px 6px; overflow: hidden; border: 1px solid var(--border-color); border-radius: 7px; color: var(--text-color); background: var(--surface-muted); cursor: pointer; transition: border-color var(--motion-fast) var(--motion-ease-standard), background-color var(--motion-fast) var(--motion-ease-standard), transform var(--motion-fast) var(--motion-ease); }
:deep(.icon-item:hover) { border-color: var(--hover-link-color); background: var(--surface-solid); transform: translateY(-2px); }
:deep(.icon-item.active) { border-color: #c69e00; box-shadow: inset 0 0 0 2px var(--hover-link-color); background: rgba(var(--hover-link-color-rgb), 0.1); }
:deep(.icon-item .app-icon) { width: 28px; height: 28px; font-size: 27px; }
:deep(.icon-item > span) { width: 100%; overflow: hidden; font-size: 11px; line-height: 18px; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
:deep(.icon-item > small) { width: 100%; overflow: hidden; color: var(--text-muted); font-size: 9px; line-height: 15px; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 8px; }
.loading-grid span { min-height: 92px; border: 1px solid var(--border-color); border-radius: 7px; background: var(--surface-muted); animation: picker-pulse 0.9s ease-in-out infinite alternate; }
.state-panel { min-height: 180px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; padding: 20px; color: var(--text-muted); text-align: center; }
.state-panel > i { color: var(--hover-link-color); font-size: 26px; }
.state-panel strong { color: var(--text-color); }
.state-panel span { max-width: 440px; font-size: 12px; overflow-wrap: anywhere; }
.state-panel--error > i { color: var(--warning-color); }
.primary-action,
.secondary-action,
.load-more,
.back-button { min-height: 36px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 0 12px; border-radius: 6px; font-weight: 700; cursor: pointer; }
.primary-action { border: 1px solid #c99f00; color: #2b2400; background: var(--hover-link-color); }
.secondary-action,
.back-button { border: 1px solid var(--border-color); color: var(--text-color); background: var(--surface-muted); }
.load-more { width: 100%; margin-top: 12px; border: 1px dashed var(--border-color); color: var(--text-muted); background: transparent; }
.load-more:hover,
.secondary-action:hover:not(:disabled),
.back-button:hover { border-color: var(--hover-link-color); color: var(--text-color); }
.collection-browser-tools { display: grid; grid-template-columns: minmax(0, 1fr) 190px 36px; gap: 8px; margin-bottom: 14px; }
.collection-browser-tools label,
.collection-filter { height: 38px; display: grid; grid-template-columns: 34px minmax(0, 1fr); align-items: center; border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-muted); background: var(--surface-muted); }
.collection-browser-tools label:focus-within,
.collection-filter:focus-within { border-color: var(--hover-link-color); }
.collection-browser-tools label i,
.collection-filter i { text-align: center; }
.collection-browser-tools input,
.collection-filter input { min-width: 0; height: 100%; padding: 0 9px 0 0; border: 0; outline: 0; color: var(--text-color); background: transparent; }
.collection-browser-tools select { min-width: 0; border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-color); background: var(--surface-muted); }
.collection-grid,
.collection-skeletons { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.collection-card { min-width: 0; overflow: hidden; border: 1px solid var(--border-color); border-radius: 7px; background: var(--surface-muted); }
.collection-open { width: 100%; min-height: 92px; display: grid; grid-template-columns: 76px minmax(0, 1fr) auto; gap: 9px; align-items: center; padding: 12px; border: 0; color: var(--text-color); background: transparent; text-align: left; cursor: pointer; }
.collection-open:hover { background: var(--surface-solid); }
.sample-icons { height: 44px; display: flex; align-items: center; justify-content: center; gap: 4px; border-radius: 5px; color: var(--text-color); background: var(--surface-solid); }
.sample-icons .app-icon { width: 18px; height: 18px; font-size: 18px; }
.collection-open > span:nth-child(2) { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.collection-open strong,
.collection-open code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.collection-open strong { font-size: 12px; }
.collection-open code { color: var(--text-muted); font-size: 10px; }
.collection-open small { color: var(--text-muted); font-size: 10px; }
.collection-card footer { display: flex; justify-content: space-between; gap: 8px; padding: 7px 10px; border-top: 1px solid var(--border-color); color: var(--text-muted); font-size: 9px; }
.collection-card footer a,
.collection-card footer span { min-width: 0; overflow: hidden; color: inherit; text-overflow: ellipsis; white-space: nowrap; }
.collection-card footer a:hover { color: var(--hover-link-color); }
.collection-skeletons span { min-height: 124px; border-radius: 7px; background: var(--surface-muted); animation: picker-pulse 0.9s ease-in-out infinite alternate; }
.collection-detail-heading { align-items: flex-start; }
.collection-title { min-width: 0; flex: 1; }
.collection-title > div { display: flex; align-items: center; gap: 8px; }
.collection-title code { color: var(--text-muted); font-size: 10px; }
.collection-title > span { color: var(--text-muted); font-size: 11px; }
.collection-links { max-width: 220px; display: flex; flex-direction: column; align-items: flex-end; gap: 3px; color: var(--text-muted); font-size: 10px; }
.collection-links a { max-width: 100%; overflow: hidden; color: inherit; text-overflow: ellipsis; white-space: nowrap; }
.collection-links a:hover { color: var(--hover-link-color); }
.collection-filter { margin-bottom: 14px; }
.picker-footer { min-height: 66px; display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 16px; border-top: 1px solid var(--border-color); background: var(--surface-muted); }
.selected-icon { min-width: 0; display: flex; align-items: center; gap: 9px; }
.selected-icon.empty { opacity: 0.58; }
.selected-preview { width: 40px; height: 40px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--border-color); border-radius: 6px; color: var(--hover-link-color); background: var(--surface-solid); font-size: 21px; }
.selected-icon > span:last-child { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.selected-icon small { color: var(--text-muted); font-size: 10px; }
.selected-icon code { max-width: min(430px, 46vw); overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.footer-actions { flex: 0 0 auto; display: flex; gap: 8px; }
button:disabled { opacity: 0.48; cursor: not-allowed; }
@keyframes picker-pulse { to { opacity: 0.42; } }
@media (max-width: 760px) {
.icon-picker { height: calc(92vh - 68px); min-height: 0; }
.picker-toolbar { align-items: stretch; flex-direction: column; gap: 7px; padding: 10px 12px; }
.library-status { align-self: flex-end; }
.picker-tabs { padding: 6px 10px 0; overflow-x: auto; }
.picker-tabs button { flex: 0 0 auto; }
.picker-content { padding: 12px 10px; }
:deep(.icon-grid),
.loading-grid { grid-template-columns: repeat(auto-fill, minmax(86px, 1fr)); }
:deep(.icon-item) { min-height: 84px; grid-template-rows: 27px 17px 14px; }
:deep(.icon-item .app-icon) { width: 25px; height: 25px; font-size: 24px; }
.collection-grid,
.collection-skeletons { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.collection-browser-tools { grid-template-columns: minmax(0, 1fr) 36px; }
.collection-browser-tools select { grid-column: 1 / -1; grid-row: 2; height: 38px; }
.collection-detail-heading { flex-wrap: wrap; }
.collection-title { order: 3; flex-basis: 100%; }
.collection-links { margin-left: auto; }
.picker-footer { align-items: stretch; flex-direction: column; }
.selected-icon code { max-width: calc(100vw - 95px); }
.footer-actions button { flex: 1; }
}
.icon-picker-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.icon-search {
width: 100%;
padding: 10px 16px;
border: 2px solid var(--border-color);
border-radius: 8px;
background: rgba(var(--background-color-rgb), 0.6);
color: var(--text-color);
font-size: 14px;
transition: all 0.3s ease;
}
.icon-search:focus {
outline: none;
border-color: #007aff;
background: rgba(var(--background-color-rgb), 0.8);
}
.icon-picker-body {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
.icon-categories {
width: 180px;
padding: 16px;
border-right: 1px solid var(--border-color);
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
flex-shrink: 0;
/* 自定义滚动条样式 */
scrollbar-width: thin;
scrollbar-color: var(--hover-link-color) rgba(var(--background-color-rgb), 0.3);
}
.icon-categories::-webkit-scrollbar {
width: 6px;
}
.icon-categories::-webkit-scrollbar-track {
background: rgba(var(--background-color-rgb), 0.3);
border-radius: 3px;
}
.icon-categories::-webkit-scrollbar-thumb {
background: var(--hover-link-color);
border-radius: 3px;
transition: background 0.3s ease;
}
.icon-categories::-webkit-scrollbar-thumb:hover {
background: #ffd700;
}
.category-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: rgba(var(--background-color-rgb), 0.6);
color: var(--text-color);
cursor: pointer;
transition: all 0.3s ease;
text-align: left;
}
.category-btn:hover {
background: rgba(var(--background-color-rgb), 0.8);
border-color: var(--hover-link-color);
}
.category-btn.active {
background: var(--hover-link-color);
color: #333;
border-color: var(--hover-link-color);
font-weight: 500;
}
.icon-grid {
flex: 1;
padding: 16px;
overflow-y: auto;
overflow-x: hidden;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
align-content: start;
min-height: 0;
/* 自定义滚动条样式 */
scrollbar-width: thin;
scrollbar-color: var(--hover-link-color) rgba(var(--background-color-rgb), 0.3);
}
.icon-grid::-webkit-scrollbar {
width: 8px;
}
.icon-grid::-webkit-scrollbar-track {
background: rgba(var(--background-color-rgb), 0.3);
border-radius: 4px;
}
.icon-grid::-webkit-scrollbar-thumb {
background: var(--hover-link-color);
border-radius: 4px;
transition: background 0.3s ease;
}
.icon-grid::-webkit-scrollbar-thumb:hover {
background: #ffd700;
}
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px 8px;
border: 2px solid var(--border-color);
border-radius: 8px;
background: rgba(var(--background-color-rgb), 0.6);
cursor: pointer;
transition: all 0.3s ease;
min-height: 100px;
}
.icon-item:hover {
background: rgba(var(--background-color-rgb), 0.8);
border-color: var(--hover-link-color);
transform: translateY(-2px);
box-shadow: 0 4px 12px var(--shadow-color);
}
.icon-item.active {
background: var(--hover-link-color);
border-color: var(--hover-link-color);
color: #333;
}
.icon-item i {
font-size: 24px;
margin-bottom: 8px;
color: inherit;
}
.icon-item.active i {
color: #333;
}
.icon-name {
font-size: 11px;
text-align: center;
word-break: break-all;
color: inherit;
opacity: 0.8;
}
.icon-picker-footer {
padding: 16px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
background: rgba(var(--background-color-rgb), 0.98);
position: sticky;
bottom: 0;
z-index: 10;
flex-shrink: 0;
}
.selected-icon {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
font-size: 14px;
color: var(--text-color);
}
.selected-icon i {
font-size: 20px;
color: var(--hover-link-color);
}
.selected-icon code {
background: rgba(var(--background-color-rgb), 0.8);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-family: 'Courier New', monospace;
}
.icon-picker-actions {
display: flex;
gap: 8px;
}
.btn-clear,
.btn-close {
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: rgba(var(--background-color-rgb), 0.8);
color: var(--text-color);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
}
.btn-clear:hover {
background: rgba(244, 67, 54, 0.1);
border-color: #f44336;
color: #f44336;
}
.btn-close {
background: var(--hover-link-color);
color: #333;
border-color: var(--hover-link-color);
font-weight: 500;
}
.btn-close:hover {
background: #ffd700;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(255, 204, 0, 0.3);
}
@media (max-width: 768px) {
.icon-picker-body {
flex-direction: column;
}
.icon-categories {
width: 100%;
flex-direction: row;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.icon-grid {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
}
@media (max-width: 460px) {
.collection-grid,
.collection-skeletons { grid-template-columns: 1fr; }
.collection-open { grid-template-columns: 74px minmax(0, 1fr) auto; }
.local-categories { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; }
.local-categories button { flex: 0 0 auto; }
}
</style>
+3 -2
View File
@@ -15,7 +15,7 @@
:rel="config.openLinksInNewTab ? 'noopener noreferrer' : undefined"
class="site-box"
>
<i :class="site.icon" aria-hidden="true"></i>
<AppIcon :icon="site.icon" aria-hidden="true" />
<span>{{ site.name }}</span>
</a>
</div>
@@ -34,6 +34,7 @@ import 'swiper/swiper-bundle.css'
import { getSites } from '../api'
import fallbackSites from '../config/site.json'
import { loadPublicConfig, publicConfig as config } from '../composables/usePublicConfig'
import AppIcon from './AppIcon.vue'
const sites = ref([])
const loading = ref(true)
@@ -104,7 +105,7 @@ onUnmounted(() => {
}
.site-box { display: flex; align-items: center; justify-content: center; gap: 9px; padding: 30px; font-weight: 600; backdrop-filter: blur(10px); transition: all 0.3s ease; }
.site-box:hover { transform: translateY(-3px); box-shadow: 0 1px 8px var(--shadow-color); }
.site-box i { flex: 0 0 auto; font-size: var(--icon-size); }
.site-box :deep(.app-icon) { flex: 0 0 auto; font-size: var(--icon-size); }
.site-box span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.site-grid--loading { padding: 10px; }
.site-empty { min-height: 150px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--text-muted); font-size: 13px; }
+6 -5
View File
@@ -33,7 +33,7 @@
<TransitionGroup tag="tbody" name="table-row">
<tr v-for="(item, index) in draftItems" :key="item.id">
<td><ReorderButtons :index="index" :count="draftItems.length" :busy="busy" @move="moveItem" /></td>
<td><span class="name-cell"><i :class="item.icon" aria-hidden="true"></i>{{ item.name }}</span></td>
<td><span class="name-cell"><AppIcon :icon="item.icon" aria-hidden="true" />{{ item.name }}</span></td>
<td><a :href="item.url" target="_blank" rel="noopener noreferrer" class="url-cell">{{ item.url }}<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i></a></td>
<td><code>{{ item.icon }}</code></td>
<td>{{ displayOrder(item, index) }}</td>
@@ -58,7 +58,7 @@
<tr v-for="(item, index) in draftItems" :key="item.id">
<td><ReorderButtons :index="index" :count="draftItems.length" :busy="busy" @move="moveItem" /></td>
<td><strong>{{ item.type }}</strong></td>
<td><i :class="item.icon" :style="{ color: item.hoverColor }" aria-hidden="true"></i></td>
<td><AppIcon :icon="item.icon" :style="{ color: item.hoverColor }" aria-hidden="true" /></td>
<td>
<a v-if="item.url" :href="item.url" target="_blank" rel="noopener noreferrer" class="url-cell">{{ item.url }}<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i></a>
<button v-else type="button" class="inline-chip" @click="previewQR(item)"><i class="fas fa-qrcode" aria-hidden="true"></i>二维码</button>
@@ -74,7 +74,7 @@
<TransitionGroup v-if="draftItems.length" tag="div" class="mobile-list" name="mobile-row">
<article v-for="(item, index) in draftItems" :key="item.id" class="mobile-item">
<ReorderButtons :index="index" :count="draftItems.length" :busy="busy" @move="moveItem" />
<div class="mobile-icon"><i :class="item.icon" :style="isSites ? {} : { color: item.hoverColor }" aria-hidden="true"></i></div>
<div class="mobile-icon"><AppIcon :icon="item.icon" :style="isSites ? {} : { color: item.hoverColor }" aria-hidden="true" /></div>
<div class="mobile-content">
<strong>{{ isSites ? item.name : item.type }}</strong>
<span>{{ item.url || '二维码联系方式' }}</span>
@@ -105,6 +105,7 @@
<script setup>
import { computed, defineComponent, h, ref, watch } from 'vue'
import AppIcon from '../AppIcon.vue'
const props = defineProps({
kind: { type: String, required: true },
@@ -267,8 +268,8 @@ tbody tr:hover { background: var(--surface-muted); }
.url-cell,
.inline-chip { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.name-cell { max-width: 260px; font-weight: 600; }
.name-cell i { width: 18px; flex: 0 0 auto; color: var(--hover-link-color); text-align: center; transition: transform var(--motion-base) var(--motion-ease); }
tbody tr:hover .name-cell i { transform: translateY(-2px) rotate(-5deg); }
.name-cell :deep(.app-icon) { width: 18px; flex: 0 0 auto; color: var(--hover-link-color); text-align: center; transition: transform var(--motion-base) var(--motion-ease); }
tbody tr:hover .name-cell :deep(.app-icon) { transform: translateY(-2px) rotate(-5deg); }
.name-cell,
.url-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.url-cell { max-width: 360px; color: var(--text-muted); }
+20 -8
View File
@@ -72,8 +72,12 @@
<input v-model="link.title" type="text" placeholder="链接标题" />
<input v-model="link.description" type="text" placeholder="链接描述" />
<input v-model="link.url" type="url" placeholder="https://example.com" />
<input v-model="link.icon" type="text" placeholder="fas fa-link" />
<button type="button" title="删除链接" :aria-label="`删除第 ${index + 1} 条链接`" @click="config.aboutLinks.splice(index, 1)"><i class="fas fa-trash" aria-hidden="true"></i></button>
<div class="about-icon-input">
<AppIcon :icon="link.icon" fallback="fas fa-link" aria-hidden="true" />
<input v-model="link.icon" type="text" placeholder="fas fa-link 或 iconify:mdi:link" />
<button type="button" class="about-icon-picker" title="选择图标" :aria-label="`为第 ${index + 1} 条链接选择图标`" @click="emit('pickIcon', link)"><i class="fas fa-icons" aria-hidden="true"></i></button>
</div>
<button type="button" class="about-link-delete" title="删除链接" :aria-label="`删除第 ${index + 1} 条链接`" @click="config.aboutLinks.splice(index, 1)"><i class="fas fa-trash" aria-hidden="true"></i></button>
</div>
<button type="button" class="secondary-button" :disabled="config.aboutLinks.length >= 8" @click="config.aboutLinks.push({ title: '', description: '', url: '', icon: 'fas fa-link' })"><i class="fas fa-plus" aria-hidden="true"></i>添加链接</button>
</div>
@@ -160,8 +164,9 @@
<script setup>
import { computed, ref, watch } from 'vue'
import IconSelector from '../IconSelector.vue'
import AppIcon from '../AppIcon.vue'
const emit = defineEmits(['update:activeSection', 'addText', 'removeText', 'saveTexts', 'testAnalytics'])
const emit = defineEmits(['update:activeSection', 'addText', 'removeText', 'saveTexts', 'testAnalytics', 'pickIcon'])
const props = defineProps({
config: { type: Object, required: true },
rotatingTexts: { type: Array, required: true },
@@ -236,10 +241,16 @@ watch(() => props.config.profileImageURL, () => { previewImageFailed.value = fal
.rotating-row button:hover:not(:disabled) { border-color: currentColor; background: rgba(201, 54, 43, 0.07); }
.rotating-row button:disabled { opacity: 0.35; cursor: not-allowed; }
.about-links-editor { display: flex; flex-direction: column; gap: 10px; }
.about-link-row { display: grid; grid-template-columns: minmax(90px, 0.8fr) minmax(90px, 0.9fr) minmax(150px, 1.4fr) minmax(90px, 0.7fr) 36px; gap: 7px; align-items: center; }
.about-link-row { display: grid; grid-template-columns: minmax(90px, 0.8fr) minmax(90px, 0.9fr) minmax(150px, 1.4fr) minmax(150px, 1fr) 36px; gap: 7px; align-items: center; }
.about-link-row input { min-width: 0; height: 38px; padding: 0 9px; }
.about-link-row button { width: 36px; height: 36px; border: 1px solid transparent; border-radius: 6px; color: var(--danger-color); background: transparent; cursor: pointer; }
.about-link-row button:hover { border-color: currentColor; background: rgba(201, 54, 43, 0.07); }
.about-icon-input { min-width: 0; height: 38px; display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; align-items: center; }
.about-icon-input > .app-icon { height: 38px; display: grid; place-items: center; border: 1px solid var(--border-color); border-right: 0; border-radius: 6px 0 0 6px; background: var(--surface-muted); }
.about-icon-input input { border-radius: 0; }
.about-icon-input button,
.about-link-delete { width: 36px; height: 36px; border: 1px solid transparent; border-radius: 6px; color: var(--danger-color); background: transparent; cursor: pointer; }
.about-icon-input button { width: 32px; border-left: 0; border-radius: 0 6px 6px 0; color: var(--text-muted); background: var(--surface-solid); }
.about-icon-input button:hover { border-color: var(--hover-link-color); color: var(--text-color); }
.about-link-delete:hover { border-color: currentColor; background: rgba(201, 54, 43, 0.07); }
.inline-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 10px; }
.analytics-state { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-left: 3px solid var(--success-color); color: var(--text-muted); background: var(--surface-muted); font-size: 12px; }
.analytics-state i { color: var(--success-color); }
@@ -297,7 +308,8 @@ watch(() => props.config.profileImageURL, () => { previewImageFailed.value = fal
.inline-actions { flex-direction: column-reverse; }
.inline-actions button { width: 100%; }
.about-link-row { grid-template-columns: 1fr 36px; }
.about-link-row input { grid-column: 1; }
.about-link-row button { grid-column: 2; grid-row: 1 / span 4; }
.about-link-row > input,
.about-link-row > .about-icon-input { grid-column: 1; }
.about-link-row > .about-link-delete { grid-column: 2; grid-row: 1 / span 4; }
}
</style>
+198
View File
@@ -0,0 +1,198 @@
import { toIconValue } from '../utils/iconValue'
export const ICONIFY_API_HOSTS = [
'https://api.iconify.design',
'https://api.simplesvg.com',
'https://api.unisvg.com',
]
const COLLECTION_CACHE_KEY = 'admin-iconify-collections-v1'
const RECENT_ICONS_KEY = 'admin-icon-picker-recent-v1'
const COLLECTION_TTL = 24 * 60 * 60 * 1000
const MEMORY_TTL = 10 * 60 * 1000
const MAX_RECENT_ICONS = 18
const memoryCache = new Map()
const chineseTerms = new Map([
['主页', 'home'], ['首页', 'home'], ['房子', 'home'], ['链接', 'link'], ['外链', 'external link'],
['邮件', 'email'], ['邮箱', 'email'], ['电话', 'phone'], ['位置', 'location'], ['地图', 'map'],
['用户', 'user'], ['头像', 'user'], ['团队', 'users'], ['设置', 'settings'], ['搜索', 'search'],
['图片', 'image'], ['相机', 'camera'], ['视频', 'video'], ['音乐', 'music'], ['播放', 'play'],
['云', 'cloud'], ['服务器', 'server'], ['数据库', 'database'], ['代码', 'code'], ['终端', 'terminal'],
['统计', 'chart'], ['图表', 'chart'], ['购物', 'shopping'], ['支付', 'payment'], ['钱包', 'wallet'],
['微信', 'wechat'], ['微博', 'weibo'], ['Github', 'github'], ['GitHub', 'github'], ['博客', 'blog'],
['时间', 'clock'], ['日历', 'calendar'], ['消息', 'message'], ['通知', 'bell'], ['爱心', 'heart'],
])
const popularPrefixes = ['lucide', 'tabler', 'mdi', 'material-symbols', 'fa6-solid', 'fa6-regular', 'fa6-brands', 'simple-icons', 'ph', 'carbon']
const readJSON = (key) => {
try {
return JSON.parse(localStorage.getItem(key) || 'null')
} catch {
return null
}
}
const cacheGet = (key) => {
const cached = memoryCache.get(key)
if (!cached || cached.expiresAt <= Date.now()) {
memoryCache.delete(key)
return null
}
return cached.value
}
const cacheSet = (key, value, ttl = MEMORY_TTL) => {
memoryCache.set(key, { value, expiresAt: Date.now() + ttl })
return value
}
const translateSearchTerm = (query) => {
let normalized = String(query || '').trim()
for (const [term, replacement] of chineseTerms) {
normalized = normalized.replaceAll(term, ` ${replacement} `)
}
return normalized.trim().replace(/\s+/g, ' ')
}
const fetchFromHost = async (host, path, outerSignal, timeout) => {
const controller = new AbortController()
const abort = () => controller.abort(outerSignal?.reason)
outerSignal?.addEventListener('abort', abort, { once: true })
const timeoutId = window.setTimeout(() => controller.abort(new DOMException('请求超时', 'TimeoutError')), timeout)
try {
const response = await fetch(`${host}${path}`, { signal: controller.signal, headers: { Accept: 'application/json' } })
if (!response.ok) throw new Error(`Iconify API 返回 ${response.status}`)
return await response.json()
} finally {
window.clearTimeout(timeoutId)
outerSignal?.removeEventListener('abort', abort)
}
}
export const requestIconify = async (path, { signal, timeout = 8000 } = {}) => {
let lastError = null
for (const host of ICONIFY_API_HOSTS) {
if (signal?.aborted) throw signal.reason || new DOMException('请求已取消', 'AbortError')
try {
return await fetchFromHost(host, path, signal, timeout)
} catch (error) {
if (signal?.aborted || error?.name === 'AbortError') throw error
lastError = error
}
}
throw lastError || new Error('Iconify 服务暂时不可用')
}
const normalizeCollection = (prefix, info = {}) => ({
prefix,
name: info.name || prefix,
total: Number(info.total) || 0,
category: info.category || '其他',
samples: Array.isArray(info.samples) ? info.samples.slice(0, 3) : [],
author: info.author || { name: '未知作者', url: '' },
license: info.license || { title: '未提供', url: '' },
palette: Boolean(info.palette),
})
const sortCollections = (items) => items.sort((left, right) => {
const leftPopular = popularPrefixes.indexOf(left.prefix)
const rightPopular = popularPrefixes.indexOf(right.prefix)
if (leftPopular !== -1 || rightPopular !== -1) {
if (leftPopular === -1) return 1
if (rightPopular === -1) return -1
return leftPopular - rightPopular
}
return left.name.localeCompare(right.name)
})
export const getIconCollections = async ({ signal, force = false } = {}) => {
if (!force) {
const memory = cacheGet('collections')
if (memory) return memory
const persisted = readJSON(COLLECTION_CACHE_KEY)
if (persisted?.expiresAt > Date.now() && Array.isArray(persisted.value)) {
return cacheSet('collections', persisted.value, COLLECTION_TTL)
}
}
const payload = await requestIconify('/collections', { signal })
const collections = sortCollections(Object.entries(payload || {}).map(([prefix, info]) => normalizeCollection(prefix, info)))
cacheSet('collections', collections, COLLECTION_TTL)
try {
localStorage.setItem(COLLECTION_CACHE_KEY, JSON.stringify({ value: collections, expiresAt: Date.now() + COLLECTION_TTL }))
} catch {}
return collections
}
const collectIconNames = (payload) => {
const names = new Set()
const add = (items) => Array.isArray(items) && items.forEach((item) => names.add(item))
add(payload?.icons)
add(payload?.uncategorized)
Object.values(payload?.categories || {}).forEach(add)
return [...names].sort((left, right) => left.localeCompare(right))
}
export const getIconCollection = async (prefix, { signal, force = false } = {}) => {
const normalizedPrefix = String(prefix || '').trim().toLowerCase()
const key = `collection:${normalizedPrefix}`
if (!force) {
const cached = cacheGet(key)
if (cached) return cached
}
const payload = await requestIconify(`/collection?prefix=${encodeURIComponent(normalizedPrefix)}&info=true`, { signal })
if (!payload?.prefix) throw new Error('图标集不存在')
return cacheSet(key, {
info: normalizeCollection(payload.prefix, payload.info || { name: payload.title, total: payload.total }),
icons: collectIconNames(payload).map((name) => `${payload.prefix}:${name}`),
})
}
export const searchIcons = async (query, { signal, limit = 64, start = 0, prefix = '', force = false } = {}) => {
const translated = translateSearchTerm(query)
const exactValue = toIconValue(translated.replace(/^iconify:/, ''))
if (exactValue) {
const name = exactValue.slice('iconify:'.length)
return { icons: [name], total: 1, start: 0, limit: 1, collections: {} }
}
if (translated.length < 2) return { icons: [], total: 0, start: 0, limit, collections: {} }
const safeLimit = Math.min(999, Math.max(32, Number(limit) || 64))
const safeStart = Math.max(0, Number(start) || 0)
const key = `search:${translated}:${prefix}:${safeStart}:${safeLimit}`
const cached = force ? null : cacheGet(key)
if (cached) return cached
const params = new URLSearchParams({ query: translated, limit: String(safeLimit), start: String(safeStart) })
if (prefix) params.set('prefix', prefix)
const payload = await requestIconify(`/search?${params.toString()}`, { signal })
return cacheSet(key, {
icons: Array.isArray(payload?.icons) ? payload.icons : [],
total: Number(payload?.total) || 0,
start: Number(payload?.start) || safeStart,
limit: Number(payload?.limit) || safeLimit,
collections: payload?.collections || {},
})
}
export const getRecentIcons = () => {
const stored = readJSON(RECENT_ICONS_KEY)
return Array.isArray(stored) ? stored.filter((value) => typeof value === 'string').slice(0, MAX_RECENT_ICONS) : []
}
export const rememberIcon = (value) => {
const normalized = String(value || '').trim()
if (!normalized) return getRecentIcons()
const recent = [normalized, ...getRecentIcons().filter((item) => item !== normalized)].slice(0, MAX_RECENT_ICONS)
try { localStorage.setItem(RECENT_ICONS_KEY, JSON.stringify(recent)) } catch {}
return recent
}
export const clearIconifyCaches = () => {
memoryCache.clear()
try { localStorage.removeItem(COLLECTION_CACHE_KEY) } catch {}
}
+52
View File
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearIconifyCaches, getIconCollection, getIconCollections, searchIcons } from './iconify'
const response = (payload) => ({ ok: true, json: async () => payload })
describe('iconify service', () => {
beforeEach(() => {
clearIconifyCaches()
localStorage.clear()
vi.restoreAllMocks()
})
it('fails over to the official backup host', async () => {
const fetchMock = vi.fn()
.mockRejectedValueOnce(new Error('primary unavailable'))
.mockResolvedValueOnce(response({ lucide: { name: 'Lucide', total: 1, category: 'UI', samples: ['home'] } }))
vi.stubGlobal('fetch', fetchMock)
const collections = await getIconCollections({ force: true })
expect(collections[0].prefix).toBe('lucide')
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[1][0]).toContain('api.simplesvg.com')
})
it('normalizes collection icons and caches the result', async () => {
const fetchMock = vi.fn().mockResolvedValue(response({
prefix: 'lucide',
total: 2,
info: { name: 'Lucide', total: 2, author: { name: 'Lucide' }, license: { title: 'ISC' } },
uncategorized: ['home'],
categories: { actions: ['check'] },
}))
vi.stubGlobal('fetch', fetchMock)
const first = await getIconCollection('lucide')
const second = await getIconCollection('lucide')
expect(first.icons).toEqual(['lucide:check', 'lucide:home'])
expect(second).toBe(first)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('translates common Chinese keywords before searching', async () => {
const fetchMock = vi.fn().mockResolvedValue(response({ icons: ['mdi:home'], total: 1, limit: 64, start: 0, collections: {} }))
vi.stubGlobal('fetch', fetchMock)
await searchIcons('主页')
expect(fetchMock.mock.calls[0][0]).toContain('query=home')
})
})
+40
View File
@@ -0,0 +1,40 @@
const iconPart = '[a-z0-9]+(?:-[a-z0-9]+)*'
const iconifyPattern = new RegExp(`^iconify:(${iconPart}):(${iconPart})$`)
const rawIconifyPattern = new RegExp(`^(${iconPart}):(${iconPart})$`)
export const DEFAULT_ICON = 'fas fa-icons'
export const parseIconValue = (value) => {
const normalized = String(value || '').trim()
const match = normalized.match(iconifyPattern)
if (match) {
return {
type: 'iconify',
name: `${match[1]}:${match[2]}`,
prefix: match[1],
icon: match[2],
value: normalized,
}
}
if (normalized.startsWith('iconify:')) {
return { type: 'invalid', value: normalized }
}
return { type: 'class', className: normalized, value: normalized }
}
export const toIconValue = (name) => {
const normalized = String(name || '').trim().toLowerCase()
return rawIconifyPattern.test(normalized) ? `iconify:${normalized}` : ''
}
export const isIconifyValue = (value) => parseIconValue(value).type === 'iconify'
export const iconDisplayName = (value) => {
const parsed = parseIconValue(value)
if (parsed.type === 'iconify') return parsed.icon
if (parsed.type === 'class') return parsed.className.split(/\s+/).pop()?.replace(/^fa-/, '') || ''
return ''
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_ICON, iconDisplayName, parseIconValue, toIconValue } from './iconValue'
describe('iconValue', () => {
it('parses Iconify values without changing legacy class values', () => {
expect(parseIconValue('iconify:mdi:home')).toMatchObject({ type: 'iconify', name: 'mdi:home' })
expect(parseIconValue('fas fa-home')).toMatchObject({ type: 'class', className: 'fas fa-home' })
expect(parseIconValue('iconify:mdi:')).toMatchObject({ type: 'invalid' })
})
it('normalizes a searchable Iconify name for persistence', () => {
expect(toIconValue('MDI:Home')).toBe('iconify:mdi:home')
expect(toIconValue('fas fa-home')).toBe('')
})
it('provides display names and a stable default', () => {
expect(iconDisplayName('iconify:tabler:arrow-up')).toBe('arrow-up')
expect(iconDisplayName('fas fa-arrow-up')).toBe('arrow-up')
expect(parseIconValue('').className).toBe('')
expect(DEFAULT_ICON).toBe('fas fa-icons')
})
})
+50
View File
@@ -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,
}
}
+24
View File
@@ -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')
})
})
+8 -4
View File
@@ -1,10 +1,14 @@
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()],
test: {
environment: 'jsdom',
globals: true,
},
css: {
preprocessorOptions: {
less: {
@@ -13,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,
},
},