Compare commits
3 Commits
6ab9e36818
...
608bff791d
| Author | SHA1 | Date | |
|---|---|---|---|
| 608bff791d | |||
| f7f59a7ee4 | |||
| e3aed70d6c |
@@ -23,34 +23,27 @@
|
||||
|
||||
如果你安装了 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
|
||||
```
|
||||
#### 构建全部平台版本(默认)
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
该命令会生成 Windows、Linux 和 macOS 的 amd64 可执行文件。
|
||||
|
||||
#### 构建 Linux 版本(用于服务器部署)
|
||||
```bash
|
||||
@@ -86,7 +79,7 @@ make backend-windows # Windows
|
||||
make backend-darwin # macOS
|
||||
```
|
||||
|
||||
**生成Ent代码(首次构建前需要):**
|
||||
**生成 Ent 代码(仅修改 Schema 后需要):**
|
||||
```bash
|
||||
make generate
|
||||
```
|
||||
@@ -103,16 +96,16 @@ make run
|
||||
|
||||
## 构建输出
|
||||
|
||||
构建完成后,`dist` 目录将只包含**单一可执行文件**:
|
||||
|
||||
```
|
||||
dist/
|
||||
└── home-vue-go.exe # Windows单一可执行文件(包含前后端)
|
||||
或
|
||||
└── home-vue-go # Linux/macOS单一可执行文件(包含前后端)
|
||||
```
|
||||
|
||||
**注意**:所有前端文件(HTML、CSS、JavaScript等)都已嵌入到二进制文件中,构建脚本会自动清理dist目录中的前端源文件。
|
||||
执行 `make build` 后,`dist` 目录将包含三个平台包:
|
||||
|
||||
```
|
||||
dist/
|
||||
├── home-vue-go-windows-amd64.exe
|
||||
├── home-vue-go-linux-amd64
|
||||
└── home-vue-go-darwin-amd64
|
||||
```
|
||||
|
||||
所有前端文件(HTML、CSS、JavaScript等)都会分别嵌入三个二进制文件中,构建脚本会自动清理 dist 中的前端源文件。
|
||||
|
||||
## 运行服务器
|
||||
|
||||
@@ -185,32 +178,17 @@ export FRONTEND_PORT=8081
|
||||
|
||||
## 注意事项
|
||||
|
||||
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端口未被占用
|
||||
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 +201,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/ 下载并安装
|
||||
|
||||
## 架构说明
|
||||
@@ -312,14 +290,9 @@ make build-linux
|
||||
# 构建完成后,dist/home-vue-go 就是Linux可执行文件
|
||||
```
|
||||
|
||||
**方式2:直接在Windows上构建(需要gcc工具链)**
|
||||
```bash
|
||||
# 如果遇到交叉编译错误,需要安装gcc工具链
|
||||
# 使用MSYS2安装:
|
||||
# pacman -S mingw-w64-x86_64-gcc
|
||||
|
||||
# 然后运行
|
||||
make build-linux
|
||||
**方式2:直接在 Windows 上交叉构建**
|
||||
```bash
|
||||
make build-linux
|
||||
```
|
||||
|
||||
**方式3:在Linux服务器上直接构建(最简单,推荐)**
|
||||
@@ -361,7 +334,7 @@ make build-linux
|
||||
- 端口映射:1551(后端API)、1552(前端界面)
|
||||
- 工作目录:可执行文件所在目录
|
||||
|
||||
**注意**:由于项目使用SQLite(需要CGO),在Windows上交叉编译Linux版本需要额外的工具链。推荐使用WSL或在Linux系统上直接构建。
|
||||
**注意**:SQLite 使用纯 Go 驱动,因此从 Windows 交叉构建 Linux 版本不需要额外的 C 工具链。
|
||||
|
||||
### 在 Linux 上构建 Windows 版本
|
||||
|
||||
|
||||
@@ -1,189 +1,184 @@
|
||||
.PHONY: generate build build-linux build-windows build-darwin clean run dist frontend backend
|
||||
|
||||
# 生成Ent代码
|
||||
generate:
|
||||
cd internal/ent && go generate ./...
|
||||
|
||||
# 构建前端
|
||||
frontend:
|
||||
npm install
|
||||
npm 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 "========================================"
|
||||
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
|
||||
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"
|
||||
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 "========================================"
|
||||
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
|
||||
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"
|
||||
endif
|
||||
|
||||
# 完整构建到dist目录(当前平台)- 单一可执行文件
|
||||
build: clean generate
|
||||
@echo "========================================"
|
||||
@echo "构建当前平台版本"
|
||||
@echo "========================================"
|
||||
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
|
||||
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"
|
||||
endif
|
||||
|
||||
# 打包到dist目录(推荐使用)
|
||||
dist: build
|
||||
|
||||
# 运行开发服务器
|
||||
run:
|
||||
go run main.go
|
||||
|
||||
# 清理
|
||||
clean:
|
||||
@echo "清理构建文件..."
|
||||
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
|
||||
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
|
||||
endif
|
||||
@echo "清理完成"
|
||||
.PHONY: generate frontend backend backend-linux backend-windows backend-darwin \
|
||||
build build-linux build-windows build-darwin clean clean-all dist run
|
||||
|
||||
.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:
|
||||
@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:
|
||||
@echo [frontend] Installing dependencies...
|
||||
@$(NPM_CMD) install --prefer-offline --no-audit --no-fund
|
||||
@echo [frontend] Building production assets...
|
||||
@$(NPM_CMD) run build
|
||||
|
||||
# Backend-only targets keep frontend files beside the executable for debugging.
|
||||
backend-windows: frontend
|
||||
@echo [backend] Building Windows amd64 executable...
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=windows" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME).exe .
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME).exe .
|
||||
endif
|
||||
|
||||
backend-linux: frontend
|
||||
@echo [backend] Building Linux amd64 executable...
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=linux" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME) .
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) .
|
||||
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
|
||||
endif
|
||||
|
||||
backend-darwin: frontend
|
||||
@echo [backend] Building macOS amd64 executable...
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=darwin" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME) .
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) .
|
||||
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
|
||||
endif
|
||||
|
||||
backend: frontend
|
||||
@echo [backend] Building for the current platform...
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME).exe .
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 $(GO) build -trimpath -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 -ldflags="-s -w" -o $(WINDOWS_TEMP) .
|
||||
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
|
||||
@mkdir $(DIST_DIR)
|
||||
@move /y $(WINDOWS_TEMP) $(DIST_DIR)\$(BINARY_NAME).exe >nul
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(WINDOWS_TEMP) .
|
||||
@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 -ldflags="-s -w" -o $(LINUX_TEMP) .
|
||||
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
|
||||
@mkdir $(DIST_DIR)
|
||||
@move /y $(LINUX_TEMP) $(DIST_DIR)\$(BINARY_NAME) >nul
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(LINUX_TEMP) .
|
||||
@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 -ldflags="-s -w" -o $(DARWIN_TEMP) .
|
||||
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
|
||||
@mkdir $(DIST_DIR)
|
||||
@move /y $(DARWIN_TEMP) $(DIST_DIR)\$(BINARY_NAME) >nul
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(DARWIN_TEMP) .
|
||||
@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 -ldflags="-s -w" -o $(ALL_WINDOWS_TEMP) .
|
||||
@echo [build] Building Linux amd64 package...
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=linux" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_LINUX_TEMP) .
|
||||
@echo [build] Building macOS amd64 package...
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=darwin" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_DARWIN_TEMP) .
|
||||
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
|
||||
@mkdir $(DIST_DIR)
|
||||
@move /y $(ALL_WINDOWS_TEMP) $(DIST_DIR)\$(BINARY_NAME)-windows-amd64.exe >nul
|
||||
@move /y $(ALL_LINUX_TEMP) $(DIST_DIR)\$(BINARY_NAME)-linux-amd64 >nul
|
||||
@move /y $(ALL_DARWIN_TEMP) $(DIST_DIR)\$(BINARY_NAME)-darwin-amd64 >nul
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_WINDOWS_TEMP) .
|
||||
@echo [build] Building Linux amd64 package...
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_LINUX_TEMP) .
|
||||
@echo [build] Building macOS amd64 package...
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_DARWIN_TEMP) .
|
||||
@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:
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && $(GO) run .
|
||||
else
|
||||
@GOPROXY="$(GOPROXY)" CGO_ENABLED=0 $(GO) run .
|
||||
endif
|
||||
|
||||
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.
|
||||
|
||||
@@ -57,9 +57,9 @@ npm install
|
||||
go mod download
|
||||
```
|
||||
|
||||
#### 3. 生成Ent代码(必须)
|
||||
#### 3. 生成 Ent 代码(仅修改 Schema 后)
|
||||
|
||||
**重要:** 在运行项目之前,必须先生成Ent代码,否则Go代码无法编译。
|
||||
生成后的 Ent 源码已提交到仓库,普通运行和构建不需要重复生成。只有修改 `internal/ent/schema` 后才运行:
|
||||
|
||||
```bash
|
||||
# 进入ent目录
|
||||
@@ -72,21 +72,11 @@ 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. 运行项目
|
||||
|
||||
@@ -132,28 +122,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
|
||||
@@ -166,7 +151,7 @@ npm run build
|
||||
|
||||
**说明:**
|
||||
- `go build` 编译Go程序为二进制文件
|
||||
- `CGO_ENABLED=1` 启用CGO(SQLite需要)
|
||||
- SQLite 使用纯 Go 驱动,构建不需要 CGO 或 GCC
|
||||
- `GOOS=linux GOARCH=amd64` 指定目标平台和架构
|
||||
- `-o` 指定输出文件名
|
||||
|
||||
@@ -257,10 +242,10 @@ cd internal/ent && go generate ./... && cd ../..
|
||||
go run main.go
|
||||
|
||||
# 构建后端(当前平台)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
+308
-305
@@ -1,305 +1,308 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"home-vue-go/internal/config"
|
||||
"home-vue-go/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type updateSiteConfigRequest struct {
|
||||
SiteName *string `json:"siteName"`
|
||||
SiteURL *string `json:"siteURL"`
|
||||
SiteIcon *string `json:"siteIcon"`
|
||||
SiteDescription *string `json:"siteDescription"`
|
||||
SiteKeywords *string `json:"siteKeywords"`
|
||||
UserName *string `json:"userName"`
|
||||
ProfileImageURL *string `json:"profileImageURL"`
|
||||
ICPNumber *string `json:"icpNumber"`
|
||||
PoliceNumber *string `json:"policeNumber"`
|
||||
PageTitle *string `json:"pageTitle"`
|
||||
Favicon *string `json:"favicon"`
|
||||
IconLibrary *string `json:"iconLibrary"`
|
||||
FontLibrary *string `json:"fontLibrary"`
|
||||
|
||||
FooterYearStart *string `json:"footerYearStart"`
|
||||
FooterYearEnd *string `json:"footerYearEnd"`
|
||||
ShowVisitTimer *bool `json:"showVisitTimer"`
|
||||
RotatingTexts *[]string `json:"rotatingTexts"`
|
||||
|
||||
GreetingText *string `json:"greetingText"`
|
||||
OnlineStatusText *string `json:"onlineStatusText"`
|
||||
FooterLabel *string `json:"footerLabel"`
|
||||
ShowAbout *bool `json:"showAbout"`
|
||||
ShowSites *bool `json:"showSites"`
|
||||
ShowContacts *bool `json:"showContacts"`
|
||||
ShowThemeToggle *bool `json:"showThemeToggle"`
|
||||
ShowFooter *bool `json:"showFooter"`
|
||||
AboutTitle *string `json:"aboutTitle"`
|
||||
AboutDescription *string `json:"aboutDescription"`
|
||||
AboutLinks *[]config.AboutLink `json:"aboutLinks"`
|
||||
SitePageSize *int `json:"sitePageSize"`
|
||||
OpenLinksNewTab *bool `json:"openLinksInNewTab"`
|
||||
|
||||
AnalyticsProvider *string `json:"analyticsProvider"`
|
||||
UmamiScript *string `json:"umamiScript"`
|
||||
UmamiScriptURL *string `json:"umamiScriptUrl"`
|
||||
UmamiWebsiteID *string `json:"umamiWebsiteId"`
|
||||
UmamiAPIMode *string `json:"umamiApiMode"`
|
||||
UmamiAPIURL *string `json:"umamiApiUrl"`
|
||||
UmamiCredential *string `json:"umamiCredential"`
|
||||
ClearUmamiCredential *bool `json:"clearUmamiCredential"`
|
||||
UmamiDomains *string `json:"umamiDomains"`
|
||||
UmamiDoNotTrack *bool `json:"umamiDoNotTrack"`
|
||||
UmamiExcludeSearch *bool `json:"umamiExcludeSearch"`
|
||||
UmamiExcludeHash *bool `json:"umamiExcludeHash"`
|
||||
UmamiPerformance *bool `json:"umamiPerformance"`
|
||||
UmamiTag *string `json:"umamiTag"`
|
||||
UmamiTrackerOptions *struct {
|
||||
Domains *string `json:"domains"`
|
||||
DoNotTrack *bool `json:"doNotTrack"`
|
||||
ExcludeSearch *bool `json:"excludeSearch"`
|
||||
ExcludeHash *bool `json:"excludeHash"`
|
||||
Performance *bool `json:"performance"`
|
||||
Tag *string `json:"tag"`
|
||||
} `json:"umamiTrackerOptions"`
|
||||
}
|
||||
|
||||
func GetSiteConfig(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
settings, err := db.LoadSiteSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, siteConfigResponse(settings, false))
|
||||
}
|
||||
}
|
||||
|
||||
func GetAdminSiteConfig(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
settings, err := db.LoadSiteSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, siteConfigResponse(settings, true))
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateSiteConfig(db *database.Database, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req updateSiteConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "配置参数格式错误"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
settings, err := db.LoadSiteSettings(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载现有站点配置失败"})
|
||||
return
|
||||
}
|
||||
next := settings.Clone()
|
||||
applyString(&next.SiteName, req.SiteName)
|
||||
applyString(&next.SiteURL, req.SiteURL)
|
||||
applyString(&next.SiteIcon, req.SiteIcon)
|
||||
applyString(&next.SiteDescription, req.SiteDescription)
|
||||
applyString(&next.SiteKeywords, req.SiteKeywords)
|
||||
applyString(&next.UserName, req.UserName)
|
||||
applyString(&next.ProfileImageURL, req.ProfileImageURL)
|
||||
applyString(&next.ICPNumber, req.ICPNumber)
|
||||
applyString(&next.PoliceNumber, req.PoliceNumber)
|
||||
applyString(&next.PageTitle, req.PageTitle)
|
||||
applyString(&next.Favicon, req.Favicon)
|
||||
applyString(&next.IconLibrary, req.IconLibrary)
|
||||
applyString(&next.FontLibrary, req.FontLibrary)
|
||||
applyString(&next.FooterYearStart, req.FooterYearStart)
|
||||
applyString(&next.FooterYearEnd, req.FooterYearEnd)
|
||||
applyBool(&next.ShowVisitTimer, req.ShowVisitTimer)
|
||||
applySlice(&next.RotatingTexts, req.RotatingTexts)
|
||||
applyString(&next.GreetingText, req.GreetingText)
|
||||
applyString(&next.OnlineStatusText, req.OnlineStatusText)
|
||||
applyString(&next.FooterLabel, req.FooterLabel)
|
||||
applyBool(&next.ShowAbout, req.ShowAbout)
|
||||
applyBool(&next.ShowSites, req.ShowSites)
|
||||
applyBool(&next.ShowContacts, req.ShowContacts)
|
||||
applyBool(&next.ShowThemeToggle, req.ShowThemeToggle)
|
||||
applyBool(&next.ShowFooter, req.ShowFooter)
|
||||
applyString(&next.AboutTitle, req.AboutTitle)
|
||||
applyString(&next.AboutDescription, req.AboutDescription)
|
||||
applySlice(&next.AboutLinks, req.AboutLinks)
|
||||
applyInt(&next.SitePageSize, req.SitePageSize)
|
||||
applyBool(&next.OpenLinksNewTab, req.OpenLinksNewTab)
|
||||
applyString(&next.AnalyticsProvider, req.AnalyticsProvider)
|
||||
applyString(&next.UmamiScript, req.UmamiScript)
|
||||
applyString(&next.UmamiScript, req.UmamiScriptURL)
|
||||
applyString(&next.UmamiWebsiteID, req.UmamiWebsiteID)
|
||||
applyString(&next.UmamiAPIMode, req.UmamiAPIMode)
|
||||
applyString(&next.UmamiAPIURL, req.UmamiAPIURL)
|
||||
applyString(&next.UmamiDomains, req.UmamiDomains)
|
||||
applyBool(&next.UmamiDoNotTrack, req.UmamiDoNotTrack)
|
||||
applyBool(&next.UmamiExcludeSearch, req.UmamiExcludeSearch)
|
||||
applyBool(&next.UmamiExcludeHash, req.UmamiExcludeHash)
|
||||
applyBool(&next.UmamiPerformance, req.UmamiPerformance)
|
||||
applyString(&next.UmamiTag, req.UmamiTag)
|
||||
if options := req.UmamiTrackerOptions; options != nil {
|
||||
applyString(&next.UmamiDomains, options.Domains)
|
||||
applyBool(&next.UmamiDoNotTrack, options.DoNotTrack)
|
||||
applyBool(&next.UmamiExcludeSearch, options.ExcludeSearch)
|
||||
applyBool(&next.UmamiExcludeHash, options.ExcludeHash)
|
||||
applyBool(&next.UmamiPerformance, options.Performance)
|
||||
applyString(&next.UmamiTag, options.Tag)
|
||||
}
|
||||
|
||||
if req.ClearUmamiCredential != nil && *req.ClearUmamiCredential {
|
||||
next.UmamiCredential = ""
|
||||
} else if req.UmamiCredential != nil {
|
||||
if strings.TrimSpace(*req.UmamiCredential) == "" {
|
||||
next.UmamiCredential = ""
|
||||
} else {
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密配置不可用"})
|
||||
return
|
||||
}
|
||||
encrypted, err := cfg.EncryptSecret(strings.TrimSpace(*req.UmamiCredential))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存统计凭据失败"})
|
||||
return
|
||||
}
|
||||
next.UmamiCredential = encrypted
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateSiteSettings(next); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := db.SaveSiteSettings(ctx, next); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, siteConfigResponse(next, true))
|
||||
}
|
||||
}
|
||||
|
||||
func GetRotatingTexts(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
settings, err := db.LoadSiteSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载轮换文本配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"texts": settings.RotatingTexts})
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateRotatingTexts(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Texts []string `json:"texts"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "轮换文本参数格式错误"})
|
||||
return
|
||||
}
|
||||
settings, err := db.LoadSiteSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"})
|
||||
return
|
||||
}
|
||||
settings.RotatingTexts = req.Texts
|
||||
settings.Normalize()
|
||||
if err := db.SaveSiteSettings(c.Request.Context(), settings); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存轮换文本配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "轮换文本配置已保存", "texts": settings.RotatingTexts})
|
||||
}
|
||||
}
|
||||
|
||||
func siteConfigResponse(settings *config.SiteSettings, admin bool) gin.H {
|
||||
result := gin.H{
|
||||
"siteName": settings.SiteName, "siteURL": settings.SiteURL, "siteIcon": settings.SiteIcon,
|
||||
"siteDescription": settings.SiteDescription, "siteKeywords": settings.SiteKeywords, "userName": settings.UserName,
|
||||
"profileImageURL": settings.ProfileImageURL, "icpNumber": settings.ICPNumber, "policeNumber": settings.PoliceNumber,
|
||||
"pageTitle": settings.PageTitle, "favicon": settings.Favicon, "iconLibrary": settings.IconLibrary, "fontLibrary": settings.FontLibrary,
|
||||
"footerYearStart": settings.FooterYearStart, "footerYearEnd": settings.FooterYearEnd, "showVisitTimer": settings.ShowVisitTimer,
|
||||
"rotatingTexts": settings.RotatingTexts, "greetingText": settings.GreetingText, "onlineStatusText": settings.OnlineStatusText,
|
||||
"footerLabel": settings.FooterLabel, "showAbout": settings.ShowAbout, "showSites": settings.ShowSites, "showContacts": settings.ShowContacts,
|
||||
"showThemeToggle": settings.ShowThemeToggle, "showFooter": settings.ShowFooter, "aboutTitle": settings.AboutTitle,
|
||||
"aboutDescription": settings.AboutDescription, "aboutLinks": settings.AboutLinks, "sitePageSize": settings.SitePageSize,
|
||||
"openLinksInNewTab": settings.OpenLinksNewTab, "analyticsProvider": settings.AnalyticsProvider, "umamiScript": settings.UmamiScript, "umamiScriptUrl": settings.UmamiScript,
|
||||
"umamiWebsiteId": settings.UmamiWebsiteID, "umamiDomains": settings.UmamiDomains, "umamiDoNotTrack": settings.UmamiDoNotTrack,
|
||||
"umamiExcludeSearch": settings.UmamiExcludeSearch, "umamiExcludeHash": settings.UmamiExcludeHash,
|
||||
"umamiPerformance": settings.UmamiPerformance, "umamiTag": settings.UmamiTag,
|
||||
"umamiTrackerOptions": gin.H{"domains": settings.UmamiDomains, "doNotTrack": settings.UmamiDoNotTrack, "excludeSearch": settings.UmamiExcludeSearch, "excludeHash": settings.UmamiExcludeHash, "performance": settings.UmamiPerformance, "tag": settings.UmamiTag},
|
||||
}
|
||||
if admin {
|
||||
result["umamiApiMode"] = settings.UmamiAPIMode
|
||||
result["umamiApiUrl"] = settings.UmamiAPIURL
|
||||
result["umamiCredentialConfigured"] = settings.UmamiCredential != ""
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validateSiteSettings(settings *config.SiteSettings) error {
|
||||
for name, value := range map[string]string{"站点URL": settings.SiteURL, "Umami脚本地址": settings.UmamiScript, "Umami API地址": settings.UmamiAPIURL} {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return fmt.Errorf("%s必须是有效的 http/https 地址", name)
|
||||
}
|
||||
}
|
||||
if settings.SitePageSize != 6 && settings.SitePageSize != 9 && settings.SitePageSize != 12 {
|
||||
return fmt.Errorf("站点每页数量只能是 6、9 或 12")
|
||||
}
|
||||
if settings.AnalyticsProvider != "local" && settings.AnalyticsProvider != "umami" {
|
||||
return fmt.Errorf("统计来源只能是 local 或 umami")
|
||||
}
|
||||
if settings.UmamiAPIMode != "selfhost" && settings.UmamiAPIMode != "cloud" {
|
||||
return fmt.Errorf("Umami API 模式不正确")
|
||||
}
|
||||
if len(settings.AboutLinks) > 8 {
|
||||
return fmt.Errorf("关于链接最多支持 8 条")
|
||||
}
|
||||
for _, link := range settings.AboutLinks {
|
||||
if strings.TrimSpace(link.URL) == "" {
|
||||
return fmt.Errorf("关于链接地址不能为空")
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(link.URL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return fmt.Errorf("关于链接必须是有效的 http/https 地址")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyString(target *string, value *string) {
|
||||
if value != nil {
|
||||
*target = strings.TrimSpace(*value)
|
||||
}
|
||||
}
|
||||
func applyBool(target *bool, value *bool) {
|
||||
if value != nil {
|
||||
*target = *value
|
||||
}
|
||||
}
|
||||
func applyInt(target *int, value *int) {
|
||||
if value != nil {
|
||||
*target = *value
|
||||
}
|
||||
}
|
||||
func applySlice[T any](target *[]T, value *[]T) {
|
||||
if value != nil {
|
||||
*target = *value
|
||||
}
|
||||
}
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"home-vue-go/internal/config"
|
||||
"home-vue-go/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type updateSiteConfigRequest struct {
|
||||
SiteName *string `json:"siteName"`
|
||||
SiteURL *string `json:"siteURL"`
|
||||
SiteIcon *string `json:"siteIcon"`
|
||||
SiteDescription *string `json:"siteDescription"`
|
||||
SiteKeywords *string `json:"siteKeywords"`
|
||||
UserName *string `json:"userName"`
|
||||
ProfileImageURL *string `json:"profileImageURL"`
|
||||
ICPNumber *string `json:"icpNumber"`
|
||||
PoliceNumber *string `json:"policeNumber"`
|
||||
PageTitle *string `json:"pageTitle"`
|
||||
Favicon *string `json:"favicon"`
|
||||
IconLibrary *string `json:"iconLibrary"`
|
||||
FontLibrary *string `json:"fontLibrary"`
|
||||
|
||||
FooterYearStart *string `json:"footerYearStart"`
|
||||
FooterYearEnd *string `json:"footerYearEnd"`
|
||||
ShowVisitTimer *bool `json:"showVisitTimer"`
|
||||
RotatingTexts *[]string `json:"rotatingTexts"`
|
||||
|
||||
GreetingText *string `json:"greetingText"`
|
||||
OnlineStatusText *string `json:"onlineStatusText"`
|
||||
FooterLabel *string `json:"footerLabel"`
|
||||
ShowAbout *bool `json:"showAbout"`
|
||||
ShowSites *bool `json:"showSites"`
|
||||
ShowContacts *bool `json:"showContacts"`
|
||||
ShowThemeToggle *bool `json:"showThemeToggle"`
|
||||
ShowFooter *bool `json:"showFooter"`
|
||||
AboutTitle *string `json:"aboutTitle"`
|
||||
AboutDescription *string `json:"aboutDescription"`
|
||||
AboutLinks *[]config.AboutLink `json:"aboutLinks"`
|
||||
SitePageSize *int `json:"sitePageSize"`
|
||||
OpenLinksNewTab *bool `json:"openLinksInNewTab"`
|
||||
|
||||
AnalyticsProvider *string `json:"analyticsProvider"`
|
||||
UmamiScript *string `json:"umamiScript"`
|
||||
UmamiScriptURL *string `json:"umamiScriptUrl"`
|
||||
UmamiWebsiteID *string `json:"umamiWebsiteId"`
|
||||
UmamiAPIMode *string `json:"umamiApiMode"`
|
||||
UmamiAPIURL *string `json:"umamiApiUrl"`
|
||||
UmamiCredential *string `json:"umamiCredential"`
|
||||
ClearUmamiCredential *bool `json:"clearUmamiCredential"`
|
||||
UmamiDomains *string `json:"umamiDomains"`
|
||||
UmamiDoNotTrack *bool `json:"umamiDoNotTrack"`
|
||||
UmamiExcludeSearch *bool `json:"umamiExcludeSearch"`
|
||||
UmamiExcludeHash *bool `json:"umamiExcludeHash"`
|
||||
UmamiPerformance *bool `json:"umamiPerformance"`
|
||||
UmamiTag *string `json:"umamiTag"`
|
||||
UmamiTrackerOptions *struct {
|
||||
Domains *string `json:"domains"`
|
||||
DoNotTrack *bool `json:"doNotTrack"`
|
||||
ExcludeSearch *bool `json:"excludeSearch"`
|
||||
ExcludeHash *bool `json:"excludeHash"`
|
||||
Performance *bool `json:"performance"`
|
||||
Tag *string `json:"tag"`
|
||||
} `json:"umamiTrackerOptions"`
|
||||
}
|
||||
|
||||
func GetSiteConfig(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
settings, err := db.LoadSiteSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, siteConfigResponse(settings, false))
|
||||
}
|
||||
}
|
||||
|
||||
func GetAdminSiteConfig(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
settings, err := db.LoadSiteSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, siteConfigResponse(settings, true))
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateSiteConfig(db *database.Database, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req updateSiteConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "配置参数格式错误"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
settings, err := db.LoadSiteSettings(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载现有站点配置失败"})
|
||||
return
|
||||
}
|
||||
next := settings.Clone()
|
||||
applyString(&next.SiteName, req.SiteName)
|
||||
applyString(&next.SiteURL, req.SiteURL)
|
||||
applyString(&next.SiteIcon, req.SiteIcon)
|
||||
applyString(&next.SiteDescription, req.SiteDescription)
|
||||
applyString(&next.SiteKeywords, req.SiteKeywords)
|
||||
applyString(&next.UserName, req.UserName)
|
||||
applyString(&next.ProfileImageURL, req.ProfileImageURL)
|
||||
applyString(&next.ICPNumber, req.ICPNumber)
|
||||
applyString(&next.PoliceNumber, req.PoliceNumber)
|
||||
applyString(&next.PageTitle, req.PageTitle)
|
||||
applyString(&next.Favicon, req.Favicon)
|
||||
applyString(&next.IconLibrary, req.IconLibrary)
|
||||
applyString(&next.FontLibrary, req.FontLibrary)
|
||||
applyString(&next.FooterYearStart, req.FooterYearStart)
|
||||
applyString(&next.FooterYearEnd, req.FooterYearEnd)
|
||||
applyBool(&next.ShowVisitTimer, req.ShowVisitTimer)
|
||||
applySlice(&next.RotatingTexts, req.RotatingTexts)
|
||||
applyString(&next.GreetingText, req.GreetingText)
|
||||
applyString(&next.OnlineStatusText, req.OnlineStatusText)
|
||||
applyString(&next.FooterLabel, req.FooterLabel)
|
||||
applyBool(&next.ShowAbout, req.ShowAbout)
|
||||
applyBool(&next.ShowSites, req.ShowSites)
|
||||
applyBool(&next.ShowContacts, req.ShowContacts)
|
||||
applyBool(&next.ShowThemeToggle, req.ShowThemeToggle)
|
||||
applyBool(&next.ShowFooter, req.ShowFooter)
|
||||
applyString(&next.AboutTitle, req.AboutTitle)
|
||||
applyString(&next.AboutDescription, req.AboutDescription)
|
||||
applySlice(&next.AboutLinks, req.AboutLinks)
|
||||
applyInt(&next.SitePageSize, req.SitePageSize)
|
||||
applyBool(&next.OpenLinksNewTab, req.OpenLinksNewTab)
|
||||
applyString(&next.AnalyticsProvider, req.AnalyticsProvider)
|
||||
applyString(&next.UmamiScript, req.UmamiScript)
|
||||
applyString(&next.UmamiScript, req.UmamiScriptURL)
|
||||
applyString(&next.UmamiWebsiteID, req.UmamiWebsiteID)
|
||||
applyString(&next.UmamiAPIMode, req.UmamiAPIMode)
|
||||
applyString(&next.UmamiAPIURL, req.UmamiAPIURL)
|
||||
applyString(&next.UmamiDomains, req.UmamiDomains)
|
||||
applyBool(&next.UmamiDoNotTrack, req.UmamiDoNotTrack)
|
||||
applyBool(&next.UmamiExcludeSearch, req.UmamiExcludeSearch)
|
||||
applyBool(&next.UmamiExcludeHash, req.UmamiExcludeHash)
|
||||
applyBool(&next.UmamiPerformance, req.UmamiPerformance)
|
||||
applyString(&next.UmamiTag, req.UmamiTag)
|
||||
if options := req.UmamiTrackerOptions; options != nil {
|
||||
applyString(&next.UmamiDomains, options.Domains)
|
||||
applyBool(&next.UmamiDoNotTrack, options.DoNotTrack)
|
||||
applyBool(&next.UmamiExcludeSearch, options.ExcludeSearch)
|
||||
applyBool(&next.UmamiExcludeHash, options.ExcludeHash)
|
||||
applyBool(&next.UmamiPerformance, options.Performance)
|
||||
applyString(&next.UmamiTag, options.Tag)
|
||||
}
|
||||
|
||||
if req.ClearUmamiCredential != nil && *req.ClearUmamiCredential {
|
||||
next.UmamiCredential = ""
|
||||
} else if req.UmamiCredential != nil {
|
||||
if strings.TrimSpace(*req.UmamiCredential) == "" {
|
||||
next.UmamiCredential = ""
|
||||
} else {
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密配置不可用"})
|
||||
return
|
||||
}
|
||||
encrypted, err := cfg.EncryptSecret(strings.TrimSpace(*req.UmamiCredential))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存统计凭据失败"})
|
||||
return
|
||||
}
|
||||
next.UmamiCredential = encrypted
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateSiteSettings(next); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := db.SaveSiteSettings(ctx, next); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, siteConfigResponse(next, true))
|
||||
}
|
||||
}
|
||||
|
||||
func GetRotatingTexts(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
settings, err := db.LoadSiteSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载轮换文本配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"texts": settings.RotatingTexts})
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateRotatingTexts(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Texts []string `json:"texts"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "轮换文本参数格式错误"})
|
||||
return
|
||||
}
|
||||
settings, err := db.LoadSiteSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"})
|
||||
return
|
||||
}
|
||||
settings.RotatingTexts = req.Texts
|
||||
settings.Normalize()
|
||||
if err := db.SaveSiteSettings(c.Request.Context(), settings); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存轮换文本配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "轮换文本配置已保存", "texts": settings.RotatingTexts})
|
||||
}
|
||||
}
|
||||
|
||||
func siteConfigResponse(settings *config.SiteSettings, admin bool) gin.H {
|
||||
result := gin.H{
|
||||
"siteName": settings.SiteName, "siteURL": settings.SiteURL, "siteIcon": settings.SiteIcon,
|
||||
"siteDescription": settings.SiteDescription, "siteKeywords": settings.SiteKeywords, "userName": settings.UserName,
|
||||
"profileImageURL": settings.ProfileImageURL, "icpNumber": settings.ICPNumber, "policeNumber": settings.PoliceNumber,
|
||||
"pageTitle": settings.PageTitle, "favicon": settings.Favicon, "iconLibrary": settings.IconLibrary, "fontLibrary": settings.FontLibrary,
|
||||
"footerYearStart": settings.FooterYearStart, "footerYearEnd": settings.FooterYearEnd, "showVisitTimer": settings.ShowVisitTimer,
|
||||
"rotatingTexts": settings.RotatingTexts, "greetingText": settings.GreetingText, "onlineStatusText": settings.OnlineStatusText,
|
||||
"footerLabel": settings.FooterLabel, "showAbout": settings.ShowAbout, "showSites": settings.ShowSites, "showContacts": settings.ShowContacts,
|
||||
"showThemeToggle": settings.ShowThemeToggle, "showFooter": settings.ShowFooter, "aboutTitle": settings.AboutTitle,
|
||||
"aboutDescription": settings.AboutDescription, "aboutLinks": settings.AboutLinks, "sitePageSize": settings.SitePageSize,
|
||||
"openLinksInNewTab": settings.OpenLinksNewTab, "analyticsProvider": settings.AnalyticsProvider, "umamiScript": settings.UmamiScript, "umamiScriptUrl": settings.UmamiScript,
|
||||
"umamiWebsiteId": settings.UmamiWebsiteID, "umamiDomains": settings.UmamiDomains, "umamiDoNotTrack": settings.UmamiDoNotTrack,
|
||||
"umamiExcludeSearch": settings.UmamiExcludeSearch, "umamiExcludeHash": settings.UmamiExcludeHash,
|
||||
"umamiPerformance": settings.UmamiPerformance, "umamiTag": settings.UmamiTag,
|
||||
"umamiTrackerOptions": gin.H{"domains": settings.UmamiDomains, "doNotTrack": settings.UmamiDoNotTrack, "excludeSearch": settings.UmamiExcludeSearch, "excludeHash": settings.UmamiExcludeHash, "performance": settings.UmamiPerformance, "tag": settings.UmamiTag},
|
||||
}
|
||||
if admin {
|
||||
result["umamiApiMode"] = settings.UmamiAPIMode
|
||||
result["umamiApiUrl"] = settings.UmamiAPIURL
|
||||
result["umamiCredentialConfigured"] = settings.UmamiCredential != ""
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validateSiteSettings(settings *config.SiteSettings) error {
|
||||
for name, value := range map[string]string{"站点URL": settings.SiteURL, "Umami脚本地址": settings.UmamiScript, "Umami API地址": settings.UmamiAPIURL} {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return fmt.Errorf("%s必须是有效的 http/https 地址", name)
|
||||
}
|
||||
}
|
||||
if settings.SitePageSize != 6 && settings.SitePageSize != 9 && settings.SitePageSize != 12 {
|
||||
return fmt.Errorf("站点每页数量只能是 6、9 或 12")
|
||||
}
|
||||
if settings.AnalyticsProvider != "local" && settings.AnalyticsProvider != "umami" {
|
||||
return fmt.Errorf("统计来源只能是 local 或 umami")
|
||||
}
|
||||
if settings.UmamiAPIMode != "selfhost" && settings.UmamiAPIMode != "cloud" {
|
||||
return fmt.Errorf("Umami API 模式不正确")
|
||||
}
|
||||
if len(settings.AboutLinks) > 8 {
|
||||
return fmt.Errorf("关于链接最多支持 8 条")
|
||||
}
|
||||
for _, link := range settings.AboutLinks {
|
||||
if strings.TrimSpace(link.URL) == "" {
|
||||
return fmt.Errorf("关于链接地址不能为空")
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(link.URL)
|
||||
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
|
||||
}
|
||||
|
||||
func applyString(target *string, value *string) {
|
||||
if value != nil {
|
||||
*target = strings.TrimSpace(*value)
|
||||
}
|
||||
}
|
||||
func applyBool(target *bool, value *bool) {
|
||||
if value != nil {
|
||||
*target = *value
|
||||
}
|
||||
}
|
||||
func applyInt(target *int, value *int) {
|
||||
if value != nil {
|
||||
*target = *value
|
||||
}
|
||||
}
|
||||
func applySlice[T any](target *[]T, value *[]T) {
|
||||
if value != nil {
|
||||
*target = *value
|
||||
}
|
||||
}
|
||||
|
||||
+319
-319
@@ -1,319 +1,319 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"home-vue-go/internal/database"
|
||||
"home-vue-go/internal/ent"
|
||||
"home-vue-go/internal/ent/contact"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetContacts(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contacts, err := db.Client.Contact.Query().Order(contact.BySortOrder(), contact.ByID()).All(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]gin.H, len(contacts))
|
||||
for i, contact := range contacts {
|
||||
result[i] = gin.H{
|
||||
"id": contact.ID,
|
||||
"type": contact.Type,
|
||||
"icon": contact.Icon,
|
||||
"url": contact.URL,
|
||||
"qrCode": contact.QrCode,
|
||||
"hoverColor": contact.HoverColor,
|
||||
"sortOrder": contact.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func ReorderContacts(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []int `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
seen := make(map[int]bool, len(req.IDs))
|
||||
for _, id := range req.IDs {
|
||||
if id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含无效ID"})
|
||||
return
|
||||
}
|
||||
if seen[id] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含重复ID"})
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
existingIDs, err := db.Client.Contact.Query().IDs(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(existingIDs) != len(req.IDs) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表与当前联系方式数量不一致"})
|
||||
return
|
||||
}
|
||||
existing := make(map[int]bool, len(existingIDs))
|
||||
for _, id := range existingIDs {
|
||||
existing[id] = true
|
||||
}
|
||||
for _, id := range req.IDs {
|
||||
if !existing[id] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含不存在的联系方式"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.Client.Tx(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for index, id := range req.IDs {
|
||||
if _, err := tx.Contact.UpdateOneID(id).SetSortOrder((index + 1) * 10).Save(ctx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
contacts, err := db.Client.Contact.Query().Order(contact.BySortOrder(), contact.ByID()).All(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]gin.H, len(contacts))
|
||||
for i, contact := range contacts {
|
||||
result[i] = gin.H{
|
||||
"id": contact.ID,
|
||||
"type": contact.Type,
|
||||
"icon": contact.Icon,
|
||||
"url": contact.URL,
|
||||
"qrCode": contact.QrCode,
|
||||
"hoverColor": contact.HoverColor,
|
||||
"sortOrder": contact.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func CreateContact(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
Icon string `json:"icon" binding:"required"`
|
||||
URL string `json:"url"`
|
||||
QrCode string `json:"qrCode"`
|
||||
HoverColor string `json:"hoverColor"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Type = strings.TrimSpace(req.Type)
|
||||
req.Icon = strings.TrimSpace(req.Icon)
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
req.QrCode = strings.TrimSpace(req.QrCode)
|
||||
req.HoverColor = strings.TrimSpace(req.HoverColor)
|
||||
if req.Type == "" || req.Icon == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "类型和图标不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.URL != "" && !validContactURL(req.URL) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式URL必须是有效的http/https/mailto/tel地址"})
|
||||
return
|
||||
}
|
||||
if req.QrCode != "" && !validQRCode(req.QrCode) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "二维码必须使用本地上传路径或安全的http/https图片地址"})
|
||||
return
|
||||
}
|
||||
if req.URL != "" && req.QrCode != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式不能同时设置链接和二维码"})
|
||||
return
|
||||
}
|
||||
if req.URL == "" && req.QrCode == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "链接或二维码不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
create := db.Client.Contact.Create().
|
||||
SetType(req.Type).
|
||||
SetIcon(req.Icon).
|
||||
SetSortOrder(req.SortOrder)
|
||||
|
||||
if req.URL != "" {
|
||||
create.SetURL(req.URL)
|
||||
}
|
||||
if req.QrCode != "" {
|
||||
create.SetQrCode(req.QrCode)
|
||||
}
|
||||
if req.HoverColor != "" {
|
||||
create.SetHoverColor(req.HoverColor)
|
||||
}
|
||||
|
||||
contact, err := create.Save(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": contact.ID,
|
||||
"type": contact.Type,
|
||||
"icon": contact.Icon,
|
||||
"url": contact.URL,
|
||||
"qrCode": contact.QrCode,
|
||||
"hoverColor": contact.HoverColor,
|
||||
"sortOrder": contact.SortOrder,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateContact(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
Icon string `json:"icon" binding:"required"`
|
||||
URL string `json:"url"`
|
||||
QrCode string `json:"qrCode"`
|
||||
HoverColor string `json:"hoverColor"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Type = strings.TrimSpace(req.Type)
|
||||
req.Icon = strings.TrimSpace(req.Icon)
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
req.QrCode = strings.TrimSpace(req.QrCode)
|
||||
req.HoverColor = strings.TrimSpace(req.HoverColor)
|
||||
if req.Type == "" || req.Icon == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "类型和图标不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.URL != "" && !validContactURL(req.URL) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式URL必须是有效的http/https/mailto/tel地址"})
|
||||
return
|
||||
}
|
||||
if req.QrCode != "" && !validQRCode(req.QrCode) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "二维码必须使用本地上传路径或安全的http/https图片地址"})
|
||||
return
|
||||
}
|
||||
if req.URL != "" && req.QrCode != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式不能同时设置链接和二维码"})
|
||||
return
|
||||
}
|
||||
if req.URL == "" && req.QrCode == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "链接或二维码不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
update := db.Client.Contact.UpdateOneID(id)
|
||||
update.SetType(req.Type)
|
||||
update.SetIcon(req.Icon)
|
||||
if req.URL != "" {
|
||||
update.SetURL(req.URL)
|
||||
} else {
|
||||
update.ClearURL()
|
||||
}
|
||||
if req.QrCode != "" {
|
||||
update.SetQrCode(req.QrCode)
|
||||
} else {
|
||||
update.ClearQrCode()
|
||||
}
|
||||
if req.HoverColor != "" {
|
||||
update.SetHoverColor(req.HoverColor)
|
||||
} else {
|
||||
update.ClearHoverColor()
|
||||
}
|
||||
update.SetSortOrder(req.SortOrder)
|
||||
|
||||
contact, err := update.Save(ctx)
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "联系方式不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": contact.ID,
|
||||
"type": contact.Type,
|
||||
"icon": contact.Icon,
|
||||
"url": contact.URL,
|
||||
"qrCode": contact.QrCode,
|
||||
"hoverColor": contact.HoverColor,
|
||||
"sortOrder": contact.SortOrder,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteContact(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err := db.Client.Contact.DeleteOneID(id).Exec(ctx); err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "联系方式不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
}
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"home-vue-go/internal/database"
|
||||
"home-vue-go/internal/ent"
|
||||
"home-vue-go/internal/ent/contact"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetContacts(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contacts, err := db.Client.Contact.Query().Order(contact.BySortOrder(), contact.ByID()).All(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]gin.H, len(contacts))
|
||||
for i, contact := range contacts {
|
||||
result[i] = gin.H{
|
||||
"id": contact.ID,
|
||||
"type": contact.Type,
|
||||
"icon": contact.Icon,
|
||||
"url": contact.URL,
|
||||
"qrCode": contact.QrCode,
|
||||
"hoverColor": contact.HoverColor,
|
||||
"sortOrder": contact.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func ReorderContacts(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []int `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
seen := make(map[int]bool, len(req.IDs))
|
||||
for _, id := range req.IDs {
|
||||
if id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含无效ID"})
|
||||
return
|
||||
}
|
||||
if seen[id] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含重复ID"})
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
existingIDs, err := db.Client.Contact.Query().IDs(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(existingIDs) != len(req.IDs) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表与当前联系方式数量不一致"})
|
||||
return
|
||||
}
|
||||
existing := make(map[int]bool, len(existingIDs))
|
||||
for _, id := range existingIDs {
|
||||
existing[id] = true
|
||||
}
|
||||
for _, id := range req.IDs {
|
||||
if !existing[id] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含不存在的联系方式"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.Client.Tx(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for index, id := range req.IDs {
|
||||
if _, err := tx.Contact.UpdateOneID(id).SetSortOrder((index + 1) * 10).Save(ctx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
contacts, err := db.Client.Contact.Query().Order(contact.BySortOrder(), contact.ByID()).All(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]gin.H, len(contacts))
|
||||
for i, contact := range contacts {
|
||||
result[i] = gin.H{
|
||||
"id": contact.ID,
|
||||
"type": contact.Type,
|
||||
"icon": contact.Icon,
|
||||
"url": contact.URL,
|
||||
"qrCode": contact.QrCode,
|
||||
"hoverColor": contact.HoverColor,
|
||||
"sortOrder": contact.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func CreateContact(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
Icon string `json:"icon" binding:"required"`
|
||||
URL string `json:"url"`
|
||||
QrCode string `json:"qrCode"`
|
||||
HoverColor string `json:"hoverColor"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Type = strings.TrimSpace(req.Type)
|
||||
req.Icon = strings.TrimSpace(req.Icon)
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
req.QrCode = strings.TrimSpace(req.QrCode)
|
||||
req.HoverColor = strings.TrimSpace(req.HoverColor)
|
||||
if req.Type == "" || !validIconValue(req.Icon) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "类型和图标不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.URL != "" && !validContactURL(req.URL) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式URL必须是有效的http/https/mailto/tel地址"})
|
||||
return
|
||||
}
|
||||
if req.QrCode != "" && !validQRCode(req.QrCode) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "二维码必须使用本地上传路径或安全的http/https图片地址"})
|
||||
return
|
||||
}
|
||||
if req.URL != "" && req.QrCode != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式不能同时设置链接和二维码"})
|
||||
return
|
||||
}
|
||||
if req.URL == "" && req.QrCode == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "链接或二维码不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
create := db.Client.Contact.Create().
|
||||
SetType(req.Type).
|
||||
SetIcon(req.Icon).
|
||||
SetSortOrder(req.SortOrder)
|
||||
|
||||
if req.URL != "" {
|
||||
create.SetURL(req.URL)
|
||||
}
|
||||
if req.QrCode != "" {
|
||||
create.SetQrCode(req.QrCode)
|
||||
}
|
||||
if req.HoverColor != "" {
|
||||
create.SetHoverColor(req.HoverColor)
|
||||
}
|
||||
|
||||
contact, err := create.Save(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": contact.ID,
|
||||
"type": contact.Type,
|
||||
"icon": contact.Icon,
|
||||
"url": contact.URL,
|
||||
"qrCode": contact.QrCode,
|
||||
"hoverColor": contact.HoverColor,
|
||||
"sortOrder": contact.SortOrder,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateContact(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
Icon string `json:"icon" binding:"required"`
|
||||
URL string `json:"url"`
|
||||
QrCode string `json:"qrCode"`
|
||||
HoverColor string `json:"hoverColor"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Type = strings.TrimSpace(req.Type)
|
||||
req.Icon = strings.TrimSpace(req.Icon)
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
req.QrCode = strings.TrimSpace(req.QrCode)
|
||||
req.HoverColor = strings.TrimSpace(req.HoverColor)
|
||||
if req.Type == "" || !validIconValue(req.Icon) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "类型和图标不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.URL != "" && !validContactURL(req.URL) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式URL必须是有效的http/https/mailto/tel地址"})
|
||||
return
|
||||
}
|
||||
if req.QrCode != "" && !validQRCode(req.QrCode) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "二维码必须使用本地上传路径或安全的http/https图片地址"})
|
||||
return
|
||||
}
|
||||
if req.URL != "" && req.QrCode != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式不能同时设置链接和二维码"})
|
||||
return
|
||||
}
|
||||
if req.URL == "" && req.QrCode == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "链接或二维码不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
update := db.Client.Contact.UpdateOneID(id)
|
||||
update.SetType(req.Type)
|
||||
update.SetIcon(req.Icon)
|
||||
if req.URL != "" {
|
||||
update.SetURL(req.URL)
|
||||
} else {
|
||||
update.ClearURL()
|
||||
}
|
||||
if req.QrCode != "" {
|
||||
update.SetQrCode(req.QrCode)
|
||||
} else {
|
||||
update.ClearQrCode()
|
||||
}
|
||||
if req.HoverColor != "" {
|
||||
update.SetHoverColor(req.HoverColor)
|
||||
} else {
|
||||
update.ClearHoverColor()
|
||||
}
|
||||
update.SetSortOrder(req.SortOrder)
|
||||
|
||||
contact, err := update.Save(ctx)
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "联系方式不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": contact.ID,
|
||||
"type": contact.Type,
|
||||
"icon": contact.Icon,
|
||||
"url": contact.URL,
|
||||
"qrCode": contact.QrCode,
|
||||
"hoverColor": contact.HoverColor,
|
||||
"sortOrder": contact.SortOrder,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteContact(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err := db.Client.Contact.DeleteOneID(id).Exec(ctx); err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "联系方式不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
}
|
||||
|
||||
+242
-242
@@ -1,242 +1,242 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"home-vue-go/internal/database"
|
||||
"home-vue-go/internal/ent"
|
||||
"home-vue-go/internal/ent/site"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetSites(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
sites, err := db.Client.Site.Query().Order(site.BySortOrder(), site.ByID()).All(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]gin.H, len(sites))
|
||||
for i, site := range sites {
|
||||
result[i] = gin.H{
|
||||
"id": site.ID,
|
||||
"name": site.Name,
|
||||
"url": site.URL,
|
||||
"icon": site.Icon,
|
||||
"sortOrder": site.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func ReorderSites(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []int `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
seen := make(map[int]bool, len(req.IDs))
|
||||
for _, id := range req.IDs {
|
||||
if id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含无效ID"})
|
||||
return
|
||||
}
|
||||
if seen[id] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含重复ID"})
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
existingIDs, err := db.Client.Site.Query().IDs(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(existingIDs) != len(req.IDs) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表与当前站点数量不一致"})
|
||||
return
|
||||
}
|
||||
existing := make(map[int]bool, len(existingIDs))
|
||||
for _, id := range existingIDs {
|
||||
existing[id] = true
|
||||
}
|
||||
for _, id := range req.IDs {
|
||||
if !existing[id] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含不存在的站点"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.Client.Tx(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for index, id := range req.IDs {
|
||||
if _, err := tx.Site.UpdateOneID(id).SetSortOrder((index + 1) * 10).Save(ctx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
sites, err := db.Client.Site.Query().Order(site.BySortOrder(), site.ByID()).All(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]gin.H, len(sites))
|
||||
for i, site := range sites {
|
||||
result[i] = gin.H{
|
||||
"id": site.ID,
|
||||
"name": site.Name,
|
||||
"url": site.URL,
|
||||
"icon": site.Icon,
|
||||
"sortOrder": site.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func CreateSite(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Icon string `json:"icon" binding:"required"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
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) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称、图标不能为空,URL必须是有效的http/https地址"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
site, err := db.Client.Site.Create().
|
||||
SetName(req.Name).
|
||||
SetURL(req.URL).
|
||||
SetIcon(req.Icon).
|
||||
SetSortOrder(req.SortOrder).
|
||||
Save(ctx)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": site.ID,
|
||||
"name": site.Name,
|
||||
"url": site.URL,
|
||||
"icon": site.Icon,
|
||||
"sortOrder": site.SortOrder,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateSite(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Icon string `json:"icon"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
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) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称、图标不能为空,URL必须是有效的http/https地址"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
update := db.Client.Site.UpdateOneID(id)
|
||||
update.SetName(req.Name)
|
||||
update.SetURL(req.URL)
|
||||
update.SetIcon(req.Icon)
|
||||
update.SetSortOrder(req.SortOrder)
|
||||
|
||||
site, err := update.Save(ctx)
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "站点不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": site.ID,
|
||||
"name": site.Name,
|
||||
"url": site.URL,
|
||||
"icon": site.Icon,
|
||||
"sortOrder": site.SortOrder,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteSite(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err := db.Client.Site.DeleteOneID(id).Exec(ctx); err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "站点不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
}
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"home-vue-go/internal/database"
|
||||
"home-vue-go/internal/ent"
|
||||
"home-vue-go/internal/ent/site"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetSites(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
sites, err := db.Client.Site.Query().Order(site.BySortOrder(), site.ByID()).All(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]gin.H, len(sites))
|
||||
for i, site := range sites {
|
||||
result[i] = gin.H{
|
||||
"id": site.ID,
|
||||
"name": site.Name,
|
||||
"url": site.URL,
|
||||
"icon": site.Icon,
|
||||
"sortOrder": site.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func ReorderSites(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []int `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
seen := make(map[int]bool, len(req.IDs))
|
||||
for _, id := range req.IDs {
|
||||
if id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含无效ID"})
|
||||
return
|
||||
}
|
||||
if seen[id] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含重复ID"})
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
existingIDs, err := db.Client.Site.Query().IDs(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(existingIDs) != len(req.IDs) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表与当前站点数量不一致"})
|
||||
return
|
||||
}
|
||||
existing := make(map[int]bool, len(existingIDs))
|
||||
for _, id := range existingIDs {
|
||||
existing[id] = true
|
||||
}
|
||||
for _, id := range req.IDs {
|
||||
if !existing[id] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含不存在的站点"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.Client.Tx(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for index, id := range req.IDs {
|
||||
if _, err := tx.Site.UpdateOneID(id).SetSortOrder((index + 1) * 10).Save(ctx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
sites, err := db.Client.Site.Query().Order(site.BySortOrder(), site.ByID()).All(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]gin.H, len(sites))
|
||||
for i, site := range sites {
|
||||
result[i] = gin.H{
|
||||
"id": site.ID,
|
||||
"name": site.Name,
|
||||
"url": site.URL,
|
||||
"icon": site.Icon,
|
||||
"sortOrder": site.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func CreateSite(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Icon string `json:"icon" binding:"required"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Name, req.URL, req.Icon = strings.TrimSpace(req.Name), strings.TrimSpace(req.URL), strings.TrimSpace(req.Icon)
|
||||
if req.Name == "" || req.URL == "" || !validIconValue(req.Icon) || !validHTTPURL(req.URL) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称、图标不能为空,URL必须是有效的http/https地址"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
site, err := db.Client.Site.Create().
|
||||
SetName(req.Name).
|
||||
SetURL(req.URL).
|
||||
SetIcon(req.Icon).
|
||||
SetSortOrder(req.SortOrder).
|
||||
Save(ctx)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": site.ID,
|
||||
"name": site.Name,
|
||||
"url": site.URL,
|
||||
"icon": site.Icon,
|
||||
"sortOrder": site.SortOrder,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateSite(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Icon string `json:"icon"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Name, req.URL, req.Icon = strings.TrimSpace(req.Name), strings.TrimSpace(req.URL), strings.TrimSpace(req.Icon)
|
||||
if req.Name == "" || req.URL == "" || !validIconValue(req.Icon) || !validHTTPURL(req.URL) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称、图标不能为空,URL必须是有效的http/https地址"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
update := db.Client.Site.UpdateOneID(id)
|
||||
update.SetName(req.Name)
|
||||
update.SetURL(req.URL)
|
||||
update.SetIcon(req.Icon)
|
||||
update.SetSortOrder(req.SortOrder)
|
||||
|
||||
site, err := update.Save(ctx)
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "站点不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": site.ID,
|
||||
"name": site.Name,
|
||||
"url": site.URL,
|
||||
"icon": site.Icon,
|
||||
"sortOrder": site.SortOrder,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteSite(db *database.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err := db.Client.Site.DeleteOneID(id).Exec(ctx); err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "站点不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
|
||||
"home-vue-go/internal/api"
|
||||
"home-vue-go/internal/config"
|
||||
|
||||
Generated
+1503
-59
File diff suppressed because it is too large
Load Diff
+7
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,7 +134,7 @@
|
||||
<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>
|
||||
|
||||
@@ -162,6 +163,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { adminAPI } from '../api'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { loadAndApplyFrontendConfig } from '../utils/frontendConfig'
|
||||
import AppIcon from './AppIcon.vue'
|
||||
import Dashboard from './Dashboard.vue'
|
||||
import IconPicker from './IconPicker.vue'
|
||||
import IconSelector from './IconSelector.vue'
|
||||
@@ -196,6 +198,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,8 +226,16 @@ 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',
|
||||
@@ -381,7 +392,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 +571,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; }
|
||||
|
||||
@@ -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']))
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
+524
-439
@@ -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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="icon-picker-footer" v-if="modelValue">
|
||||
<div class="selected-icon">
|
||||
<span>已选择:</span>
|
||||
<i :class="modelValue"></i>
|
||||
<code>{{ modelValue }}</code>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
const activeCategory = ref('all')
|
||||
|
||||
// Font Awesome 常用图标分类
|
||||
const categories = [
|
||||
<template>
|
||||
<div class="icon-picker">
|
||||
<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>
|
||||
</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 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>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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 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: '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' },
|
||||
]
|
||||
|
||||
// 常用图标列表(按分类)
|
||||
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 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)
|
||||
)
|
||||
}
|
||||
|
||||
return icons
|
||||
})
|
||||
|
||||
const getIconName = (icon) => {
|
||||
// 从 "fas fa-home" 提取 "home"
|
||||
const parts = icon.split(' ')
|
||||
return parts[parts.length - 1] || icon
|
||||
}
|
||||
|
||||
const selectIcon = (icon) => {
|
||||
emit('update:modelValue', icon)
|
||||
}
|
||||
|
||||
const clearIcon = () => {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
const closePicker = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const filterIcons = () => {
|
||||
// 搜索时自动切换到"全部"分类
|
||||
if (searchQuery.value.trim() && activeCategory.value !== 'all') {
|
||||
activeCategory.value = 'all'
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal) {
|
||||
// 如果选择了图标,可以高亮显示
|
||||
}
|
||||
})
|
||||
</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-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));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{ name: 'business', label: '商业', icon: 'fas fa-briefcase' },
|
||||
{ name: 'tech', label: '技术', icon: 'fas fa-code' },
|
||||
{ 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 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 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
|
||||
}
|
||||
}
|
||||
|
||||
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 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', '')
|
||||
|
||||
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 })
|
||||
|
||||
onMounted(() => loadCollections())
|
||||
onUnmounted(() => {
|
||||
window.clearTimeout(searchTimer)
|
||||
searchController?.abort()
|
||||
collectionController?.abort()
|
||||
collectionListController?.abort()
|
||||
})
|
||||
|
||||
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 { 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; }
|
||||
}
|
||||
|
||||
@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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
+6
-2
@@ -3,8 +3,12 @@ import vue from '@vitejs/plugin-vue';
|
||||
|
||||
const apiPort = process.env.API_PORT || '1551';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
less: {
|
||||
|
||||
Reference in New Issue
Block a user