Add files via upload
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
# 构建说明
|
||||
|
||||
本项目支持打包为**单一可执行文件**,包含前后端,启动后同时提供:
|
||||
- **1551端口**:后端API服务
|
||||
- **1552端口**:前端界面服务(自动代理API请求到1551)
|
||||
|
||||
所有前端文件已嵌入到二进制文件中,无需额外文件。
|
||||
|
||||
## 构建方式
|
||||
|
||||
### 使用 Makefile(推荐)
|
||||
|
||||
本项目使用 `Makefile` 进行跨平台构建,支持 Windows、Linux 和 macOS。
|
||||
|
||||
**前置要求:**
|
||||
- 安装 Node.js 和 npm
|
||||
- 安装 Go 1.23+
|
||||
- 安装 make 工具
|
||||
- **Windows**: 可通过 Git for Windows、Chocolatey 或 WSL 安装
|
||||
- **Linux/macOS**: 通常已预装
|
||||
|
||||
### Windows 上使用 Make
|
||||
|
||||
如果你安装了 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 安装目录(通常是 `C:\Program Files\Git`)
|
||||
2. 将 `C:\Program Files\Git\usr\bin` 添加到系统 PATH 环境变量
|
||||
3. 重启终端后即可直接使用 `make` 命令
|
||||
|
||||
**方式3:使用 Chocolatey 安装 make**
|
||||
```powershell
|
||||
choco install make
|
||||
```
|
||||
|
||||
**方式4:使用 WSL**
|
||||
在 WSL 中运行 make 命令。
|
||||
|
||||
### 构建命令
|
||||
|
||||
#### 构建当前平台版本
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
#### 构建 Linux 版本(用于服务器部署)
|
||||
```bash
|
||||
make build-linux
|
||||
```
|
||||
|
||||
#### 构建 Windows 版本
|
||||
```bash
|
||||
make build-windows
|
||||
```
|
||||
|
||||
#### 构建 macOS 版本
|
||||
```bash
|
||||
make build-darwin
|
||||
```
|
||||
|
||||
### 其他 Makefile 命令
|
||||
|
||||
**只构建前端:**
|
||||
```bash
|
||||
make frontend
|
||||
```
|
||||
|
||||
**只构建后端(当前平台):**
|
||||
```bash
|
||||
make backend
|
||||
```
|
||||
|
||||
**只构建后端(指定平台):**
|
||||
```bash
|
||||
make backend-linux # Linux
|
||||
make backend-windows # Windows
|
||||
make backend-darwin # macOS
|
||||
```
|
||||
|
||||
**生成Ent代码(首次构建前需要):**
|
||||
```bash
|
||||
make generate
|
||||
```
|
||||
|
||||
**清理构建文件:**
|
||||
```bash
|
||||
make clean
|
||||
```
|
||||
|
||||
**运行开发服务器:**
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
## 构建输出
|
||||
|
||||
构建完成后,`dist` 目录将只包含**单一可执行文件**:
|
||||
|
||||
```
|
||||
dist/
|
||||
└── home-vue-go.exe # Windows单一可执行文件(包含前后端)
|
||||
或
|
||||
└── home-vue-go # Linux/macOS单一可执行文件(包含前后端)
|
||||
```
|
||||
|
||||
**注意**:所有前端文件(HTML、CSS、JavaScript等)都已嵌入到二进制文件中,构建脚本会自动清理dist目录中的前端源文件。
|
||||
|
||||
## 运行服务器
|
||||
|
||||
### Windows
|
||||
```bash
|
||||
cd dist
|
||||
home-vue-go.exe
|
||||
```
|
||||
|
||||
### Linux/macOS
|
||||
```bash
|
||||
cd dist
|
||||
./home-vue-go
|
||||
```
|
||||
|
||||
## 访问地址
|
||||
|
||||
启动后,服务器会同时提供两个服务:
|
||||
|
||||
- **后端API**: http://localhost:1551
|
||||
- API接口:http://localhost:1551/api
|
||||
- 管理接口:http://localhost:1551/api/admin
|
||||
|
||||
- **前端界面**: http://localhost:1552
|
||||
- 主页:http://localhost:1552
|
||||
- 管理界面:http://localhost:1552/admin
|
||||
- 登录页面:http://localhost:1552/login
|
||||
|
||||
**注意**:前端会自动将 `/api` 请求代理到 `http://localhost:1551`,无需额外配置。
|
||||
|
||||
## 1Panel 配置
|
||||
|
||||
在1Panel中配置非常简单:
|
||||
|
||||
1. **上传文件**:将 `home-vue-go`(Linux版本)上传到服务器
|
||||
|
||||
2. **配置端口**:
|
||||
- 后端API端口:`1551`
|
||||
- 前端服务端口:`1552`
|
||||
|
||||
3. **运行命令**:
|
||||
```bash
|
||||
./home-vue-go
|
||||
```
|
||||
|
||||
4. **完成**:无需其他配置,单一文件即可运行完整服务
|
||||
|
||||
## 配置说明
|
||||
|
||||
- **后端API端口**:默认 `1551`,可通过环境变量 `API_PORT` 修改
|
||||
- **前端服务端口**:默认 `1552`,可通过环境变量 `FRONTEND_PORT` 修改
|
||||
- **数据目录**:运行时会自动在二进制文件同目录下创建 `data` 目录
|
||||
- **默认管理员账号**:`admin` / `admin123`(首次启动时显示)
|
||||
|
||||
### 修改端口示例
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
set API_PORT=8080
|
||||
set FRONTEND_PORT=8081
|
||||
home-vue-go.exe
|
||||
```
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
export API_PORT=8080
|
||||
export FRONTEND_PORT=8081
|
||||
./home-vue-go
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **CGO依赖**:本项目使用SQLite数据库,需要启用CGO(`CGO_ENABLED=1`)
|
||||
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系统上直接构建
|
||||
|
||||
## 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(推荐)
|
||||
|
||||
Git for Windows 自带 make 工具,通常位于:
|
||||
- `C:\Program Files\Git\usr\bin\make.exe`
|
||||
- `C:\Program Files (x86)\Git\usr\bin\make.exe`
|
||||
|
||||
**添加步骤:**
|
||||
1. 右键"此电脑" → "属性" → "高级系统设置" → "环境变量"
|
||||
2. 在"系统变量"中找到 `Path`,点击"编辑"
|
||||
3. 添加 Git 的 bin 目录:`C:\Program Files\Git\usr\bin`
|
||||
4. 点击"确定"保存
|
||||
5. 重启终端后即可直接使用 `make` 命令
|
||||
|
||||
### 方式3:使用 Chocolatey 安装 make
|
||||
```powershell
|
||||
choco install make
|
||||
```
|
||||
|
||||
### 方式4:使用 WSL
|
||||
在 WSL 中运行 make 命令。
|
||||
|
||||
### 方式5:手动下载 make for Windows
|
||||
从 https://sourceforge.net/projects/gnuwin32/files/make/ 下载并安装
|
||||
|
||||
## 架构说明
|
||||
|
||||
### 单一可执行文件架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ home-vue-go (单一可执行文件) │
|
||||
│ 包含: 后端代码 + 前端文件(嵌入) │
|
||||
├─────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────┐ │
|
||||
│ │ 后端API服务 │ │ 前端服务 │ │
|
||||
│ │ 端口: 1551 │ │ 端口:1552│ │
|
||||
│ └──────┬───────┘ └────┬─────┘ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └───────┬───────┘ │
|
||||
│ │ │
|
||||
│ API代理 │
|
||||
│ (前端/api/* → 后端1551) │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **后端服务(1551)**:提供所有API接口
|
||||
- **前端服务(1552)**:
|
||||
- 从嵌入的文件系统提供前端静态文件(HTML、CSS、JS)
|
||||
- 自动代理 `/api/*` 请求到后端1551端口
|
||||
- 支持SPA路由
|
||||
|
||||
## 完整构建流程示例
|
||||
|
||||
### 在 Windows 上构建 Linux 版本(用于1Panel)
|
||||
|
||||
**方式1:使用WSL(推荐)**
|
||||
|
||||
**前提条件:** 确保WSL中已安装Go和Node.js
|
||||
|
||||
**安装Go(如果未安装):**
|
||||
```bash
|
||||
# 在WSL中运行
|
||||
# 下载Go(替换版本号为最新版本,当前需要Go 1.23+)
|
||||
wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz
|
||||
|
||||
# 删除旧版本(如果存在)
|
||||
sudo rm -rf /usr/local/go
|
||||
|
||||
# 解压到/usr/local
|
||||
sudo tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz
|
||||
|
||||
# 添加到PATH(添加到~/.bashrc或~/.zshrc)
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# 验证安装
|
||||
go version
|
||||
```
|
||||
|
||||
**安装Node.js(如果未安装):**
|
||||
```bash
|
||||
# 使用nvm安装(推荐)
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||
source ~/.bashrc
|
||||
nvm install 18
|
||||
nvm use 18
|
||||
|
||||
# 或使用apt安装
|
||||
sudo apt update
|
||||
sudo apt install nodejs npm
|
||||
```
|
||||
|
||||
**构建步骤:**
|
||||
```bash
|
||||
# 在WSL中运行(不需要再运行wsl命令,如果已经在WSL中)
|
||||
cd /mnt/d/Desktop/Home-Vue-go
|
||||
make build-linux
|
||||
# 构建完成后,dist/home-vue-go 就是Linux可执行文件
|
||||
```
|
||||
|
||||
**方式2:直接在Windows上构建(需要gcc工具链)**
|
||||
```bash
|
||||
# 如果遇到交叉编译错误,需要安装gcc工具链
|
||||
# 使用MSYS2安装:
|
||||
# pacman -S mingw-w64-x86_64-gcc
|
||||
|
||||
# 然后运行
|
||||
make build-linux
|
||||
```
|
||||
|
||||
**方式3:在Linux服务器上直接构建(最简单,推荐)**
|
||||
|
||||
这是最推荐的方式,无需交叉编译工具链,构建速度快且稳定。
|
||||
|
||||
**前提条件:**
|
||||
- Linux服务器已安装 Go 1.23+ 和 Node.js 16.16.0+
|
||||
- 已安装 make 工具(通常已预装)
|
||||
|
||||
**构建步骤:**
|
||||
```bash
|
||||
# 1. 克隆项目到服务器
|
||||
git clone <your-repo>
|
||||
cd Home-Vue-go
|
||||
|
||||
# 2. 确保环境已安装(如果未安装)
|
||||
# 安装Go(示例,根据你的发行版调整)
|
||||
# wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz
|
||||
# sudo tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz
|
||||
# echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
|
||||
# source ~/.bashrc
|
||||
|
||||
# 安装Node.js(示例,使用nvm)
|
||||
# curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||
# source ~/.bashrc
|
||||
# nvm install 18
|
||||
|
||||
# 3. 构建Linux版本
|
||||
make build-linux
|
||||
|
||||
# 4. 构建完成后,可执行文件位于 dist/home-vue-go
|
||||
# 可以直接运行,或配置到1Panel
|
||||
./dist/home-vue-go
|
||||
```
|
||||
|
||||
**1Panel配置:**
|
||||
- 运行命令:`./home-vue-go`(或完整路径)
|
||||
- 端口映射:1551(后端API)、1552(前端界面)
|
||||
- 工作目录:可执行文件所在目录
|
||||
|
||||
**注意**:由于项目使用SQLite(需要CGO),在Windows上交叉编译Linux版本需要额外的工具链。推荐使用WSL或在Linux系统上直接构建。
|
||||
|
||||
### 在 Linux 上构建 Windows 版本
|
||||
|
||||
```bash
|
||||
make build-windows
|
||||
# 输出: dist/home-vue-go.exe
|
||||
```
|
||||
|
||||
### 在 macOS 上构建 Linux 版本
|
||||
|
||||
```bash
|
||||
make build-linux
|
||||
# 输出: dist/home-vue-go
|
||||
```
|
||||
|
||||
## 清理构建文件
|
||||
|
||||
```bash
|
||||
make clean
|
||||
```
|
||||
|
||||
这会删除:
|
||||
- `dist/` 目录
|
||||
- `node_modules/` 目录
|
||||
- 可执行文件
|
||||
|
||||
## 开发模式
|
||||
|
||||
开发时,可以分别运行前后端:
|
||||
|
||||
**终端1 - 后端:**
|
||||
```bash
|
||||
make run
|
||||
# 或
|
||||
go run main.go
|
||||
# 后端运行在 http://localhost:1551
|
||||
```
|
||||
|
||||
**终端2 - 前端:**
|
||||
```bash
|
||||
npm run dev
|
||||
# 前端运行在 http://localhost:1552,自动代理API到1551
|
||||
```
|
||||
|
||||
## 部署优势
|
||||
|
||||
✅ **单一可执行文件**:前后端一体化,所有文件嵌入在二进制中
|
||||
✅ **无需依赖**:不需要Node.js、npm或其他运行时
|
||||
✅ **端口分离**:API和前端服务分离,便于管理和扩展
|
||||
✅ **自动代理**:前端自动代理API请求,无需额外配置
|
||||
✅ **1Panel友好**:只需配置两个端口,运行一个命令即可
|
||||
✅ **部署简单**:上传一个文件,配置端口,即可运行
|
||||
✅ **跨平台构建**:使用make统一构建流程,支持多平台
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 摸鱼玩家
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,189 @@
|
||||
.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 "清理完成"
|
||||
@@ -1,2 +1,302 @@
|
||||
# Home-Vue-go
|
||||
Home-Vue原引导页开源项目的带后端版本
|
||||
## 个人主页 - 动态版本
|
||||
|
||||
基于Vue3 + Go的个人主页项目,支持动态更新配置,无需修改代码即可更新站点信息。
|
||||
|
||||
### 技术栈
|
||||
|
||||
**前端:**
|
||||
- Vue3 + Vite
|
||||
- CSS3 + HTML5 + JavaScript
|
||||
- Vue Router
|
||||
- Axios
|
||||
|
||||
**后端:**
|
||||
- Go + Gin
|
||||
- Ent ORM
|
||||
- JWT认证
|
||||
- SQLite数据库
|
||||
|
||||
### 功能特性
|
||||
|
||||
- ✅ 动态配置站点信息(名称、URL、图标等)
|
||||
- ✅ 可视化管理界面
|
||||
- ✅ 支持多种图片格式上传(jpg、png、jpeg、webp、avif等)
|
||||
- ✅ 联系方式管理(Email、GitHub、支付宝、微信等)
|
||||
- ✅ JWT认证保护管理接口
|
||||
- ✅ 数据存储在SQLite,轻量级数据库
|
||||
- ✅ 支持Linux服务器二进制打包部署
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
Home-Vue-go/
|
||||
├── docs/ # 文档目录
|
||||
├── scripts/ # 构建脚本
|
||||
├── src/ # 前端源代码
|
||||
├── internal/ # Go后端代码
|
||||
└── public/ # 静态资源
|
||||
```
|
||||
|
||||
详细的项目结构说明请查看 [PROJECT_STRUCTURE.md](./docs/PROJECT_STRUCTURE.md)
|
||||
|
||||
### 快速开始
|
||||
|
||||
#### 1. 环境要求
|
||||
|
||||
- Go >= 1.21
|
||||
- Node.js >= 16.16.0
|
||||
- npm >= 8.15.0
|
||||
|
||||
#### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
# 安装前端依赖
|
||||
npm install
|
||||
|
||||
# 安装Go依赖
|
||||
go mod download
|
||||
```
|
||||
|
||||
#### 3. 生成Ent代码(必须)
|
||||
|
||||
**重要:** 在运行项目之前,必须先生成Ent代码,否则Go代码无法编译。
|
||||
|
||||
```bash
|
||||
# 进入ent目录
|
||||
cd internal/ent
|
||||
|
||||
# 生成Ent代码
|
||||
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` 方式
|
||||
|
||||
#### 4. 运行项目
|
||||
|
||||
**开发模式:**
|
||||
|
||||
需要打开两个终端窗口:
|
||||
|
||||
**终端1 - 启动Go后端:**
|
||||
```bash
|
||||
# 在项目根目录运行
|
||||
go run main.go
|
||||
```
|
||||
|
||||
**终端2 - 启动前端开发服务器:**
|
||||
```bash
|
||||
# 在项目根目录运行
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**说明:**
|
||||
- `go run main.go` 会编译并运行Go程序
|
||||
- 后端默认运行在 `http://localhost:1551`
|
||||
- 前端默认运行在 `http://localhost:1552`
|
||||
- 首次运行会自动创建 `data/` 目录和数据库
|
||||
- 可以通过环境变量 `PORT` 修改后端端口(默认1551)
|
||||
|
||||
**Windows用户注意:** 如果遇到中文乱码,在PowerShell中运行:
|
||||
```powershell
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
chcp 65001
|
||||
```
|
||||
|
||||
访问:
|
||||
- 前端:http://localhost:1552
|
||||
- 管理界面:http://localhost:1552/admin
|
||||
- 登录页面:http://localhost:1552/login
|
||||
|
||||
**默认管理员账号:**
|
||||
- 用户名:`admin`
|
||||
- 密码:`admin123`
|
||||
|
||||
⚠️ **重要:** 首次运行后请立即修改默认密码!
|
||||
|
||||
#### 5. 构建部署
|
||||
|
||||
**构建Go后端(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
|
||||
```
|
||||
|
||||
**构建Go后端(Windows):**
|
||||
```bash
|
||||
# 生成Ent代码(必须)
|
||||
cd internal\ent
|
||||
go generate ./...
|
||||
cd ..\..
|
||||
|
||||
# 构建Windows二进制文件
|
||||
go build -o home-vue-go.exe main.go
|
||||
```
|
||||
|
||||
**构建前端:**
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建完成后:
|
||||
- Go二进制文件:`./home-vue-go` (Linux) 或 `./home-vue-go.exe` (Windows)
|
||||
- 前端构建文件:`./dist`
|
||||
|
||||
**说明:**
|
||||
- `go build` 编译Go程序为二进制文件
|
||||
- `CGO_ENABLED=1` 启用CGO(SQLite需要)
|
||||
- `GOOS=linux GOARCH=amd64` 指定目标平台和架构
|
||||
- `-o` 指定输出文件名
|
||||
|
||||
### 部署说明
|
||||
|
||||
1. **上传文件到服务器:**
|
||||
- 上传 `home-vue-go` 二进制文件
|
||||
- 上传 `dist` 目录(前端构建文件)
|
||||
|
||||
2. **运行二进制文件:**
|
||||
```bash
|
||||
./home-vue-go
|
||||
```
|
||||
|
||||
3. **数据存储:**
|
||||
- 数据库文件:`./data/home.db`
|
||||
- 上传的图片:`./data/uploads/`
|
||||
- 所有数据存储在二进制文件同级目录的 `data` 文件夹中
|
||||
|
||||
4. **环境变量(可选):**
|
||||
```bash
|
||||
export PORT=1551 # 服务端口,默认1551
|
||||
export JWT_SECRET=your-secret-key # JWT密钥,建议修改
|
||||
```
|
||||
|
||||
### 管理界面使用
|
||||
|
||||
1. 访问 `/admin` 进入管理界面
|
||||
2. 使用默认账号登录
|
||||
3. 在管理界面中可以:
|
||||
- **站点配置**:修改站点名称、URL、图标、描述等
|
||||
- **站点管理**:添加、编辑、删除站点链接
|
||||
- **联系方式管理**:管理Email、GitHub、支付宝、微信等联系方式
|
||||
|
||||
### 数据迁移
|
||||
|
||||
如果你之前使用的是静态版本(使用JSON配置文件),可以:
|
||||
|
||||
1. 启动新版本服务
|
||||
2. 登录管理界面
|
||||
3. 手动导入原有数据,或使用API批量导入
|
||||
|
||||
### 文档
|
||||
|
||||
- [运行项目指南](./docs/RUN_PROJECT.md) - 如何运行前端和后端(推荐)
|
||||
- [快速开始](./docs/QUICK_START.md) - 5步快速启动项目
|
||||
- [Go命令学习指南](./docs/GO_COMMANDS.md) - Go命令详解(推荐学习)
|
||||
- [Windows设置指南](./docs/WINDOWS_SETUP.md) - Windows专用设置指南
|
||||
- [项目结构说明](./docs/PROJECT_STRUCTURE.md) - 详细的目录结构说明
|
||||
- [设置指南](./docs/SETUP.md) - 详细的安装和配置步骤
|
||||
- [本地调试指南](./docs/LOCAL_DEBUG.md) - 本地调试详细步骤
|
||||
- [更新日志](./docs/CHANGELOG.md) - 版本更新记录
|
||||
- [图标选择器说明](./docs/ICON_SELECTOR.md) - 图标配置使用指南
|
||||
- [故障排查指南](./docs/TROUBLESHOOTING.md) - 常见问题解决方案
|
||||
|
||||
### API文档
|
||||
|
||||
#### 公开API(无需认证)
|
||||
|
||||
- `GET /api/sites` - 获取站点列表
|
||||
- `GET /api/contacts` - 获取联系方式列表
|
||||
- `GET /api/config` - 获取站点配置
|
||||
|
||||
#### 管理API(需要JWT认证)
|
||||
|
||||
- `POST /api/auth/login` - 登录获取token
|
||||
- `GET /api/admin/sites` - 获取站点列表(管理)
|
||||
- `POST /api/admin/sites` - 创建站点
|
||||
- `PUT /api/admin/sites/:id` - 更新站点
|
||||
- `DELETE /api/admin/sites/:id` - 删除站点
|
||||
- `GET /api/admin/contacts` - 获取联系方式列表(管理)
|
||||
- `POST /api/admin/contacts` - 创建联系方式
|
||||
- `PUT /api/admin/contacts/:id` - 更新联系方式
|
||||
- `DELETE /api/admin/contacts/:id` - 删除联系方式
|
||||
- `GET /api/admin/config` - 获取站点配置(管理)
|
||||
- `PUT /api/admin/config` - 更新站点配置
|
||||
- `POST /api/admin/upload` - 上传图片文件
|
||||
|
||||
### 注意事项
|
||||
|
||||
1. **Email格式验证**:Email类型的联系方式URL必须是 `mailto:` 格式(如:`mailto:i@bsgun.cn`)
|
||||
2. **图片上传**:支持 jpg、jpeg、png、gif、webp、avif、svg、bmp 格式
|
||||
3. **数据库安全**:SQLite数据库文件存储在服务器本地,不对外暴露,防止数据库注入
|
||||
4. **JWT密钥**:生产环境请务必修改JWT_SECRET环境变量
|
||||
5. **默认密码**:首次部署后请立即修改管理员密码
|
||||
|
||||
### 开发命令
|
||||
|
||||
**Go相关命令:**
|
||||
```bash
|
||||
# 更新依赖
|
||||
go mod tidy
|
||||
|
||||
# 下载依赖
|
||||
go mod download
|
||||
|
||||
# 生成Ent代码
|
||||
cd internal/ent && go generate ./... && cd ../..
|
||||
|
||||
# 运行后端(开发模式)
|
||||
go run main.go
|
||||
|
||||
# 构建后端(当前平台)
|
||||
go build -o home-vue-go main.go
|
||||
|
||||
# 构建后端(Linux)
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o home-vue-go main.go
|
||||
|
||||
# 查看Go版本
|
||||
go version
|
||||
|
||||
# 查看模块信息
|
||||
go mod graph
|
||||
```
|
||||
|
||||
**前端相关命令:**
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 开发模式
|
||||
npm run dev
|
||||
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
|
||||
# 预览构建结果
|
||||
npm run preview
|
||||
```
|
||||
|
||||
### 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
module home-vue-go
|
||||
|
||||
go 1.23
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
require (
|
||||
ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 // indirect
|
||||
github.com/agext/levenshtein v1.2.3 // indirect
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
||||
github.com/bmatcuk/doublestar v1.3.4 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
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/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
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
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/hashicorp/hcl/v2 v2.18.1 // 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/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/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.8.0 // 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/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
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 h1:E0wvcUXTkgyN4wy4LGtNzMNGMytJN8afmIWXJVMi4cc=
|
||||
ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w=
|
||||
entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4=
|
||||
entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||
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-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=
|
||||
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
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/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/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/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=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
|
||||
github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
|
||||
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
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/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/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/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo=
|
||||
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
|
||||
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/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/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/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=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
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-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/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/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/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/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/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/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/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=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
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/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-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
|
||||
github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
|
||||
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/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/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/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
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/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=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- 这里给一个基础默认标题,实际运行时会由前端根据后端配置动态覆盖 -->
|
||||
<title>个人主页</title>
|
||||
<!-- keywords / description 将在运行时由 loadAndApplyFrontendConfig 基于后端 SiteConfig 动态创建或更新 -->
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,388 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"home-vue-go/internal/api"
|
||||
"home-vue-go/internal/config"
|
||||
"home-vue-go/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed dist/*
|
||||
var distFS embed.FS
|
||||
|
||||
func main() {
|
||||
// 设置亚洲/上海时区
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
log.Fatal("无法加载时区:", err)
|
||||
}
|
||||
time.Local = loc
|
||||
|
||||
// 获取可执行文件所在目录
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Fatal("无法获取可执行文件路径:", err)
|
||||
}
|
||||
exeDir := filepath.Dir(exePath)
|
||||
|
||||
// 创建data目录
|
||||
dataDir := filepath.Join(exeDir, "data")
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
log.Fatal("无法创建data目录:", err)
|
||||
}
|
||||
|
||||
// 初始化配置
|
||||
cfg := config.New(dataDir)
|
||||
|
||||
// 初始化数据库
|
||||
db, err := database.Init(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
log.Fatal("数据库初始化失败:", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// 设置Gin模式
|
||||
if os.Getenv("GIN_MODE") == "" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// 创建Gin路由(不使用Default以避免重复日志)
|
||||
r := gin.New()
|
||||
|
||||
// 添加恢复中间件
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
// 静态文件服务 - 从文件系统提供前端构建文件
|
||||
// 优先使用可执行文件同目录下的dist目录
|
||||
distPath := filepath.Join(exeDir, "dist")
|
||||
if _, err := os.Stat(distPath); err == nil {
|
||||
// 使用可执行文件同目录下的dist
|
||||
r.Static("/static", filepath.Join(distPath, "static"))
|
||||
r.StaticFile("/favicon.ico", filepath.Join(distPath, "favicon.ico"))
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
// API和上传路径不处理
|
||||
if path == "/api" || strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/uploads/") {
|
||||
c.Status(404)
|
||||
return
|
||||
}
|
||||
// 其他路径返回index.html(SPA路由支持)
|
||||
c.File(filepath.Join(distPath, "index.html"))
|
||||
})
|
||||
log.Printf("前端文件目录: %s", distPath)
|
||||
} else {
|
||||
// 回退到当前工作目录的dist(开发模式)
|
||||
if _, err := os.Stat("./dist"); err == nil {
|
||||
r.Static("/static", "./dist/static")
|
||||
r.StaticFile("/favicon.ico", "./dist/favicon.ico")
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
if c.Request.URL.Path != "/api" && !strings.HasPrefix(c.Request.URL.Path, "/api/") && !strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
|
||||
c.File("./dist/index.html")
|
||||
}
|
||||
})
|
||||
log.Println("使用当前目录的dist文件夹(开发模式)")
|
||||
} else {
|
||||
log.Println("警告: 未找到前端文件目录,前端功能不可用")
|
||||
log.Println("提示: 请将前端构建文件放在可执行文件同目录的dist文件夹中")
|
||||
}
|
||||
}
|
||||
|
||||
// 配置CORS
|
||||
r.Use(corsMiddleware())
|
||||
|
||||
// 初始化API路由
|
||||
api.SetupRoutes(r, db, cfg)
|
||||
|
||||
// 创建前端服务器(1552端口)- 从嵌入的文件系统提供前端文件
|
||||
frontendRouter := gin.New()
|
||||
frontendRouter.Use(gin.Recovery())
|
||||
frontendRouter.Use(corsMiddleware())
|
||||
|
||||
// 从嵌入的文件系统加载前端文件
|
||||
distRoot, err := fs.Sub(distFS, "dist")
|
||||
if err == nil {
|
||||
// 使用嵌入的文件系统
|
||||
frontendRouter.StaticFS("/static", http.FS(distRoot))
|
||||
|
||||
// 提供favicon
|
||||
frontendRouter.GET("/favicon.ico", func(c *gin.Context) {
|
||||
data, err := distRoot.Open("favicon.ico")
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer data.Close()
|
||||
content, err := io.ReadAll(data)
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "image/x-icon", content)
|
||||
})
|
||||
|
||||
// API代理:将/api请求代理到1551端口
|
||||
frontendRouter.Any("/api/*path", func(c *gin.Context) {
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
targetURL := "http://localhost:1551" + c.Request.URL.Path
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
targetURL += "?" + c.Request.URL.RawQuery
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建代理请求失败"})
|
||||
return
|
||||
}
|
||||
|
||||
for key, values := range c.Request.Header {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "代理请求失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Writer.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// 复制响应状态码和内容
|
||||
c.Status(resp.StatusCode)
|
||||
c.Header("Content-Type", resp.Header.Get("Content-Type"))
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
})
|
||||
|
||||
// /uploads代理
|
||||
frontendRouter.Static("/uploads", cfg.UploadDir)
|
||||
|
||||
// SPA路由支持
|
||||
frontendRouter.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/uploads/") {
|
||||
c.Status(404)
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试打开文件
|
||||
filePath := strings.TrimPrefix(path, "/")
|
||||
if filePath == "" {
|
||||
filePath = "index.html"
|
||||
}
|
||||
file, err := distRoot.Open(filePath)
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
stat, err := file.Stat()
|
||||
if err == nil && !stat.IsDir() {
|
||||
content, err := io.ReadAll(file)
|
||||
if err == nil {
|
||||
contentType := "text/html"
|
||||
if strings.HasSuffix(filePath, ".css") {
|
||||
contentType = "text/css"
|
||||
} else if strings.HasSuffix(filePath, ".js") {
|
||||
contentType = "application/javascript"
|
||||
} else if strings.HasSuffix(filePath, ".json") {
|
||||
contentType = "application/json"
|
||||
} else if strings.HasSuffix(filePath, ".ico") {
|
||||
contentType = "image/x-icon"
|
||||
} else if strings.HasSuffix(filePath, ".png") {
|
||||
contentType = "image/png"
|
||||
} else if strings.HasSuffix(filePath, ".jpg") || strings.HasSuffix(filePath, ".jpeg") {
|
||||
contentType = "image/jpeg"
|
||||
} else if strings.HasSuffix(filePath, ".svg") {
|
||||
contentType = "image/svg+xml"
|
||||
}
|
||||
c.Data(http.StatusOK, contentType, content)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回index.html(SPA路由)
|
||||
indexFile, err := distRoot.Open("index.html")
|
||||
if err == nil {
|
||||
defer indexFile.Close()
|
||||
content, err := io.ReadAll(indexFile)
|
||||
if err == nil {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
|
||||
} else {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
} else {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
log.Println("使用嵌入的前端文件(单一可执行文件模式)")
|
||||
} else {
|
||||
// 回退到文件系统(开发模式)
|
||||
log.Println("警告: 无法加载嵌入的前端文件,尝试从文件系统加载")
|
||||
distPath := filepath.Join(exeDir, "dist")
|
||||
if _, err := os.Stat(distPath); err == nil {
|
||||
frontendRouter.Static("/static", filepath.Join(distPath, "static"))
|
||||
frontendRouter.StaticFile("/favicon.ico", filepath.Join(distPath, "favicon.ico"))
|
||||
|
||||
frontendRouter.Any("/api/*path", func(c *gin.Context) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
targetURL := "http://localhost:1551" + c.Request.URL.Path
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
targetURL += "?" + c.Request.URL.RawQuery
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, c.Request.Body)
|
||||
for key, values := range c.Request.Header {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "代理请求失败"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Writer.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
// 复制响应状态码和内容
|
||||
c.Status(resp.StatusCode)
|
||||
c.Header("Content-Type", resp.Header.Get("Content-Type"))
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
})
|
||||
|
||||
frontendRouter.Static("/uploads", cfg.UploadDir)
|
||||
frontendRouter.NoRoute(func(c *gin.Context) {
|
||||
if !strings.HasPrefix(c.Request.URL.Path, "/api/") && !strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
|
||||
c.File(filepath.Join(distPath, "index.html"))
|
||||
}
|
||||
})
|
||||
log.Printf("使用文件系统前端文件: %s", distPath)
|
||||
} else if _, err := os.Stat("./dist"); err == nil {
|
||||
frontendRouter.Static("/static", "./dist/static")
|
||||
frontendRouter.StaticFile("/favicon.ico", "./dist/favicon.ico")
|
||||
frontendRouter.Any("/api/*path", func(c *gin.Context) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
targetURL := "http://localhost:1551" + c.Request.URL.Path
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
targetURL += "?" + c.Request.URL.RawQuery
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, c.Request.Body)
|
||||
for key, values := range c.Request.Header {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "代理请求失败"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Writer.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
// 复制响应状态码和内容
|
||||
c.Status(resp.StatusCode)
|
||||
c.Header("Content-Type", resp.Header.Get("Content-Type"))
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
})
|
||||
frontendRouter.Static("/uploads", cfg.UploadDir)
|
||||
frontendRouter.NoRoute(func(c *gin.Context) {
|
||||
if !strings.HasPrefix(c.Request.URL.Path, "/api/") && !strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
|
||||
c.File("./dist/index.html")
|
||||
}
|
||||
})
|
||||
log.Println("使用当前目录的dist文件夹(开发模式)")
|
||||
} else {
|
||||
log.Println("警告: 未找到前端文件,前端功能不可用")
|
||||
}
|
||||
}
|
||||
|
||||
// 启动两个服务器
|
||||
apiPort := os.Getenv("API_PORT")
|
||||
if apiPort == "" {
|
||||
apiPort = "1551"
|
||||
}
|
||||
|
||||
frontendPort := os.Getenv("FRONTEND_PORT")
|
||||
if frontendPort == "" {
|
||||
frontendPort = "1552"
|
||||
}
|
||||
|
||||
// 创建API服务器
|
||||
apiServer := &http.Server{
|
||||
Addr: ":" + apiPort,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// 创建前端服务器
|
||||
frontendServer := &http.Server{
|
||||
Addr: ":" + frontendPort,
|
||||
Handler: frontendRouter,
|
||||
}
|
||||
|
||||
// 只在首次启动时显示默认账号信息
|
||||
firstRunFile := filepath.Join(dataDir, ".first_run")
|
||||
if _, err := os.Stat(firstRunFile); os.IsNotExist(err) {
|
||||
os.WriteFile(firstRunFile, []byte(""), 0644)
|
||||
log.Printf("默认管理员账号: admin, 密码: admin123")
|
||||
log.Printf("提示: 首次启动后,请及时修改默认密码以确保安全")
|
||||
}
|
||||
|
||||
log.Printf("========================================")
|
||||
log.Printf("服务器启动成功!")
|
||||
log.Printf("后端API: http://localhost:%s", apiPort)
|
||||
log.Printf("前端界面: http://localhost:%s", frontendPort)
|
||||
log.Printf("========================================")
|
||||
|
||||
// 在goroutine中启动前端服务器
|
||||
go func() {
|
||||
if err := frontendServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("前端服务器启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 在主goroutine中启动API服务器
|
||||
if err := apiServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("API服务器启动失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
Generated
+3517
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "home-vue",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/motion": "^2.2.5",
|
||||
"axios": "^1.7.7",
|
||||
"less": "^4.2.0",
|
||||
"swiper": "^11.1.14",
|
||||
"typed.js": "^2.1.0",
|
||||
"vue": "^3.4.37",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
+104
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<router-view v-if="isAdminPage || isLoginPage" />
|
||||
<template v-else>
|
||||
<div class="background"></div>
|
||||
<Home />
|
||||
<footer>
|
||||
<span>© {{ footerYearText }} Made in <a href="/" target="_blank">{{ userName }}</a></span>
|
||||
<a v-if="icpNumber && icpNumber !== '暂未填写' && icpNumber.trim() !== ''" href="https://beian.miit.gov.cn/" target="_blank">{{ icpNumber }}</a>
|
||||
<a v-if="policenumber && policenumber !== '暂未填写' && policenumber.trim() !== ''" :href="`https://beian.mps.gov.cn/#/query/webSearch?police=${policenumber}`" target="_blank" class="police_link">
|
||||
<span class="police_img"></span> {{ policenumber }}
|
||||
</a>
|
||||
</footer>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Home from './components/Home.vue';
|
||||
import { getSiteConfig } from './api';
|
||||
|
||||
const route = useRoute();
|
||||
const userName = ref(import.meta.env.VITE_APP_USER_NAME || '用户');
|
||||
const icpNumber = ref(import.meta.env.VITE_APP_ICP_NUMBER || '');
|
||||
const policenumber = ref(import.meta.env.VITE_APP_POLICE_NUMBER || '');
|
||||
|
||||
// 底部年份(支持起止年份)
|
||||
const footerYearStart = ref('');
|
||||
const footerYearEnd = ref('');
|
||||
|
||||
const footerYearText = computed(() => {
|
||||
const currentYear = new Date().getFullYear().toString();
|
||||
const start = (footerYearStart.value || '').trim();
|
||||
const end = (footerYearEnd.value || '').trim();
|
||||
|
||||
// 后台未配置时,默认显示当前年份
|
||||
if (!start && !end) {
|
||||
return currentYear;
|
||||
}
|
||||
|
||||
// 只配置了起始年份
|
||||
if (start && !end) {
|
||||
return start;
|
||||
}
|
||||
|
||||
// 起止年份都有且不同
|
||||
if (start && end && start !== end) {
|
||||
return `${start}~${end}`;
|
||||
}
|
||||
|
||||
// 其他情况(例如起止相同),只显示一个年份
|
||||
return start || end || currentYear;
|
||||
});
|
||||
|
||||
const isAdminPage = computed(() => route.path === '/admin');
|
||||
const isLoginPage = computed(() => route.path === '/login');
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await getSiteConfig();
|
||||
// 同步所有配置信息
|
||||
if (res.data.siteName) {
|
||||
// 站点名称可用于页面显示
|
||||
}
|
||||
if (res.data.siteURL) {
|
||||
// 站点URL可用于链接等
|
||||
}
|
||||
if (res.data.siteDescription) {
|
||||
// 站点描述已通过frontendConfig.js同步到meta标签
|
||||
}
|
||||
if (res.data.siteKeywords) {
|
||||
// 站点关键词已通过frontendConfig.js同步到meta标签
|
||||
}
|
||||
// 只有非空且不是"暂未填写"时才显示备案信息
|
||||
if (res.data.icpNumber && res.data.icpNumber !== '暂未填写' && res.data.icpNumber.trim() !== '') {
|
||||
icpNumber.value = res.data.icpNumber;
|
||||
} else {
|
||||
icpNumber.value = '';
|
||||
}
|
||||
if (res.data.policeNumber && res.data.policeNumber !== '暂未填写' && res.data.policeNumber.trim() !== '') {
|
||||
policenumber.value = res.data.policeNumber;
|
||||
} else {
|
||||
policenumber.value = '';
|
||||
}
|
||||
if (res.data.userName) userName.value = res.data.userName;
|
||||
|
||||
// 底部年份配置
|
||||
if (typeof res.data.footerYearStart === 'string') {
|
||||
footerYearStart.value = res.data.footerYearStart;
|
||||
}
|
||||
if (typeof res.data.footerYearEnd === 'string') {
|
||||
footerYearEnd.value = res.data.footerYearEnd;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!isAdminPage.value && !isLoginPage.value) {
|
||||
loadConfig();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,100 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器 - 添加JWT token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器 - 处理错误
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
// 可以在这里跳转到登录页
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 公开API
|
||||
export const getSites = () => api.get('/sites')
|
||||
export const getContacts = () => api.get('/contacts')
|
||||
export const getSiteConfig = () => api.get('/config')
|
||||
export const getFrontendConfig = () => api.get('/frontend-config')
|
||||
|
||||
// 认证API
|
||||
export const login = (username, password) =>
|
||||
api.post('/auth/login', { username, password })
|
||||
|
||||
// 管理API
|
||||
export const adminAPI = {
|
||||
// 站点管理
|
||||
getSites: () => api.get('/admin/sites'),
|
||||
createSite: (data) => api.post('/admin/sites', data),
|
||||
updateSite: (id, data) => api.put(`/admin/sites/${id}`, data),
|
||||
deleteSite: (id) => api.delete(`/admin/sites/${id}`),
|
||||
|
||||
// 联系方式管理
|
||||
getContacts: () => api.get('/admin/contacts'),
|
||||
createContact: (data) => api.post('/admin/contacts', data),
|
||||
updateContact: (id, data) => api.put(`/admin/contacts/${id}`, data),
|
||||
deleteContact: (id) => api.delete(`/admin/contacts/${id}`),
|
||||
|
||||
// 站点配置管理
|
||||
getSiteConfig: () => api.get('/admin/config'),
|
||||
updateSiteConfig: (data) => api.put('/admin/config', data),
|
||||
|
||||
// 文件上传
|
||||
uploadFile: (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post('/admin/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
// 统计API
|
||||
getStats: () => api.get('/admin/stats'),
|
||||
getChartData: (period) => api.get(`/admin/charts?period=${period}`),
|
||||
getRecentVisits: (limit = 5) => api.get(`/admin/recent-visits?limit=${limit}`),
|
||||
|
||||
// 热重载通知
|
||||
notifyConfigUpdate: () => api.post('/admin/notify-update'),
|
||||
|
||||
// 用户管理
|
||||
changePassword: (oldPassword, newPassword) =>
|
||||
api.put('/admin/change-password', { oldPassword, newPassword }),
|
||||
|
||||
// 日志API
|
||||
getBackendLogs: (lines = 100) => api.get(`/admin/logs?lines=${lines}`),
|
||||
|
||||
// 登录历史API
|
||||
getLoginHistory: (limit = 20) => api.get(`/admin/login-history?limit=${limit}`),
|
||||
|
||||
// 轮换文本配置API
|
||||
getRotatingTexts: () => api.get('/api/rotating-texts'),
|
||||
updateRotatingTexts: (texts) => api.put('/admin/rotating-texts', { texts }),
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="about-page" @click.stop>
|
||||
<div class="about-modal">
|
||||
<div class="about-modal-content">
|
||||
<div class="tech-stack">
|
||||
<h3>使用的技术栈</h3>
|
||||
<ul class="tech-list">
|
||||
<li v-for="tech in techStack" :key="tech.name" :class="['tech-item', tech.name.toLowerCase()]">
|
||||
<i :class="tech.icon"></i>
|
||||
{{ tech.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="github-info">
|
||||
<h3>开源地址</h3>
|
||||
<div class="github-links">
|
||||
<a href="https://github.com/JLinMr/Home-Vue" target="_blank" class="github-link">
|
||||
<i class="fab fa-github"></i>
|
||||
<div class="link-content">
|
||||
<span class="link-title">静态原项目</span>
|
||||
<span class="link-desc">Home-Vue</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="https://github.com/JLinMr/Home-Vue-go" target="_blank" class="github-link">
|
||||
<i class="fab fa-github"></i>
|
||||
<div class="link-content">
|
||||
<span class="link-title">动态现项目</span>
|
||||
<span class="link-desc">Home-Vue-go</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="closeModal" class="close-btn">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const techStack = [
|
||||
// 前端技术栈
|
||||
{ name: 'Vue3', icon: 'fab fa-vuejs' },
|
||||
{ name: 'Vite', icon: 'fas fa-bolt' },
|
||||
{ name: 'CSS3', icon: 'fab fa-css3-alt' },
|
||||
{ name: 'HTML5', icon: 'fab fa-html5' },
|
||||
{ name: 'JavaScript', icon: 'fab fa-js' },
|
||||
// 后端技术栈
|
||||
{ name: 'Go', icon: 'fab fa-golang' },
|
||||
{ name: 'Gin', icon: 'fas fa-server' },
|
||||
{ name: 'SQLite', icon: 'fas fa-database' },
|
||||
{ name: 'Ent', icon: 'fas fa-code-branch' },
|
||||
{ name: 'JWT', icon: 'fas fa-key' }
|
||||
];
|
||||
const closeModal = () => emit('close');
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.about-page {
|
||||
width: 500px;
|
||||
backdrop-filter: blur(5px);
|
||||
background-color: rgba(var(--background-color-rgb), 0.9);
|
||||
padding: 40px;
|
||||
border-radius: var(--border-radius);
|
||||
position: relative;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 20px;
|
||||
width: 90%;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.tech-stack ul,
|
||||
.tech-list {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tech-stack li,
|
||||
.tech-item {
|
||||
padding: 10px 15px;
|
||||
border-radius: var(--border-radius);
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tech-stack li:hover,
|
||||
.tech-item:hover {
|
||||
background-color: var(--hover-other-color);
|
||||
color: var(--hover-link-color);
|
||||
}
|
||||
|
||||
.tech-stack li i,
|
||||
.tech-item i {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.tech-stack li,
|
||||
.tech-item {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.github-links {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.github-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background-color: #040404d0;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
border-radius: var(--border-radius);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
flex: 1;
|
||||
|
||||
&:hover {
|
||||
background-color: #1a1a1a;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1.5em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.link-desc {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.github-links {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5em;
|
||||
cursor: pointer;
|
||||
color: #7f8c8d;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: #c61b09;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.about-modal-content > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
|
||||
&:nth-child(1) { animation-delay: 0.2s; }
|
||||
&:nth-child(2) { animation-delay: 0.3s; }
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,528 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<div class="user-profile-container">
|
||||
<div class="user-profile-image" v-motion-pop>
|
||||
<img :src="profileImage" alt="头像" @click.stop="toggleInfo">
|
||||
<span class="status-ball"></span>
|
||||
</div>
|
||||
<div class="user-name" v-motion-slide-left>
|
||||
<h1>Hi,</h1>
|
||||
<h1>I'm <span class="name-style">{{ userName }}</span></h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p ref="descriptionElement"></p>
|
||||
</div>
|
||||
<div class="contact-section" v-motion-pop>
|
||||
<template v-for="contact in contacts" :key="contact.type">
|
||||
<a v-if="contact.url" :href="contact.url" target="_blank" class="contact-item" :style="{ '--hover-color': contact.hoverColor }">
|
||||
<i :class="contact.icon"></i>
|
||||
<span class="tooltip">{{ contact.type }}</span>
|
||||
</a>
|
||||
<span v-else @click="toggleQRCode(contact.qrCode)" class="contact-item" :style="{ '--hover-color': contact.hoverColor }">
|
||||
<i :class="contact.icon"></i>
|
||||
<span class="tooltip">{{ contact.type }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<span class="contact-item" @click="toggleDarkMode" :style="{ '--hover-color': isDarkMode ? '#ffcc00' : '#666' }">
|
||||
<i :class="darkModeIconClass"></i>
|
||||
<span class="tooltip">{{ isDarkMode ? '浅色' : '深色' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Website />
|
||||
<!-- 使用v-if确保组件完全从DOM中移除,包括所有class -->
|
||||
<VisitTimer v-if="showVisitTimer" :key="showVisitTimer ? 'visit-timer-show' : 'visit-timer-hide'" />
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showAbout" class="overlay" @click="showAbout = false">
|
||||
<div class="modal-content">
|
||||
<AboutPage @close="showAbout = false" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showQR" class="overlay" @click="hideQRCode">
|
||||
<div class="modal-content">
|
||||
<img :src="qrCodeSrc" alt="QR Code" class="qr-image" @click.stop>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
||||
import { getContacts, getSiteConfig } from '../api';
|
||||
import api from '../api';
|
||||
import Website from './Website.vue';
|
||||
import AboutPage from './AboutPage.vue';
|
||||
import VisitTimer from './VisitTimer.vue';
|
||||
import Typed from 'typed.js';
|
||||
|
||||
const contacts = ref([]);
|
||||
const showQR = ref(false);
|
||||
const showAbout = ref(false);
|
||||
const qrCodeSrc = ref('');
|
||||
const profileImage = ref('');
|
||||
const userName = ref('');
|
||||
const siteConfig = ref({});
|
||||
const descriptionElement = ref(null);
|
||||
const showVisitTimer = ref(true);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [contactsRes, configRes] = await Promise.all([
|
||||
getContacts(),
|
||||
getSiteConfig(),
|
||||
]);
|
||||
contacts.value = contactsRes.data;
|
||||
siteConfig.value = configRes.data;
|
||||
userName.value = configRes.data.userName || import.meta.env.VITE_APP_USER_NAME || '用户';
|
||||
profileImage.value = configRes.data.profileImageURL || import.meta.env.VITE_APP_PROFILE_IMAGE_URL || '';
|
||||
// 确保正确读取 showVisitTimer,明确处理 false 值
|
||||
const timerValue = configRes.data.showVisitTimer;
|
||||
const newValue = timerValue !== undefined && timerValue !== null
|
||||
? Boolean(timerValue)
|
||||
: true;
|
||||
// 使用赋值触发响应式更新
|
||||
const oldValue = showVisitTimer.value;
|
||||
showVisitTimer.value = newValue;
|
||||
console.log('加载配置 - showVisitTimer:', {
|
||||
oldValue,
|
||||
newValue,
|
||||
rawValue: timerValue,
|
||||
type: typeof timerValue,
|
||||
isFalse: timerValue === false
|
||||
});
|
||||
|
||||
// 如果从true变为false,确保DOM被移除
|
||||
if (oldValue && !newValue) {
|
||||
await nextTick();
|
||||
const timerElements = document.querySelectorAll('.visit-timer-container, .visit-timer');
|
||||
if (timerElements.length > 0) {
|
||||
console.warn('检测到需要移除的visit-timer元素,数量:', timerElements.length);
|
||||
timerElements.forEach(el => {
|
||||
console.log('移除元素:', el);
|
||||
el.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
// 如果API失败,使用环境变量或默认值
|
||||
userName.value = import.meta.env.VITE_APP_USER_NAME || '用户';
|
||||
profileImage.value = import.meta.env.VITE_APP_PROFILE_IMAGE_URL || '';
|
||||
showVisitTimer.value = true; // 默认显示
|
||||
}
|
||||
};
|
||||
|
||||
const predefinedDescriptions = ref([
|
||||
"你好鸭,欢迎来到我的主页!!",
|
||||
"随时可以联系我,期待与你交流。",
|
||||
"愿你历尽千帆,归来仍是少年。",
|
||||
"梦想还是要有的,万一实现了呢?",
|
||||
"I hope you have a happy day every day."
|
||||
]);
|
||||
|
||||
let typedInstance = null;
|
||||
|
||||
const loadRotatingTexts = async () => {
|
||||
try {
|
||||
const res = await api.get('/rotating-texts');
|
||||
if (res.data?.texts && res.data.texts.length > 0) {
|
||||
predefinedDescriptions.value = res.data.texts;
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('加载轮换文本失败,使用默认文本:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const initializeTyped = () => {
|
||||
if (typedInstance) {
|
||||
typedInstance.destroy();
|
||||
}
|
||||
typedInstance = new Typed(descriptionElement.value, {
|
||||
strings: predefinedDescriptions.value,
|
||||
typeSpeed: 120,
|
||||
backSpeed: 80,
|
||||
showCursor: true,
|
||||
cursorChar: '|',
|
||||
loop: true,
|
||||
});
|
||||
};
|
||||
|
||||
// 记录访问统计
|
||||
const trackVisit = async () => {
|
||||
try {
|
||||
const path = window.location.pathname;
|
||||
const referer = document.referrer || '';
|
||||
await api.post('/track-visit', {
|
||||
path: path,
|
||||
referer: referer,
|
||||
});
|
||||
} catch (error) {
|
||||
// 静默失败,不影响用户体验
|
||||
console.debug('访问统计记录失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听配置更新消息
|
||||
let broadcastChannel = null
|
||||
if (window.BroadcastChannel) {
|
||||
broadcastChannel = new BroadcastChannel('config-update')
|
||||
broadcastChannel.onmessage = async (event) => {
|
||||
if (event.data.type === 'config-updated') {
|
||||
console.log('收到配置更新消息,重新加载数据...')
|
||||
const oldTimerValue = showVisitTimer.value
|
||||
await loadData()
|
||||
await loadRotatingTexts()
|
||||
if (typedInstance) {
|
||||
typedInstance.destroy()
|
||||
}
|
||||
initializeTyped()
|
||||
// 停留时间显示状态会在loadData中更新
|
||||
// 使用 nextTick 确保 DOM 更新
|
||||
await nextTick()
|
||||
console.log('配置更新后 - showVisitTimer:', showVisitTimer.value, '之前的值:', oldTimerValue, 'DOM已更新')
|
||||
|
||||
// 如果从true变为false,强制检查并移除残留的DOM元素
|
||||
if (oldTimerValue && !showVisitTimer.value) {
|
||||
console.log('showVisitTimer从true变为false,强制清理残留元素')
|
||||
const timerElements = document.querySelectorAll('.visit-timer-container, .visit-timer, [class*="visit-timer"]')
|
||||
if (timerElements.length > 0) {
|
||||
console.warn('发现残留的visit-timer相关元素,强制移除:', timerElements.length)
|
||||
timerElements.forEach(el => {
|
||||
console.log('移除残留元素:', el.className, el)
|
||||
el.remove()
|
||||
})
|
||||
}
|
||||
// 再次等待DOM更新
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听showVisitTimer变化,确保DOM正确更新
|
||||
watch(showVisitTimer, async (newValue, oldValue) => {
|
||||
console.log('showVisitTimer变化:', { oldValue, newValue })
|
||||
// 等待DOM更新完成
|
||||
await nextTick()
|
||||
// 如果设置为false,强制检查并移除任何残留的visit-timer元素
|
||||
if (!newValue) {
|
||||
const timerElements = document.querySelectorAll('.visit-timer-container, .visit-timer')
|
||||
if (timerElements.length > 0) {
|
||||
console.warn('发现残留的visit-timer元素,强制移除:', timerElements.length)
|
||||
timerElements.forEach(el => el.remove())
|
||||
}
|
||||
}
|
||||
}, { immediate: false })
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
await loadRotatingTexts();
|
||||
initializeTyped();
|
||||
trackVisit(); // 记录访问
|
||||
});
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
if (broadcastChannel) {
|
||||
broadcastChannel.close()
|
||||
}
|
||||
if (typedInstance) {
|
||||
typedInstance.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
const toggleQRCode = (qrCode) => {
|
||||
qrCodeSrc.value = qrCode || '';
|
||||
showQR.value = !showQR.value;
|
||||
};
|
||||
|
||||
const hideQRCode = () => {
|
||||
showQR.value = false;
|
||||
};
|
||||
|
||||
const toggleInfo = () => {
|
||||
showAbout.value = !showAbout.value;
|
||||
};
|
||||
|
||||
const isDarkMode = ref(false);
|
||||
const darkModeIconClass = ref('fas fa-moon');
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
isDarkMode.value = !isDarkMode.value;
|
||||
document.body.classList.toggle('dark-mode', isDarkMode.value);
|
||||
|
||||
localStorage.setItem('darkMode', isDarkMode.value);
|
||||
|
||||
darkModeIconClass.value = isDarkMode.value ? 'fas fa-sun' : 'fas fa-moon';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const savedDarkMode = localStorage.getItem('darkMode');
|
||||
if (savedDarkMode !== null) {
|
||||
isDarkMode.value = savedDarkMode === 'true';
|
||||
document.body.classList.toggle('dark-mode', isDarkMode.value);
|
||||
}
|
||||
|
||||
darkModeIconClass.value = isDarkMode.value ? 'fas fa-sun' : 'fas fa-moon';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
margin-top: 20px;
|
||||
|
||||
.user-profile-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.user-profile-image {
|
||||
display: flex;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
padding: 5px;
|
||||
border: 3px solid var(--border-color);
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.status-ball {
|
||||
position: absolute;
|
||||
background: #00c800;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
border-radius: 20px;
|
||||
border: 3px solid #eee;
|
||||
bottom: 5px;
|
||||
right: 15px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: "在线中";
|
||||
color: #00c800;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out, color 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
width: 4.5em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 1;
|
||||
color: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
font-size: 1.3em;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.name-style {
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
border-radius: 5px;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translate(-50%);
|
||||
z-index: -1;
|
||||
content: "";
|
||||
background: #ffcc00ad;
|
||||
height: 30%;
|
||||
width: 110%;
|
||||
transition: height 0.3s ease-in-out;
|
||||
}
|
||||
&:hover::before {
|
||||
height: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 1.2rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '"';
|
||||
font-size: 1.5em;
|
||||
color: #999;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--border-radius);
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
.contact-item {
|
||||
color: var(--text-color);
|
||||
font-size: var(--icon-size);
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease-in-out, color 0.3s ease-in-out;
|
||||
position: relative;
|
||||
|
||||
.fas.fa-moon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px) rotate(10deg);
|
||||
color: var(--hover-color);
|
||||
|
||||
.tooltip {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 10px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
background-color: rgba(var(--background-color-rgb), 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s ease-out;
|
||||
|
||||
.modal-content {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
.modal-content {
|
||||
transform: translateY(30px) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-to,
|
||||
.fade-leave-from {
|
||||
opacity: 1;
|
||||
|
||||
.modal-content {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 4px 8px var(--shadow-color);
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
&:hover {
|
||||
transform: scale(1.03) translateY(-5px);
|
||||
box-shadow: 0 15px 30px -10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.content {
|
||||
gap: 15px;
|
||||
}
|
||||
.content .user-profile-container {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,442 @@
|
||||
<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 = [
|
||||
{ name: 'all', label: '全部', icon: 'fas fa-th' },
|
||||
{ name: 'web', label: '网页', icon: 'fas fa-globe' },
|
||||
{ name: 'social', label: '社交', icon: 'fas fa-share-alt' },
|
||||
{ name: 'media', label: '媒体', icon: 'fas fa-photo-video' },
|
||||
{ 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-external-link-alt',
|
||||
'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-alt', 'fas fa-laptop', 'fas fa-keyboard',
|
||||
],
|
||||
other: [
|
||||
'fas fa-envelope', 'fas fa-phone', 'fas fa-map-marker-alt', 'fas fa-calendar',
|
||||
'fas fa-clock', 'fas fa-bell', 'fas fa-cog', '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>
|
||||
@@ -0,0 +1,517 @@
|
||||
<template>
|
||||
<div class="icon-selector">
|
||||
<div class="icon-tabs">
|
||||
<button
|
||||
v-if="defaultIconPath"
|
||||
:class="['tab-btn', { active: iconMode === 'default' }]"
|
||||
@click="iconMode = 'default'"
|
||||
>
|
||||
默认图标
|
||||
</button>
|
||||
<button
|
||||
:class="['tab-btn', { active: iconMode === 'upload' }]"
|
||||
@click="iconMode = 'upload'"
|
||||
>
|
||||
上传图标
|
||||
</button>
|
||||
<button
|
||||
:class="['tab-btn', { active: iconMode === 'url' }]"
|
||||
@click="iconMode = 'url'"
|
||||
>
|
||||
URL图标
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 默认图标 -->
|
||||
<div v-if="iconMode === 'default' && defaultIconPath" class="icon-content">
|
||||
<div class="default-icon-preview">
|
||||
<img :src="defaultIconPath" alt="默认图标" class="icon-preview" />
|
||||
<p class="icon-hint">使用默认本地图标:{{ defaultIconPath }}</p>
|
||||
</div>
|
||||
<button @click="selectDefault" class="select-btn">使用默认图标</button>
|
||||
</div>
|
||||
|
||||
<!-- 上传图标 -->
|
||||
<div v-if="iconMode === 'upload'" class="icon-content">
|
||||
<div class="upload-area">
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
@change="handleFileSelect"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
/>
|
||||
<div v-if="!uploadedIconUrl" class="upload-placeholder">
|
||||
<p>点击选择图标文件</p>
|
||||
<p class="hint">支持 jpg、png、jpeg、webp、avif、svg、ico 等格式</p>
|
||||
</div>
|
||||
<div v-else class="uploaded-preview">
|
||||
<img :src="uploadedIconUrl" alt="上传的图标" class="icon-preview" />
|
||||
<p class="icon-hint">已上传的图标</p>
|
||||
</div>
|
||||
<button @click="$refs.fileInput.click()" class="upload-btn">
|
||||
{{ uploadedIconUrl ? '重新选择' : '选择文件' }}
|
||||
</button>
|
||||
<div v-if="uploading" class="upload-status">上传中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL图标 -->
|
||||
<div v-if="iconMode === 'url'" class="icon-content">
|
||||
<div class="url-input-group">
|
||||
<label>图标URL</label>
|
||||
<input
|
||||
v-model="iconUrl"
|
||||
type="text"
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
class="url-input"
|
||||
@input="handleUrlInput"
|
||||
/>
|
||||
<small class="form-hint">支持重定向图片URL,格式:avif, png, jpg, jpeg, webp, svg, ico等</small>
|
||||
<div v-if="urlValidating" class="url-status">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>正在验证图片URL...</span>
|
||||
</div>
|
||||
<div v-if="urlError" class="url-error">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>{{ urlError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="iconUrl && !urlError && urlValidated" class="url-preview">
|
||||
<img :src="validatedUrl" alt="URL图标" class="icon-preview" @error="handleImageError" />
|
||||
<p class="icon-hint">URL图标预览(支持重定向)</p>
|
||||
</div>
|
||||
<button @click="selectUrl" class="select-btn" :disabled="!iconUrl || urlValidating || !!urlError">
|
||||
{{ urlValidating ? '验证中...' : '使用URL图标' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 当前选择的图标预览 -->
|
||||
<div v-if="currentIcon" class="current-icon">
|
||||
<p class="current-label">当前图标:</p>
|
||||
<img :src="currentIcon" alt="当前图标" class="icon-preview" @error="handleImageError" />
|
||||
<p class="icon-hint">{{ currentIcon }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { adminAPI } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
defaultIconPath: {
|
||||
type: String,
|
||||
default: '/favicon.ico',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const iconMode = ref('default')
|
||||
const iconUrl = ref('')
|
||||
const uploadedIconUrl = ref('')
|
||||
const uploading = ref(false)
|
||||
const currentIcon = ref(props.modelValue || props.defaultIconPath)
|
||||
const urlValidating = ref(false)
|
||||
const urlValidated = ref(false)
|
||||
const urlError = ref('')
|
||||
const validatedUrl = ref('')
|
||||
|
||||
// 支持的图片格式
|
||||
const allowedImageFormats = ['avif', 'png', 'jpg', 'jpeg', 'webp', 'svg', 'ico', 'gif', 'bmp']
|
||||
|
||||
// 监听外部值变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
currentIcon.value = newVal
|
||||
// 根据值判断模式
|
||||
if (props.defaultIconPath && newVal === props.defaultIconPath) {
|
||||
iconMode.value = 'default'
|
||||
} else if (newVal.startsWith('http://') || newVal.startsWith('https://')) {
|
||||
iconMode.value = 'url'
|
||||
iconUrl.value = newVal
|
||||
} else if (newVal.startsWith('/uploads/')) {
|
||||
iconMode.value = 'upload'
|
||||
uploadedIconUrl.value = newVal
|
||||
} else {
|
||||
// 如果值不为空但不是已知格式,默认使用URL模式
|
||||
iconMode.value = 'url'
|
||||
iconUrl.value = newVal
|
||||
}
|
||||
} else {
|
||||
currentIcon.value = props.defaultIconPath || ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const handleFileSelect = async (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
// 检查文件类型,支持常见图标格式包括ico
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/x-icon', 'image/vnd.microsoft.icon', 'image/ico', 'image/icon']
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase()
|
||||
const allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'avif', 'bmp']
|
||||
|
||||
if (!allowedTypes.includes(file.type) && !allowedExtensions.includes(fileExtension)) {
|
||||
alert('不支持的文件格式,请上传 jpg、png、gif、webp、svg、ico 等格式的图片')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const res = await adminAPI.uploadFile(file)
|
||||
uploadedIconUrl.value = res.data.url
|
||||
currentIcon.value = res.data.url
|
||||
emit('update:modelValue', res.data.url)
|
||||
} catch (error) {
|
||||
alert('上传失败: ' + (error.response?.data?.error || error.message))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectDefault = () => {
|
||||
currentIcon.value = props.defaultIconPath
|
||||
emit('update:modelValue', props.defaultIconPath)
|
||||
}
|
||||
|
||||
// 验证图片URL(支持重定向)
|
||||
const validateImageUrl = async (url) => {
|
||||
if (!url) {
|
||||
urlError.value = ''
|
||||
urlValidated.value = false
|
||||
return false
|
||||
}
|
||||
|
||||
urlValidating.value = true
|
||||
urlError.value = ''
|
||||
urlValidated.value = false
|
||||
|
||||
try {
|
||||
// 检查URL格式
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
urlError.value = 'URL必须以http://或https://开头'
|
||||
urlValidating.value = false
|
||||
return false
|
||||
}
|
||||
|
||||
// 使用fetch跟随重定向
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
mode: 'cors',
|
||||
redirect: 'follow'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
urlError.value = '无法访问该URL'
|
||||
urlValidating.value = false
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取最终URL(可能经过重定向)
|
||||
const finalUrl = response.url || url
|
||||
|
||||
// 检查Content-Type
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
const isImage = contentType.startsWith('image/')
|
||||
|
||||
// 或者检查URL扩展名
|
||||
const urlLower = finalUrl.toLowerCase()
|
||||
const hasImageExtension = allowedImageFormats.some(format =>
|
||||
urlLower.includes(`.${format}`) || urlLower.includes(`/${format}`)
|
||||
)
|
||||
|
||||
if (!isImage && !hasImageExtension) {
|
||||
urlError.value = 'URL指向的不是图片文件'
|
||||
urlValidating.value = false
|
||||
return false
|
||||
}
|
||||
|
||||
// 使用Image对象进一步验证(支持重定向)
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
img.onload = () => {
|
||||
validatedUrl.value = finalUrl
|
||||
urlValidated.value = true
|
||||
urlError.value = ''
|
||||
urlValidating.value = false
|
||||
resolve(true)
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
// 即使Image加载失败,如果Content-Type正确,也允许使用
|
||||
if (isImage || hasImageExtension) {
|
||||
validatedUrl.value = finalUrl
|
||||
urlValidated.value = true
|
||||
urlError.value = ''
|
||||
urlValidating.value = false
|
||||
resolve(true)
|
||||
} else {
|
||||
urlError.value = '无法加载图片,请检查URL是否正确'
|
||||
urlValidated.value = false
|
||||
urlValidating.value = false
|
||||
resolve(false)
|
||||
}
|
||||
}
|
||||
|
||||
img.src = finalUrl
|
||||
})
|
||||
} catch (error) {
|
||||
urlError.value = '验证失败: ' + (error.message || '网络错误')
|
||||
urlValidated.value = false
|
||||
urlValidating.value = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理URL输入(防抖)
|
||||
let urlValidationTimer = null
|
||||
const handleUrlInput = () => {
|
||||
urlValidated.value = false
|
||||
urlError.value = ''
|
||||
|
||||
if (urlValidationTimer) {
|
||||
clearTimeout(urlValidationTimer)
|
||||
}
|
||||
|
||||
urlValidationTimer = setTimeout(() => {
|
||||
if (iconUrl.value) {
|
||||
validateImageUrl(iconUrl.value)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const selectUrl = async () => {
|
||||
if (iconUrl.value && !urlError.value) {
|
||||
// 如果还未验证,先验证
|
||||
if (!urlValidated.value) {
|
||||
const isValid = await validateImageUrl(iconUrl.value)
|
||||
if (!isValid) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
currentIcon.value = validatedUrl.value || iconUrl.value
|
||||
emit('update:modelValue', validatedUrl.value || iconUrl.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
if (iconMode.value === 'url') {
|
||||
urlError.value = '无法加载图片,请检查URL是否正确'
|
||||
urlValidated.value = false
|
||||
}
|
||||
console.warn('无法加载图标URL:', iconUrl.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化时根据当前值设置模式
|
||||
if (props.modelValue) {
|
||||
if (props.defaultIconPath && props.modelValue === props.defaultIconPath) {
|
||||
iconMode.value = 'default'
|
||||
} else if (
|
||||
props.modelValue.startsWith('http://') ||
|
||||
props.modelValue.startsWith('https://')
|
||||
) {
|
||||
iconMode.value = 'url'
|
||||
iconUrl.value = props.modelValue
|
||||
} else if (props.modelValue.startsWith('/uploads/')) {
|
||||
iconMode.value = 'upload'
|
||||
uploadedIconUrl.value = props.modelValue
|
||||
} else {
|
||||
// 如果值不为空但不是已知格式,默认使用URL模式
|
||||
iconMode.value = 'url'
|
||||
iconUrl.value = props.modelValue
|
||||
}
|
||||
} else if (!props.defaultIconPath) {
|
||||
// 如果没有默认图标路径,默认使用上传模式
|
||||
iconMode.value = 'upload'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon-selector {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
background: rgba(var(--background-color-rgb), 0.3);
|
||||
}
|
||||
|
||||
.icon-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
border-bottom-color: #007aff;
|
||||
color: #007aff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.icon-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.default-icon-preview,
|
||||
.uploaded-preview,
|
||||
.url-preview {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: rgba(var(--background-color-rgb), 0.5);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.icon-preview {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
margin: 0 auto 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.icon-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
padding: 40px;
|
||||
background: rgba(var(--background-color-rgb), 0.5);
|
||||
border-radius: 8px;
|
||||
border: 2px dashed var(--border-color);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.upload-placeholder p {
|
||||
margin: 5px 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.upload-placeholder .hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.upload-btn,
|
||||
.select-btn {
|
||||
padding: 10px 20px;
|
||||
background: #007aff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.upload-btn:hover,
|
||||
.select-btn:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.select-btn:disabled {
|
||||
background: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
margin-top: 10px;
|
||||
color: #007aff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.url-input-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.url-input-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.url-input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--background-color-rgb), 0.5);
|
||||
color: var(--text-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.url-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
color: #007aff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.url-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
color: #f44336;
|
||||
font-size: 13px;
|
||||
padding: 8px;
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(244, 67, 54, 0.2);
|
||||
}
|
||||
|
||||
.current-icon {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.current-label {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-background">
|
||||
<div class="floating-shapes">
|
||||
<div class="shape shape-1"></div>
|
||||
<div class="shape shape-2"></div>
|
||||
<div class="shape shape-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-box">
|
||||
<div class="login-header">
|
||||
<div class="login-icon">
|
||||
<i class="fas fa-lock"></i>
|
||||
</div>
|
||||
<h2>管理员登录</h2>
|
||||
<p class="login-subtitle">欢迎回来,请登录您的账户</p>
|
||||
</div>
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<div class="form-group">
|
||||
<div class="input-wrapper">
|
||||
<i class="fas fa-user input-icon"></i>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
class="login-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-wrapper">
|
||||
<i class="fas fa-lock input-icon"></i>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
class="login-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" :disabled="loading" class="login-btn">
|
||||
<span v-if="!loading">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
登录
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
登录中...
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="error" class="error-message">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { login } from '../api'
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const handleLogin = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await login(username.value, password.value)
|
||||
localStorage.setItem('token', res.data.token)
|
||||
window.location.href = '/admin'
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: var(--background-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg,
|
||||
rgba(0, 122, 255, 0.1) 0%,
|
||||
rgba(255, 204, 0, 0.1) 50%,
|
||||
rgba(0, 122, 255, 0.1) 100%);
|
||||
background-size: 200% 200%;
|
||||
animation: gradientShift 15s ease infinite;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.floating-shapes {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shape {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.1;
|
||||
animation: float 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.shape-1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: var(--hover-link-color);
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.shape-2 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: #007aff;
|
||||
bottom: -50px;
|
||||
right: -50px;
|
||||
animation-delay: 5s;
|
||||
}
|
||||
|
||||
.shape-3 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
background: var(--hover-link-color);
|
||||
top: 50%;
|
||||
right: 10%;
|
||||
animation-delay: 10s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -30px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.login-box {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: rgba(var(--background-color-rgb), 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
padding: 48px 40px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.login-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 20px;
|
||||
background: linear-gradient(135deg, #007aff, var(--hover-link-color));
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 15px rgba(0, 122, 255, 0.3);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 4px 15px rgba(0, 122, 255, 0.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(0, 122, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.login-icon i {
|
||||
font-size: 28px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--text-color);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 0;
|
||||
color: rgba(var(--text-color-rgb, 51, 51, 51), 0.6);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
color: rgba(var(--text-color-rgb, 51, 51, 51), 0.5);
|
||||
font-size: 16px;
|
||||
z-index: 1;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px 14px 48px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--background-color-rgb), 0.6);
|
||||
color: var(--text-color);
|
||||
font-size: 15px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.login-input::placeholder {
|
||||
color: rgba(var(--text-color-rgb, 51, 51, 51), 0.4);
|
||||
}
|
||||
|
||||
.login-input:focus {
|
||||
border-color: #007aff;
|
||||
background: rgba(var(--background-color-rgb), 0.8);
|
||||
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.login-input:focus + .input-icon,
|
||||
.login-input:focus ~ .input-icon {
|
||||
color: #007aff;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #007aff, #0056b3);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 15px rgba(0, 122, 255, 0.3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
|
||||
.login-btn:hover::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 122, 255, 0.4);
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.login-btn span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #f44336;
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(244, 67, 54, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
animation: shake 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.login-box {
|
||||
padding: 36px 24px;
|
||||
margin: 20px;
|
||||
max-width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.shape {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div class="visit-timer-container">
|
||||
<div class="visit-timer"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 50, x: '-50%', scale: 0.5 }"
|
||||
:enter="{ opacity: 1, y: 0, x: '-50%', scale: 1, transition: { duration: 300 } }"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="toggleCalendar">
|
||||
<div class="timer-content">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>停留时间 : </span>
|
||||
<div class="time">
|
||||
<template v-for="(value, unit) in timeUnits" :key="unit">
|
||||
<div class="time-wrapper">
|
||||
<Transition name="flip">
|
||||
<span :key="value" class="time-unit">{{ value }}</span>
|
||||
</Transition>
|
||||
</div>
|
||||
<span v-if="unit !== 'seconds'" class="separator">:</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="calendar">
|
||||
<div v-if="showCalendar"
|
||||
class="calendar-popup"
|
||||
@mouseleave="handleMouseLeave">
|
||||
<div class="calendar-header">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
{{ dateTime.dateOnly }}
|
||||
</div>
|
||||
<div class="calendar-time">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>{{ dateTime.weekday }}</span>
|
||||
<span>{{ dateTime.timeWithoutSeconds }}</span>
|
||||
<span v-if="isCalendarPinned" class="pin-indicator">
|
||||
<i class="fas fa-thumbtack"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||
|
||||
// === 合并计时和日期时间逻辑 ===
|
||||
const useTimeManager = () => {
|
||||
const startTime = ref(Date.now());
|
||||
const currentTime = ref(Date.now());
|
||||
|
||||
onMounted(() => {
|
||||
const timer = setInterval(() => {
|
||||
currentTime.value = Date.now();
|
||||
}, 1000);
|
||||
|
||||
onUnmounted(() => clearInterval(timer));
|
||||
});
|
||||
|
||||
// 计算属性
|
||||
const timeUnits = computed(() => {
|
||||
const totalSeconds = Math.floor((currentTime.value - startTime.value) / 1000);
|
||||
return {
|
||||
hours: Math.floor(totalSeconds / 3600).toString().padStart(2, '0'),
|
||||
minutes: Math.floor((totalSeconds % 3600) / 60).toString().padStart(2, '0'),
|
||||
seconds: (totalSeconds % 60).toString().padStart(2, '0')
|
||||
};
|
||||
});
|
||||
|
||||
const dateTime = computed(() => {
|
||||
const now = new Date(currentTime.value);
|
||||
return {
|
||||
dateOnly: now.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}),
|
||||
weekday: now.toLocaleDateString('zh-CN', { weekday: 'long' }),
|
||||
timeWithoutSeconds: now.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
timeUnits,
|
||||
dateTime
|
||||
};
|
||||
};
|
||||
|
||||
// === 简化组件状态管理 ===
|
||||
const showCalendar = ref(false);
|
||||
const isCalendarPinned = ref(false);
|
||||
|
||||
const { timeUnits, dateTime } = useTimeManager();
|
||||
|
||||
// 简化事件处理
|
||||
const handleMouseEnter = () => showCalendar.value = true;
|
||||
const handleMouseLeave = () => !isCalendarPinned.value && (showCalendar.value = false);
|
||||
const toggleCalendar = () => {
|
||||
isCalendarPinned.value = !isCalendarPinned.value;
|
||||
showCalendar.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
/* 基础组件样式 */
|
||||
.visit-timer-container {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
|
||||
/* 在移动端隐藏组件 */
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.visit-timer, .calendar-popup {
|
||||
pointer-events: auto;
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: blur(10px);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.visit-timer {
|
||||
bottom: 50px;
|
||||
padding: 8px 15px;
|
||||
border-radius: 20px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
transform: translateX(-50%) translateY(-5px);
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
}
|
||||
|
||||
.timer-content {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
font-size: 0.9em;
|
||||
|
||||
.time {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 时间单位样式 */
|
||||
.time-wrapper {
|
||||
position: relative;
|
||||
width: 1.6em;
|
||||
height: 1.2em;
|
||||
overflow: visible;
|
||||
|
||||
.time-unit {
|
||||
display: inline-block;
|
||||
width: 1.6em;
|
||||
text-align: center;
|
||||
height: 1.2em;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* 日历弹窗样式 */
|
||||
.calendar-popup {
|
||||
bottom: calc(50px + 50px);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px var(--shadow-color);
|
||||
min-width: 188px;
|
||||
|
||||
.calendar-header {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.calendar-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
/* 固定图钉样式 */
|
||||
.pin-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: -10px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--background-color);
|
||||
box-shadow: 0 2px 12px var(--shadow-color);
|
||||
color: red;
|
||||
transform: rotate(45deg);
|
||||
animation: pin-in 0.6s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
/* 添加图钉动画关键帧 */
|
||||
@keyframes pin-in {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(0.5) translateY(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
transform: rotate(0deg) scale(1.2) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(45deg) scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画样式 */
|
||||
.calendar {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(20px) scale(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.flip {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&-enter-from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&-leave-to {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="swiper-container">
|
||||
<div class="swiper-wrapper">
|
||||
<div v-for="(siteChunk, index) in chunkedSites" :key="index" class="swiper-slide">
|
||||
<div class="site-grid">
|
||||
<div v-for="(site, i) in siteChunk" :key="i" class="site-box" @click="openLink(site.url)">
|
||||
<div class="site-content">
|
||||
<i :class="site.icon" aria-hidden="true"></i>
|
||||
<span class="site-name">{{ site.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swiper-pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import Swiper from 'swiper/bundle';
|
||||
import 'swiper/swiper-bundle.css';
|
||||
import { getSites } from '../api';
|
||||
|
||||
const sites = ref([]);
|
||||
const chunkedSites = ref([]);
|
||||
|
||||
const loadSites = async () => {
|
||||
try {
|
||||
const res = await getSites();
|
||||
sites.value = res.data;
|
||||
// 将站点数据分块(每页6个)
|
||||
chunkedSites.value = sites.value.reduce((acc, site, index) => {
|
||||
const chunkIndex = Math.floor(index / 6);
|
||||
if (!acc[chunkIndex]) acc[chunkIndex] = [];
|
||||
acc[chunkIndex].push(site);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// 如果数据加载后需要重新初始化Swiper
|
||||
if (chunkedSites.value.length > 0) {
|
||||
setTimeout(() => {
|
||||
initSwiper();
|
||||
}, 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载站点数据失败:', error);
|
||||
// 如果API失败,使用空数组
|
||||
chunkedSites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
let swiperInstance = null;
|
||||
|
||||
const initSwiper = () => {
|
||||
if (swiperInstance) {
|
||||
swiperInstance.destroy();
|
||||
}
|
||||
swiperInstance = new Swiper('.swiper-container', {
|
||||
slidesPerView: 1,
|
||||
spaceBetween: 20,
|
||||
pagination: { el: '.swiper-pagination', clickable: true },
|
||||
mousewheel: true,
|
||||
});
|
||||
};
|
||||
|
||||
const openLink = (url) => {
|
||||
if (url) window.open(url, '_blank');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadSites();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
margin: 30px 0 20px;
|
||||
}
|
||||
|
||||
.swiper-container {
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.swiper-pagination {
|
||||
bottom: inherit;
|
||||
}
|
||||
|
||||
.site-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.site-box {
|
||||
padding: 30px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: rgba(var(--background-color-rgb), 0.2);
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 1px 8px var(--shadow-color);
|
||||
}
|
||||
}
|
||||
|
||||
.site-content {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
font-size: var(--icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
.site-name {
|
||||
margin: 0;
|
||||
font-size: 1.17em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.site-content {
|
||||
gap: 5px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.site-box {
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.site-content i {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.swiper-pagination-bullet-active) {
|
||||
background: #8c8c8c94;
|
||||
width: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"type": "Email",
|
||||
"icon": "fas fa-envelope",
|
||||
"url": "mailto:i@bsgun.cn",
|
||||
"hoverColor": "#e78b0a"
|
||||
},
|
||||
{
|
||||
"type": "Github",
|
||||
"icon": "fab fa-github",
|
||||
"url": "https://github.com/JLinmr",
|
||||
"hoverColor": "#6500fc"
|
||||
},
|
||||
{
|
||||
"type": "支付宝",
|
||||
"icon": "fab fa-alipay",
|
||||
"qrCode": "https://lib.bsgun.cn/Hexo-static/img/zfbzf.avif",
|
||||
"hoverColor": "#007aff"
|
||||
},
|
||||
{
|
||||
"type": "微信",
|
||||
"icon": "fab fa-weixin",
|
||||
"qrCode": "https://lib.bsgun.cn/Hexo-static/img/wxzf.avif",
|
||||
"hoverColor": "#247700"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"name": "博客",
|
||||
"url": "https://blog.bsgun.cn",
|
||||
"icon": "fa fa-blog"
|
||||
},
|
||||
{
|
||||
"name": "雨云",
|
||||
"url": "https://www.rainyun.com/Lin_",
|
||||
"icon": "fa fa-cloud "
|
||||
},
|
||||
{
|
||||
"name": "图床",
|
||||
"url": "https://dev.bsgun.cn",
|
||||
"icon": "fa fa-image"
|
||||
},
|
||||
{
|
||||
"name": "封面",
|
||||
"url": "https://cover.bsgun.cn",
|
||||
"icon": "fa fa-panorama"
|
||||
},
|
||||
{
|
||||
"name": "监测",
|
||||
"url": "https://status.bsgun.cn",
|
||||
"icon": "fa fa-chart-line"
|
||||
},
|
||||
{
|
||||
"name": "图标",
|
||||
"url": "https://icon.bsgun.cn/",
|
||||
"icon": "fa fa-icons"
|
||||
}
|
||||
]
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './style.less';
|
||||
import { MotionPlugin } from '@vueuse/motion';
|
||||
import router from './router';
|
||||
import { loadAndApplyFrontendConfig } from './utils/frontendConfig';
|
||||
|
||||
// 加载前端配置
|
||||
loadAndApplyFrontendConfig();
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(MotionPlugin);
|
||||
app.use(router);
|
||||
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Home from '../components/Home.vue'
|
||||
import Admin from '../components/Admin.vue'
|
||||
import Login from '../components/Login.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: Home,
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: Admin,
|
||||
beforeEnter: (to, from, next) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
component: Login,
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
+155
File diff suppressed because one or more lines are too long
@@ -0,0 +1,95 @@
|
||||
import { getFrontendConfig } from '../api'
|
||||
|
||||
/**
|
||||
* 从API获取前端配置并更新页面
|
||||
*/
|
||||
export async function loadAndApplyFrontendConfig() {
|
||||
try {
|
||||
const res = await getFrontendConfig()
|
||||
const config = res.data
|
||||
|
||||
// 更新页面标题
|
||||
if (config.title) {
|
||||
document.title = config.title
|
||||
}
|
||||
|
||||
// 更新meta标签
|
||||
if (config.keywords) {
|
||||
updateMetaTag('keywords', config.keywords)
|
||||
}
|
||||
if (config.description) {
|
||||
updateMetaTag('description', config.description)
|
||||
}
|
||||
|
||||
// 更新favicon
|
||||
if (config.favicon) {
|
||||
updateFavicon(config.favicon)
|
||||
}
|
||||
|
||||
// 动态加载图标库
|
||||
if (config.iconLibrary) {
|
||||
loadStylesheet(config.iconLibrary, 'icon-library')
|
||||
}
|
||||
|
||||
// 动态加载字体库
|
||||
if (config.fontLibrary) {
|
||||
loadStylesheet(config.fontLibrary, 'font-library')
|
||||
}
|
||||
|
||||
// 动态加载Umami统计脚本
|
||||
if (config.umamiScript && config.umamiWebsiteId) {
|
||||
loadUmamiScript(config.umamiScript, config.umamiWebsiteId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载前端配置失败:', error)
|
||||
// 如果API失败,使用默认值(从环境变量或index.html中的占位符)
|
||||
}
|
||||
}
|
||||
|
||||
function updateMetaTag(name, content) {
|
||||
if (!content) return
|
||||
|
||||
let meta = document.querySelector(`meta[name="${name}"]`)
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta')
|
||||
meta.setAttribute('name', name)
|
||||
document.head.appendChild(meta)
|
||||
}
|
||||
meta.setAttribute('content', content)
|
||||
}
|
||||
|
||||
function updateFavicon(href) {
|
||||
let link = document.querySelector("link[rel*='icon']")
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
link.href = href
|
||||
}
|
||||
|
||||
function loadStylesheet(href, id) {
|
||||
// 检查是否已加载
|
||||
if (document.getElementById(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.id = id
|
||||
link.rel = 'stylesheet'
|
||||
link.href = href.startsWith('//') ? `https:${href}` : href
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
function loadUmamiScript(src, websiteId) {
|
||||
// 检查是否已加载
|
||||
if (document.querySelector(`script[data-website-id="${websiteId}"]`)) {
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.defer = true
|
||||
script.src = src
|
||||
script.setAttribute('data-website-id', websiteId)
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
less: {
|
||||
javascriptEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 1552,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:1551',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:1551',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user