Add files via upload

This commit is contained in:
QWQLwToo
2026-01-28 13:26:17 +08:00
committed by GitHub
parent 7030903eb6
commit 151c1c2387
29 changed files with 12656 additions and 2 deletions
+417
View File
@@ -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统一构建流程,支持多平台
+21
View File
@@ -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.
+189
View File
@@ -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 "清理完成"
+302 -2
View File
@@ -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` 启用CGOSQLite需要)
- `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
+53
View File
@@ -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
)
+137
View File
@@ -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
View File
@@ -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>
+388
View File
@@ -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.htmlSPA路由支持)
c.File(filepath.Join(distPath, "index.html"))
})
log.Printf("前端文件目录: %s", distPath)
} else {
// 回退到当前工作目录的dist(开发模式)
if _, err := os.Stat("./dist"); err == nil {
r.Static("/static", "./dist/static")
r.StaticFile("/favicon.ico", "./dist/favicon.ico")
r.NoRoute(func(c *gin.Context) {
if c.Request.URL.Path != "/api" && !strings.HasPrefix(c.Request.URL.Path, "/api/") && !strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
c.File("./dist/index.html")
}
})
log.Println("使用当前目录的dist文件夹(开发模式)")
} else {
log.Println("警告: 未找到前端文件目录,前端功能不可用")
log.Println("提示: 请将前端构建文件放在可执行文件同目录的dist文件夹中")
}
}
// 配置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.htmlSPA路由)
indexFile, err := distRoot.Open("index.html")
if err == nil {
defer indexFile.Close()
content, err := io.ReadAll(indexFile)
if err == nil {
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
} else {
c.Status(http.StatusNotFound)
}
} else {
c.Status(http.StatusNotFound)
}
})
log.Println("使用嵌入的前端文件(单一可执行文件模式)")
} else {
// 回退到文件系统(开发模式)
log.Println("警告: 无法加载嵌入的前端文件,尝试从文件系统加载")
distPath := filepath.Join(exeDir, "dist")
if _, err := os.Stat(distPath); err == nil {
frontendRouter.Static("/static", filepath.Join(distPath, "static"))
frontendRouter.StaticFile("/favicon.ico", filepath.Join(distPath, "favicon.ico"))
frontendRouter.Any("/api/*path", 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()
}
}
+3517
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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
View File
@@ -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>
+100
View File
@@ -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
+207
View File
@@ -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
+528
View File
@@ -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
});
// truefalseDOM
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已更新')
// truefalseDOM
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()
}
}
}
}
// showVisitTimerDOM
watch(showVisitTimer, async (newValue, oldValue) => {
console.log('showVisitTimer变化:', { oldValue, newValue })
// DOM
await nextTick()
// falsevisit-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>
+442
View File
@@ -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>
+517
View File
@@ -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">支持 jpgpngjpegwebpavifsvgico 等格式</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 = () => {
// 使ImageContent-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>
+403
View File
@@ -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>
+270
View File
@@ -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>
+158
View File
@@ -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>
+26
View File
@@ -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"
}
]
+32
View File
@@ -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
View File
@@ -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');
+34
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+95
View File
@@ -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)
}
+26
View File
@@ -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,
},
},
},
});