@@ -0,0 +1,169 @@
|
||||
# 后台管理系统使用说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
后台管理系统提供了完整的用户认证、内容管理、路由管理、日志查看等功能。
|
||||
|
||||
## 访问地址
|
||||
|
||||
访问 `http://localhost:3355/admin` 进入后台管理界面。
|
||||
|
||||
## 用户注册和登录
|
||||
|
||||
### 注册
|
||||
|
||||
1. 首次访问后台管理页面,点击"注册"标签
|
||||
2. 填写以下信息:
|
||||
- **用户名**:3-50个字符
|
||||
- **邮箱**:有效的邮箱地址
|
||||
- **密码**:必须满足以下要求:
|
||||
- 至少8个字符
|
||||
- 包含至少一个大写字母
|
||||
- 包含至少一个小写字母
|
||||
- 包含至少一个数字
|
||||
- 包含至少一个特殊字符
|
||||
- 不能是常见弱密码(如 password、123456 等)
|
||||
- 不能全部是相同字符
|
||||
|
||||
3. **重要**:第一个注册的用户将自动成为管理员
|
||||
|
||||
### 登录
|
||||
|
||||
1. 使用注册的用户名和密码登录
|
||||
2. 登录成功后会自动跳转到管理界面
|
||||
|
||||
## 功能模块
|
||||
|
||||
### 1. 仪表盘
|
||||
|
||||
显示系统概览信息:
|
||||
- 用户总数
|
||||
- 路由总数
|
||||
- 日志条目数
|
||||
- 服务器时间
|
||||
|
||||
### 2. 路由管理
|
||||
|
||||
可以添加、编辑、删除路由:
|
||||
|
||||
- **HTTP 方法**:GET、POST、PUT、DELETE、PATCH
|
||||
- **路径**:路由路径(如 `/api/example`)
|
||||
- **类型**:
|
||||
- `view`:视图路由
|
||||
- `json`:JSON 接口
|
||||
- `file`:文件路由
|
||||
- `static`:静态文件
|
||||
- `custom`:自定义处理
|
||||
- **处理器/文件路径**:处理函数或文件路径
|
||||
- **描述**:路由描述(可选)
|
||||
- **启用状态**:是否启用该路由
|
||||
- **排序**:路由执行顺序
|
||||
|
||||
### 3. 文件管理
|
||||
|
||||
- 浏览服务器文件
|
||||
- 查看文件内容
|
||||
- 编辑文件(需要手动实现保存功能)
|
||||
|
||||
### 4. 配置管理
|
||||
|
||||
可以编辑以下配置文件:
|
||||
- `tool-status.json`
|
||||
- `update-info.json`
|
||||
- `media-types.json`
|
||||
|
||||
操作步骤:
|
||||
1. 选择要编辑的配置文件
|
||||
2. 点击"加载配置"加载当前配置
|
||||
3. 在编辑器中修改 JSON 内容
|
||||
4. 点击"保存配置"保存更改
|
||||
|
||||
### 5. 日志查看
|
||||
|
||||
- 实时查看系统日志
|
||||
- 支持刷新和清空日志
|
||||
- 日志级别:INFO、WARN、ERROR
|
||||
|
||||
## API 接口
|
||||
|
||||
### 认证接口
|
||||
|
||||
- `POST /admin/register` - 用户注册
|
||||
- `POST /admin/login` - 用户登录
|
||||
- `POST /admin/logout` - 用户登出
|
||||
- `GET /admin/me` - 获取当前用户信息
|
||||
|
||||
### 管理接口(需要管理员权限)
|
||||
|
||||
- `GET /admin/api/logs` - 获取日志
|
||||
- `GET /admin/api/routes` - 获取所有路由
|
||||
- `POST /admin/api/routes` - 创建路由
|
||||
- `PUT /admin/api/routes/:id` - 更新路由
|
||||
- `DELETE /admin/api/routes/:id` - 删除路由
|
||||
- `GET /admin/api/files` - 获取文件列表
|
||||
- `GET /admin/api/file` - 读取文件
|
||||
- `POST /admin/api/file` - 保存文件
|
||||
- `PUT /admin/api/config` - 更新配置文件
|
||||
- `GET /admin/api/system` - 获取系统信息
|
||||
- `POST /admin/api/reload` - 热重载(需要重启服务器)
|
||||
|
||||
## 安全说明
|
||||
|
||||
1. **密码强度**:系统强制要求强密码,防止弱密码攻击
|
||||
2. **JWT 认证**:使用 JWT Token 进行身份验证
|
||||
3. **权限控制**:只有管理员可以访问管理功能
|
||||
4. **路径验证**:文件操作会验证路径,防止目录遍历攻击
|
||||
|
||||
## 数据库
|
||||
|
||||
系统使用 SQLite 数据库,数据库文件位于 `data/app.db`。
|
||||
|
||||
### 数据表
|
||||
|
||||
- **users**:用户表
|
||||
- id, username, email, password, is_admin, is_active
|
||||
- **routes**:路由表
|
||||
- id, method, path, type, handler, description, is_active, order
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **第一个用户**:第一个注册的用户自动成为管理员,请妥善保管账号
|
||||
2. **热重载**:路由热重载功能需要重启服务器才能生效
|
||||
3. **文件编辑**:文件编辑功能需要谨慎使用,建议先备份
|
||||
4. **日志限制**:日志缓冲区最多保存 1000 条记录
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 无法登录
|
||||
|
||||
1. 检查用户名和密码是否正确
|
||||
2. 确认账户未被禁用(is_active = true)
|
||||
3. 检查浏览器 Cookie 是否被禁用
|
||||
|
||||
### 权限不足
|
||||
|
||||
1. 确认当前用户是管理员(is_admin = true)
|
||||
2. 检查 Token 是否有效
|
||||
3. 尝试重新登录
|
||||
|
||||
### 数据库错误
|
||||
|
||||
1. 检查 `data` 目录权限
|
||||
2. 确认 SQLite 数据库文件可写
|
||||
3. 查看服务器日志获取详细错误信息
|
||||
|
||||
## 开发扩展
|
||||
|
||||
### 添加新的管理功能
|
||||
|
||||
1. 在 `handlers/admin.go` 中添加处理函数
|
||||
2. 在 `config/routes.go` 中注册路由
|
||||
3. 在前端 `admin.html` 和 `admin.js` 中添加界面
|
||||
|
||||
### 自定义日志记录
|
||||
|
||||
使用 `handlers.AddLog(level, message)` 添加日志到缓冲区。
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有问题,请查看服务器日志或联系开发团队。
|
||||
@@ -0,0 +1,237 @@
|
||||
# 打包编译说明
|
||||
|
||||
## 快速编译
|
||||
|
||||
### Windows
|
||||
|
||||
```bash
|
||||
# 方法1: 使用批处理脚本
|
||||
build.bat
|
||||
|
||||
# 方法2: 手动编译
|
||||
go build -ldflags="-s -w" -o software-download-center.exe .
|
||||
```
|
||||
|
||||
### Linux/macOS
|
||||
|
||||
```bash
|
||||
# 方法1: 使用 Shell 脚本
|
||||
chmod +x build.sh
|
||||
./build.sh
|
||||
|
||||
# 方法2: 手动编译
|
||||
go build -ldflags="-s -w" -o software-download-center .
|
||||
```
|
||||
|
||||
## 交叉编译
|
||||
|
||||
### 编译 Windows 版本(在 Linux/macOS 上)
|
||||
|
||||
```bash
|
||||
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o software-download-center.exe .
|
||||
```
|
||||
|
||||
### 编译 Linux 版本(在 Windows 上)
|
||||
|
||||
```bash
|
||||
set GOOS=linux
|
||||
set GOARCH=amd64
|
||||
go build -ldflags="-s -w" -o software-download-center .
|
||||
```
|
||||
|
||||
### 编译 macOS 版本
|
||||
|
||||
```bash
|
||||
# Intel 版本
|
||||
GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o software-download-center .
|
||||
|
||||
# Apple Silicon (M1/M2) 版本
|
||||
GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o software-download-center .
|
||||
```
|
||||
|
||||
## 编译参数说明
|
||||
|
||||
- `-ldflags="-s -w"`: 减小可执行文件大小
|
||||
- `-s`: 去除符号表
|
||||
- `-w`: 去除调试信息
|
||||
|
||||
## 部署文件结构
|
||||
|
||||
编译后的目录结构:
|
||||
|
||||
```
|
||||
output/
|
||||
├── software-download-center_*.exe (或可执行文件)
|
||||
├── start.bat (Windows 启动脚本)
|
||||
├── start.sh (Linux/macOS 启动脚本)
|
||||
├── public/ (静态资源目录)
|
||||
│ ├── css/
|
||||
│ ├── img/
|
||||
│ ├── fonts/
|
||||
│ ├── lang/
|
||||
│ └── *.json
|
||||
├── views/ (模板目录)
|
||||
│ ├── index.html
|
||||
│ ├── admin.html
|
||||
│ ├── 404.html
|
||||
│ └── 500.html
|
||||
├── README.md
|
||||
└── ADMIN.md
|
||||
```
|
||||
|
||||
## 运行服务端
|
||||
|
||||
### Windows
|
||||
|
||||
```bash
|
||||
# 方法1: 使用启动脚本
|
||||
start.bat
|
||||
|
||||
# 方法2: 直接运行
|
||||
software-download-center_windows_amd64.exe
|
||||
|
||||
# 方法3: 指定端口
|
||||
set PORT=8080
|
||||
software-download-center_windows_amd64.exe
|
||||
```
|
||||
|
||||
### Linux/macOS
|
||||
|
||||
```bash
|
||||
# 方法1: 使用启动脚本
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
|
||||
# 方法2: 直接运行
|
||||
chmod +x software-download-center_linux_amd64
|
||||
./software-download-center_linux_amd64
|
||||
|
||||
# 方法3: 指定端口
|
||||
PORT=8080 ./software-download-center_linux_amd64
|
||||
```
|
||||
|
||||
## 作为系统服务运行
|
||||
|
||||
### Linux (systemd)
|
||||
|
||||
创建服务文件 `/etc/systemd/system/software-download-center.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Software Download Center
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/software-download-center
|
||||
ExecStart=/opt/software-download-center/software-download-center_linux_amd64
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment="PORT=3355"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
启动服务:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable software-download-center
|
||||
sudo systemctl start software-download-center
|
||||
sudo systemctl status software-download-center
|
||||
```
|
||||
|
||||
### Windows (NSSM)
|
||||
|
||||
使用 NSSM 将程序安装为 Windows 服务:
|
||||
|
||||
```bash
|
||||
# 下载 NSSM: https://nssm.cc/download
|
||||
nssm install SoftwareDownloadCenter "C:\path\to\software-download-center.exe"
|
||||
nssm set SoftwareDownloadCenter AppDirectory "C:\path\to"
|
||||
nssm set SoftwareDownloadCenter AppEnvironmentExtra PORT=3355
|
||||
nssm start SoftwareDownloadCenter
|
||||
```
|
||||
|
||||
## 生产环境建议
|
||||
|
||||
1. **使用反向代理**(Nginx/Caddy)
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3355;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **使用进程管理器**
|
||||
- Linux: systemd, supervisor, pm2
|
||||
- Windows: NSSM, Windows Service
|
||||
- 跨平台: PM2
|
||||
|
||||
3. **配置 HTTPS**
|
||||
- 使用 Let's Encrypt 免费证书
|
||||
- 配置自动续期
|
||||
|
||||
4. **日志管理**
|
||||
- 配置日志轮转
|
||||
- 使用日志收集工具(如 ELK)
|
||||
|
||||
5. **监控**
|
||||
- 配置健康检查
|
||||
- 使用监控工具(如 Prometheus)
|
||||
|
||||
## 文件大小优化
|
||||
|
||||
编译后的文件大小通常在 15-25MB 左右。如需进一步优化:
|
||||
|
||||
1. 使用 UPX 压缩(可选):
|
||||
```bash
|
||||
upx --best software-download-center.exe
|
||||
```
|
||||
|
||||
2. 使用静态链接(减小依赖):
|
||||
```bash
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o software-download-center .
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 编译失败
|
||||
|
||||
- 确保 Go 版本 >= 1.21
|
||||
- 运行 `go mod download` 下载依赖
|
||||
- 检查网络连接(需要访问 Go 模块仓库)
|
||||
|
||||
### 运行时错误
|
||||
|
||||
- 确保 `public` 和 `views` 目录存在
|
||||
- 检查文件权限
|
||||
- 查看日志输出
|
||||
|
||||
### 端口被占用
|
||||
|
||||
- 修改 `PORT` 环境变量
|
||||
- 或修改代码中的默认端口
|
||||
|
||||
## 版本信息
|
||||
|
||||
编译时可以通过 `-ldflags` 注入版本信息:
|
||||
|
||||
```bash
|
||||
go build -ldflags="-X main.Version=1.0.0 -X main.BuildTime=$(date +%Y-%m-%d)" -o software-download-center .
|
||||
```
|
||||
|
||||
然后在代码中定义:
|
||||
|
||||
```go
|
||||
var Version = "dev"
|
||||
var BuildTime = "unknown"
|
||||
```
|
||||
@@ -0,0 +1,173 @@
|
||||
# CGO 问题解决方案
|
||||
|
||||
## 问题说明
|
||||
|
||||
如果遇到以下错误:
|
||||
```
|
||||
SQLite 连接失败: Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work
|
||||
```
|
||||
|
||||
这是因为 SQLite 驱动 (`go-sqlite3`) 需要 CGO 支持,但当前二进制文件是在禁用 CGO 的情况下编译的。
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 方案 1: 启用 CGO 重新编译(推荐用于 SQLite)
|
||||
|
||||
#### Windows
|
||||
|
||||
1. **安装 GCC 编译器**(如果还没有):
|
||||
- 下载并安装 [TDM-GCC](https://jmeubank.github.io/tdm-gcc/) 或 [MinGW-w64](https://www.mingw-w64.org/)
|
||||
- 确保 GCC 在系统 PATH 中
|
||||
|
||||
2. **验证 CGO 支持**:
|
||||
```powershell
|
||||
go env CGO_ENABLED
|
||||
```
|
||||
应该显示 `1`(如果显示 `0`,需要设置环境变量)
|
||||
|
||||
3. **启用 CGO 并重新编译**:
|
||||
```powershell
|
||||
$env:CGO_ENABLED="1"
|
||||
go build -o software-download-center.exe .
|
||||
```
|
||||
|
||||
#### Linux/macOS
|
||||
|
||||
1. **安装 GCC**(如果还没有):
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install build-essential
|
||||
|
||||
# macOS
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
2. **启用 CGO 并重新编译**:
|
||||
```bash
|
||||
export CGO_ENABLED=1
|
||||
go build -o software-download-center .
|
||||
```
|
||||
|
||||
### 方案 2: 使用 MySQL 数据库(无需 CGO)
|
||||
|
||||
如果不想处理 CGO 问题,可以直接使用 MySQL:
|
||||
|
||||
#### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
# 设置 MySQL 环境变量
|
||||
$env:DB_TYPE="mysql"
|
||||
$env:DB_HOST="localhost"
|
||||
$env:DB_PORT="3306"
|
||||
$env:DB_USER="root"
|
||||
$env:DB_PASSWORD="your_password"
|
||||
$env:DB_NAME="software_download_center"
|
||||
|
||||
# 运行程序
|
||||
go run main.go
|
||||
```
|
||||
|
||||
#### Linux/macOS
|
||||
|
||||
```bash
|
||||
# 设置 MySQL 环境变量
|
||||
export DB_TYPE=mysql
|
||||
export DB_HOST=localhost
|
||||
export DB_PORT=3306
|
||||
export DB_USER=root
|
||||
export DB_PASSWORD=your_password
|
||||
export DB_NAME=software_download_center
|
||||
|
||||
# 运行程序
|
||||
go run main.go
|
||||
```
|
||||
|
||||
#### 使用编译后的程序
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
$env:DB_TYPE="mysql"
|
||||
$env:DB_HOST="localhost"
|
||||
$env:DB_PORT="3306"
|
||||
$env:DB_USER="root"
|
||||
$env:DB_PASSWORD="your_password"
|
||||
$env:DB_NAME="software_download_center"
|
||||
.\software-download-center.exe
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
export DB_TYPE=mysql
|
||||
export DB_HOST=localhost
|
||||
export DB_PORT=3306
|
||||
export DB_USER=root
|
||||
export DB_PASSWORD=your_password
|
||||
export DB_NAME=software_download_center
|
||||
./software-download-center
|
||||
```
|
||||
|
||||
## 快速测试 MySQL 连接
|
||||
|
||||
在运行程序之前,确保 MySQL 服务正在运行,并且数据库已创建:
|
||||
|
||||
```sql
|
||||
-- 连接到 MySQL
|
||||
mysql -u root -p
|
||||
|
||||
-- 创建数据库
|
||||
CREATE DATABASE software_download_center CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 退出
|
||||
EXIT;
|
||||
```
|
||||
|
||||
## 永久解决方案
|
||||
|
||||
### 选项 A: 始终启用 CGO(用于 SQLite)
|
||||
|
||||
在编译脚本中设置 `CGO_ENABLED=1`:
|
||||
|
||||
**Windows (build.bat)**:
|
||||
```batch
|
||||
@echo off
|
||||
set CGO_ENABLED=1
|
||||
go build -o software-download-center.exe .
|
||||
```
|
||||
|
||||
**Linux/macOS (build.sh)**:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
export CGO_ENABLED=1
|
||||
go build -o software-download-center .
|
||||
```
|
||||
|
||||
### 选项 B: 默认使用 MySQL
|
||||
|
||||
在 `main.go` 或环境配置中设置默认数据库类型为 MySQL。
|
||||
|
||||
## 检查当前 CGO 状态
|
||||
|
||||
```bash
|
||||
go env CGO_ENABLED
|
||||
```
|
||||
|
||||
- `1` = CGO 已启用(可以使用 SQLite)
|
||||
- `0` = CGO 已禁用(需要使用 MySQL)
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么需要 CGO?
|
||||
A: SQLite 的 Go 驱动 (`go-sqlite3`) 是对 C 库的包装,需要 CGO 来调用 C 代码。
|
||||
|
||||
### Q: 可以完全避免 CGO 吗?
|
||||
A: 可以,使用 MySQL 或其他纯 Go 实现的数据库驱动(如 `modernc.org/sqlite`,但功能可能有限)。
|
||||
|
||||
### Q: MySQL 需要 CGO 吗?
|
||||
A: 不需要,MySQL 驱动 (`go-sql-driver/mysql`) 是纯 Go 实现的。
|
||||
|
||||
## 推荐方案
|
||||
|
||||
- **开发环境**: 使用 SQLite(需要启用 CGO)
|
||||
- **生产环境**:
|
||||
- 如果已有 MySQL 服务器,使用 MySQL(无需 CGO)
|
||||
- 如果希望简单部署,使用 SQLite + CGO
|
||||
@@ -0,0 +1,362 @@
|
||||
# 快速开始指南
|
||||
|
||||
这是一个详细的、一步一步的安装和运行指南。
|
||||
|
||||
## 第一步:检查 Go 环境
|
||||
|
||||
打开终端(Windows: PowerShell 或 CMD,Linux/macOS: Terminal),运行:
|
||||
|
||||
```bash
|
||||
go version
|
||||
```
|
||||
|
||||
**预期输出:**
|
||||
```
|
||||
go version go1.21.0 windows/amd64
|
||||
```
|
||||
|
||||
**如果显示错误:**
|
||||
- 请先安装 Go:https://golang.org/dl/
|
||||
- 安装后重启终端
|
||||
|
||||
---
|
||||
|
||||
## 第二步:进入项目目录
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell 或 CMD)
|
||||
cd D:\Desktop\update\go
|
||||
|
||||
# Linux/macOS
|
||||
cd /path/to/update/go
|
||||
```
|
||||
|
||||
**验证:** 运行 `dir` (Windows) 或 `ls` (Linux/macOS) 应该能看到 `main.go` 和 `go.mod` 文件。
|
||||
|
||||
---
|
||||
|
||||
## 第三步:下载依赖
|
||||
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
**预期输出:**
|
||||
```
|
||||
go: downloading github.com/gin-gonic/gin v1.9.1
|
||||
go: downloading github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
go: downloading gorm.io/gorm v1.25.5
|
||||
go: downloading gorm.io/driver/sqlite v1.5.4
|
||||
go: downloading golang.org/x/crypto v0.17.0
|
||||
...
|
||||
```
|
||||
|
||||
**如果下载失败:**
|
||||
```bash
|
||||
# 设置 Go 代理(中国大陆用户)
|
||||
go env -w GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
# 然后重新运行
|
||||
go mod download
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第四步:整理依赖(生成 go.sum)
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
**预期输出:**
|
||||
```
|
||||
go: downloading github.com/mattn/go-sqlite3 v1.14.17
|
||||
go: downloading github.com/jinzhu/now v1.1.5
|
||||
go: downloading github.com/jinzhu/inflection v1.0.0
|
||||
...
|
||||
```
|
||||
|
||||
这个命令会:
|
||||
- ✅ 下载所有缺失的依赖
|
||||
- ✅ 移除未使用的依赖
|
||||
- ✅ 生成/更新 `go.sum` 文件
|
||||
|
||||
---
|
||||
|
||||
## 第五步:验证依赖
|
||||
|
||||
```bash
|
||||
go mod verify
|
||||
```
|
||||
|
||||
**预期输出:**
|
||||
```
|
||||
all modules verified
|
||||
```
|
||||
|
||||
如果显示错误,请重新运行 `go mod tidy`。
|
||||
|
||||
---
|
||||
|
||||
## 第六步:测试编译
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
go build -o software-download-center.exe .
|
||||
|
||||
# Linux/macOS
|
||||
go build -o software-download-center .
|
||||
```
|
||||
|
||||
**预期结果:**
|
||||
- ✅ 无错误信息
|
||||
- ✅ 生成可执行文件(`software-download-center.exe` 或 `software-download-center`)
|
||||
|
||||
**如果编译失败:**
|
||||
- 检查错误信息
|
||||
- 确保所有依赖都已下载(重新运行 `go mod tidy`)
|
||||
|
||||
---
|
||||
|
||||
## 第七步:运行项目
|
||||
|
||||
### 方法 1: 直接运行(开发模式)
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
go run main.go
|
||||
|
||||
# Linux/macOS
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### 方法 2: 使用编译后的文件
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
.\software-download-center.exe
|
||||
|
||||
# Linux/macOS
|
||||
./software-download-center
|
||||
```
|
||||
|
||||
**预期输出:**
|
||||
```
|
||||
=============================================
|
||||
✅ 数据库初始化成功
|
||||
✅ 配置缓存初始化成功
|
||||
=============================================
|
||||
📋 开始注册路由...
|
||||
✅ 路由注册成功 [GET ] / (类型: view)
|
||||
✅ 路由注册成功 [GET ] /tool-status.json (类型: json)
|
||||
...
|
||||
📋 路由注册完成!
|
||||
|
||||
=============================================
|
||||
✅ 服务器启动成功
|
||||
📡 访问地址: http://localhost:3355
|
||||
🌍 当前环境: production
|
||||
🔄 兼容旧版访问:支持 /tool-status.json /update-info.json /media-types.json
|
||||
=============================================
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第八步:访问应用
|
||||
|
||||
1. **打开浏览器**
|
||||
2. **访问主页**:http://localhost:3355
|
||||
3. **访问后台管理**:http://localhost:3355/admin
|
||||
|
||||
---
|
||||
|
||||
## 常见问题解决
|
||||
|
||||
### 问题 1: `missing go.sum entry`
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
missing go.sum entry for module providing package github.com/gin-gonic/gin
|
||||
```
|
||||
|
||||
**解决方法:**
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题 2: 依赖下载失败
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
go: github.com/gin-gonic/gin@v1.9.1: Get "https://proxy.golang.org/...": dial tcp: lookup proxy.golang.org: no such host
|
||||
```
|
||||
|
||||
**解决方法:**
|
||||
```bash
|
||||
# 设置 Go 代理
|
||||
go env -w GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
# 重新下载
|
||||
go mod download
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题 3: 编译错误
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
# software-download-center/utils
|
||||
utils\route-utils.go:51:29: invalid operation
|
||||
```
|
||||
|
||||
**解决方法:**
|
||||
```bash
|
||||
# 清理并重新编译
|
||||
go clean
|
||||
go mod tidy
|
||||
go build -o software-download-center.exe .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题 4: 端口被占用
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
listen tcp :3355: bind: address already in use
|
||||
```
|
||||
|
||||
**解决方法:**
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
$env:PORT="8080"; go run main.go
|
||||
|
||||
# Windows (CMD)
|
||||
set PORT=8080 && go run main.go
|
||||
|
||||
# Linux/macOS
|
||||
PORT=8080 go run main.go
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题 5: 数据库初始化失败
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
数据库初始化失败: open data/app.db: The system cannot find the path specified
|
||||
```
|
||||
|
||||
**解决方法:**
|
||||
```bash
|
||||
# 手动创建 data 目录
|
||||
# Windows
|
||||
mkdir data
|
||||
|
||||
# Linux/macOS
|
||||
mkdir -p data
|
||||
|
||||
# 然后重新运行
|
||||
go run main.go
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整命令清单(复制粘贴)
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
# 1. 检查 Go 版本
|
||||
go version
|
||||
|
||||
# 2. 进入项目目录
|
||||
cd D:\Desktop\update\go
|
||||
|
||||
# 3. 下载依赖
|
||||
go mod download
|
||||
|
||||
# 4. 整理依赖
|
||||
go mod tidy
|
||||
|
||||
# 5. 验证依赖
|
||||
go mod verify
|
||||
|
||||
# 6. 测试编译
|
||||
go build -o software-download-center.exe .
|
||||
|
||||
# 7. 运行项目
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### Windows (CMD)
|
||||
|
||||
```cmd
|
||||
REM 1. 检查 Go 版本
|
||||
go version
|
||||
|
||||
REM 2. 进入项目目录
|
||||
cd D:\Desktop\update\go
|
||||
|
||||
REM 3. 下载依赖
|
||||
go mod download
|
||||
|
||||
REM 4. 整理依赖
|
||||
go mod tidy
|
||||
|
||||
REM 5. 验证依赖
|
||||
go mod verify
|
||||
|
||||
REM 6. 测试编译
|
||||
go build -o software-download-center.exe .
|
||||
|
||||
REM 7. 运行项目
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### Linux/macOS
|
||||
|
||||
```bash
|
||||
# 1. 检查 Go 版本
|
||||
go version
|
||||
|
||||
# 2. 进入项目目录
|
||||
cd /path/to/update/go
|
||||
|
||||
# 3. 下载依赖
|
||||
go mod download
|
||||
|
||||
# 4. 整理依赖
|
||||
go mod tidy
|
||||
|
||||
# 5. 验证依赖
|
||||
go mod verify
|
||||
|
||||
# 6. 测试编译
|
||||
go build -o software-download-center .
|
||||
|
||||
# 7. 运行项目
|
||||
go run main.go
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
- 📖 查看 [README.md](./README.md) 了解完整功能
|
||||
- 🔧 查看 [ADMIN.md](./ADMIN.md) 了解后台管理
|
||||
- 📦 查看 [BUILD.md](./BUILD.md) 了解打包部署
|
||||
|
||||
---
|
||||
|
||||
## 需要帮助?
|
||||
|
||||
如果遇到问题:
|
||||
1. 检查 Go 版本:`go version`(需要 >= 1.21)
|
||||
2. 检查网络连接(下载依赖需要)
|
||||
3. 查看错误信息并参考上面的"常见问题解决"
|
||||
4. 运行 `go mod tidy` 重新整理依赖
|
||||
@@ -0,0 +1,583 @@
|
||||
# YMhut更新站 - Go 版本
|
||||
|
||||
这是 Node.js 版本的完整 Go 实现,使用 Gin 框架构建,提供了更好的性能和更完善的 UI/UX 体验。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 完整复现 Node.js 版本的所有功能
|
||||
- ✅ 产品列表展示(自动从 downloads 目录读取)
|
||||
- ✅ JSON API 接口(tool-status.json, update-info.json, media-types.json 等)
|
||||
- ✅ 文件下载功能(支持安全校验)
|
||||
- ✅ 历史版本查看
|
||||
- ✅ 改进的 UI/UX(增强动画、优化响应式设计)
|
||||
- ✅ 完整的错误处理(404、500 页面)
|
||||
- ✅ 彩色日志系统
|
||||
- ✅ **后台管理系统**(新增)
|
||||
- 用户注册和登录(第一个用户自动成为管理员)
|
||||
- 密码强度验证(防止弱密码)
|
||||
- 路由管理(可视化添加、编辑、删除路由)
|
||||
- 文件管理(浏览、查看、编辑文件)
|
||||
- 配置管理(编辑 JSON 配置文件)
|
||||
- 日志查看(实时查看系统日志)
|
||||
- 系统信息(查看系统统计)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
go/
|
||||
├── main.go # 主入口文件
|
||||
├── go.mod # Go 模块依赖
|
||||
├── go.sum # 依赖校验文件
|
||||
├── config/
|
||||
│ └── routes.go # 路由配置
|
||||
├── models/ # 数据模型
|
||||
│ ├── user.go # 用户模型
|
||||
│ └── route.go # 路由模型
|
||||
├── database/ # 数据库
|
||||
│ └── db.go # 数据库初始化
|
||||
├── handlers/ # 请求处理
|
||||
│ ├── auth.go # 认证处理
|
||||
│ └── admin.go # 后台管理处理
|
||||
├── middleware/ # 中间件
|
||||
│ └── auth.go # 认证中间件
|
||||
├── utils/
|
||||
│ ├── logger.go # 日志工具
|
||||
│ ├── route-utils.go # 路由辅助函数
|
||||
│ ├── password.go # 密码验证
|
||||
│ ├── jwt.go # JWT Token
|
||||
│ └── config.go # 配置缓存
|
||||
├── views/ # HTML 模板
|
||||
│ ├── index.html
|
||||
│ ├── admin.html # 后台管理界面
|
||||
│ ├── 404.html
|
||||
│ └── 500.html
|
||||
└── public/ # 静态资源
|
||||
├── css/
|
||||
├── img/
|
||||
├── fonts/
|
||||
├── lang/
|
||||
├── downloads/ # 下载文件目录
|
||||
└── *.json # JSON 配置文件
|
||||
```
|
||||
|
||||
## 前置要求
|
||||
|
||||
### 必需
|
||||
|
||||
- **Go 1.21 或更高版本**
|
||||
- 下载地址:https://golang.org/dl/
|
||||
- 安装后验证:`go version`
|
||||
|
||||
### 可选(用于编译)
|
||||
|
||||
- **Git**(用于版本控制)
|
||||
- **CGO**(SQLite 需要,Windows 可能需要安装 GCC)
|
||||
|
||||
## 快速开始
|
||||
|
||||
**新手推荐**:查看 [QUICKSTART.md](./QUICKSTART.md) 获取详细的、一步一步的安装指南。
|
||||
|
||||
## 完整安装步骤
|
||||
|
||||
### 步骤 1: 检查 Go 环境
|
||||
|
||||
打开终端(Windows: PowerShell 或 CMD,Linux/macOS: Terminal),运行:
|
||||
|
||||
```bash
|
||||
go version
|
||||
```
|
||||
|
||||
**预期输出示例:**
|
||||
```
|
||||
go version go1.21.0 windows/amd64
|
||||
```
|
||||
|
||||
如果显示 "command not found" 或类似错误,请先安装 Go。
|
||||
|
||||
### 步骤 2: 进入项目目录
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
cd D:\Desktop\update\go
|
||||
|
||||
# Windows (CMD)
|
||||
cd D:\Desktop\update\go
|
||||
|
||||
# Linux/macOS
|
||||
cd /path/to/update/go
|
||||
```
|
||||
|
||||
### 步骤 3: 下载依赖
|
||||
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
**预期输出:**
|
||||
```
|
||||
go: downloading github.com/gin-gonic/gin v1.9.1
|
||||
go: downloading github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
go: downloading gorm.io/gorm v1.25.5
|
||||
...
|
||||
```
|
||||
|
||||
### 步骤 4: 整理依赖(生成 go.sum)
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
**预期输出:**
|
||||
```
|
||||
go: downloading github.com/mattn/go-sqlite3 v1.14.17
|
||||
go: downloading github.com/jinzhu/now v1.1.5
|
||||
...
|
||||
```
|
||||
|
||||
这个命令会:
|
||||
- 下载所有缺失的依赖
|
||||
- 移除未使用的依赖
|
||||
- 生成/更新 `go.sum` 文件
|
||||
|
||||
### 步骤 5: 验证依赖
|
||||
|
||||
```bash
|
||||
go mod verify
|
||||
```
|
||||
|
||||
**预期输出:**
|
||||
```
|
||||
all modules verified
|
||||
```
|
||||
|
||||
### 步骤 6: 测试编译
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
go build -o software-download-center.exe .
|
||||
|
||||
# Linux/macOS
|
||||
go build -o software-download-center .
|
||||
```
|
||||
|
||||
**预期输出:**
|
||||
- 无错误信息
|
||||
- 生成可执行文件
|
||||
|
||||
### 步骤 7: 运行项目
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
go run main.go
|
||||
|
||||
# 或使用编译后的文件
|
||||
.\software-download-center.exe
|
||||
|
||||
# Linux/macOS
|
||||
go run main.go
|
||||
|
||||
# 或使用编译后的文件
|
||||
./software-download-center
|
||||
```
|
||||
|
||||
**预期输出:**
|
||||
```
|
||||
=============================================
|
||||
✅ 数据库初始化成功
|
||||
✅ 配置缓存初始化成功
|
||||
=============================================
|
||||
📋 开始注册路由...
|
||||
✅ 路由注册成功 [GET ] / (类型: view)
|
||||
...
|
||||
📋 路由注册完成!
|
||||
|
||||
=============================================
|
||||
✅ 服务器启动成功
|
||||
📡 访问地址: http://localhost:3355
|
||||
🌍 当前环境: production
|
||||
🔄 兼容旧版访问:支持 /tool-status.json /update-info.json /media-types.json
|
||||
=============================================
|
||||
```
|
||||
|
||||
### 步骤 8: 访问应用
|
||||
|
||||
1. **主页**:http://localhost:3355
|
||||
2. **后台管理**:http://localhost:3355/admin
|
||||
|
||||
## 开发模式运行
|
||||
|
||||
### 启用调试模式
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
$env:GIN_MODE="debug"; go run main.go
|
||||
|
||||
# Windows (CMD)
|
||||
set GIN_MODE=debug && go run main.go
|
||||
|
||||
# Linux/macOS
|
||||
GIN_MODE=debug go run main.go
|
||||
```
|
||||
|
||||
### 自定义端口
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
$env:PORT="8080"; go run main.go
|
||||
|
||||
# Windows (CMD)
|
||||
set PORT=8080 && go run main.go
|
||||
|
||||
# Linux/macOS
|
||||
PORT=8080 go run main.go
|
||||
```
|
||||
|
||||
## 编译打包
|
||||
|
||||
### 方法 1: 使用打包脚本(推荐)
|
||||
|
||||
#### Windows
|
||||
|
||||
```bash
|
||||
# 运行批处理脚本
|
||||
build.bat
|
||||
```
|
||||
|
||||
#### Linux/macOS
|
||||
|
||||
```bash
|
||||
# 添加执行权限
|
||||
chmod +x build.sh
|
||||
|
||||
# 运行脚本
|
||||
./build.sh
|
||||
```
|
||||
|
||||
编译后的文件位于 `build/output/` 目录。
|
||||
|
||||
### 方法 2: 手动编译
|
||||
|
||||
#### 编译当前平台版本
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
go build -ldflags="-s -w" -o software-download-center.exe .
|
||||
|
||||
# Linux
|
||||
go build -ldflags="-s -w" -o software-download-center .
|
||||
|
||||
# macOS
|
||||
go build -ldflags="-s -w" -o software-download-center .
|
||||
```
|
||||
|
||||
#### 交叉编译(在其他平台编译)
|
||||
|
||||
```bash
|
||||
# 在 Windows 上编译 Linux 版本
|
||||
set GOOS=linux
|
||||
set GOARCH=amd64
|
||||
go build -ldflags="-s -w" -o software-download-center .
|
||||
|
||||
# 在 Linux/macOS 上编译 Windows 版本
|
||||
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o software-download-center.exe .
|
||||
|
||||
# 在 Linux/macOS 上编译 macOS 版本(Apple Silicon)
|
||||
GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o software-download-center .
|
||||
```
|
||||
|
||||
**编译参数说明:**
|
||||
- `-ldflags="-s -w"`:减小可执行文件大小
|
||||
- `-s`:去除符号表
|
||||
- `-w`:去除调试信息
|
||||
|
||||
### 方法 3: 静态编译(无 CGO 依赖)
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
set CGO_ENABLED=0
|
||||
set GOOS=windows
|
||||
set GOARCH=amd64
|
||||
go build -ldflags="-s -w" -o software-download-center.exe .
|
||||
|
||||
# Linux
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o software-download-center .
|
||||
|
||||
# macOS
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o software-download-center .
|
||||
```
|
||||
|
||||
**注意:** 静态编译时 SQLite 可能无法使用,需要 CGO 支持。
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `PORT` | 服务器端口 | `3355` |
|
||||
| `GIN_MODE` | Gin 运行模式 | `release` |
|
||||
| `CGO_ENABLED` | 是否启用 CGO | `1` |
|
||||
|
||||
### 文件命名规则
|
||||
|
||||
下载文件需要遵循以下命名格式才能被正确识别:
|
||||
|
||||
```
|
||||
产品名称 Setup 版本号.扩展名
|
||||
```
|
||||
|
||||
**示例:**
|
||||
- `YmhutBox Setup 1.4.21.exe`
|
||||
- `弈鸣小筑 Setup 2.0.0.zip`
|
||||
|
||||
**支持的扩展名:** `.exe`, `.zip`, `.pkg`, `.dmg`, `.msi`
|
||||
|
||||
## 后台管理
|
||||
|
||||
### 访问地址
|
||||
|
||||
http://localhost:3355/admin
|
||||
|
||||
### 首次使用
|
||||
|
||||
1. 访问后台管理页面
|
||||
2. 点击"注册"标签
|
||||
3. 填写注册信息(第一个注册的用户自动成为管理员)
|
||||
4. 登录后即可使用所有管理功能
|
||||
|
||||
### 密码要求
|
||||
|
||||
- 至少 8 个字符
|
||||
- 包含至少一个大写字母
|
||||
- 包含至少一个小写字母
|
||||
- 包含至少一个数字
|
||||
- 包含至少一个特殊字符
|
||||
- 不能是常见弱密码
|
||||
|
||||
详细使用说明请查看 [ADMIN.md](./ADMIN.md)。
|
||||
|
||||
## UI/UX 改进
|
||||
|
||||
相比 Node.js 版本,Go 版本在 UI/UX 方面做了以下改进:
|
||||
|
||||
1. **增强的动画效果**
|
||||
- 更流畅的卡片入场动画
|
||||
- 改进的悬停效果(轻微上浮)
|
||||
- 优化的按钮交互反馈
|
||||
|
||||
2. **改进的滚动条**
|
||||
- 更宽的滚动条(10px)
|
||||
- 圆角设计
|
||||
- 平滑的过渡效果
|
||||
|
||||
3. **无障碍改进**
|
||||
- 支持 `prefers-reduced-motion` 媒体查询
|
||||
- 增强的焦点可见性
|
||||
- 更好的键盘导航支持
|
||||
|
||||
4. **性能优化**
|
||||
- Go 的高性能并发处理
|
||||
- 更快的响应时间
|
||||
- 更低的内存占用
|
||||
|
||||
## API 接口
|
||||
|
||||
### JSON 接口
|
||||
|
||||
- `GET /tool-status.json` - 工具状态配置
|
||||
- `GET /update-info.json` - 更新信息
|
||||
- `GET /media-types.json` - 媒体类型配置
|
||||
- `GET /plugins.json` - 插件配置
|
||||
|
||||
所有接口都支持不带 `.json` 后缀的访问方式(向后兼容)。
|
||||
|
||||
### 文件下载
|
||||
|
||||
- `GET /downloads/:filename` - 下载指定文件
|
||||
|
||||
### 静态资源
|
||||
|
||||
- `/css/*` - CSS 样式文件
|
||||
- `/img/*` - 图片资源
|
||||
- `/fonts/*` - 字体文件
|
||||
- `/lang/*` - 语言文件
|
||||
|
||||
### 后台管理 API
|
||||
|
||||
详细 API 文档请查看 [ADMIN.md](./ADMIN.md)。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 编译错误:missing go.sum entry
|
||||
|
||||
**解决方法:**
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
### 2. 运行时错误:数据库初始化失败
|
||||
|
||||
**可能原因:**
|
||||
- `data` 目录权限不足
|
||||
- 磁盘空间不足
|
||||
|
||||
**解决方法:**
|
||||
- 检查目录权限
|
||||
- 确保有足够的磁盘空间
|
||||
- 手动创建 `data` 目录
|
||||
|
||||
### 3. 端口被占用
|
||||
|
||||
**解决方法:**
|
||||
```bash
|
||||
# 使用其他端口
|
||||
PORT=8080 go run main.go
|
||||
```
|
||||
|
||||
### 4. SQLite 相关错误(CGO 问题)
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
SQLite 连接失败: Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work
|
||||
```
|
||||
|
||||
**可能原因:**
|
||||
- 缺少 CGO 支持
|
||||
- 缺少 GCC 编译器
|
||||
- 编译时禁用了 CGO
|
||||
|
||||
**解决方法:**
|
||||
|
||||
**方案 1: 启用 CGO 重新编译**
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
$env:CGO_ENABLED="1"
|
||||
go build -o software-download-center.exe .
|
||||
|
||||
# Linux/macOS
|
||||
export CGO_ENABLED=1
|
||||
go build -o software-download-center .
|
||||
```
|
||||
|
||||
**方案 2: 使用 MySQL(无需 CGO)**
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
$env:DB_TYPE="mysql"
|
||||
$env:DB_HOST="localhost"
|
||||
$env:DB_USER="root"
|
||||
$env:DB_PASSWORD="password"
|
||||
$env:DB_NAME="software_download_center"
|
||||
go run main.go
|
||||
|
||||
# Linux/macOS
|
||||
export DB_TYPE=mysql
|
||||
export DB_HOST=localhost
|
||||
export DB_USER=root
|
||||
export DB_PASSWORD=password
|
||||
export DB_NAME=software_download_center
|
||||
go run main.go
|
||||
```
|
||||
|
||||
详细说明请查看 [CGO_FIX.md](./CGO_FIX.md)。
|
||||
|
||||
### 5. 依赖下载失败
|
||||
|
||||
**解决方法:**
|
||||
```bash
|
||||
# 设置 Go 代理(中国大陆)
|
||||
go env -w GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
# 或使用官方代理
|
||||
go env -w GOPROXY=https://proxy.golang.org,direct
|
||||
```
|
||||
|
||||
## 开发说明
|
||||
|
||||
### 添加新路由
|
||||
|
||||
在 `config/routes.go` 中的 `RegisterRoutes` 函数中添加新路由。
|
||||
|
||||
### 修改模板
|
||||
|
||||
模板文件位于 `views/` 目录,使用 Go 的 `html/template` 语法。
|
||||
|
||||
### 添加新的工具函数
|
||||
|
||||
在 `utils/` 目录下创建新的工具文件。
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
数据库使用 GORM 自动迁移,启动时会自动创建表结构。
|
||||
|
||||
## 部署说明
|
||||
|
||||
### 生产环境部署
|
||||
|
||||
1. **编译可执行文件**
|
||||
```bash
|
||||
go build -ldflags="-s -w" -o software-download-center .
|
||||
```
|
||||
|
||||
2. **复制必要文件**
|
||||
```bash
|
||||
# 复制静态资源
|
||||
cp -r public output/
|
||||
cp -r views output/
|
||||
```
|
||||
|
||||
3. **运行服务**
|
||||
```bash
|
||||
PORT=3355 ./software-download-center
|
||||
```
|
||||
|
||||
### 使用进程管理器
|
||||
|
||||
详细部署说明请查看 [BUILD.md](./BUILD.md)。
|
||||
|
||||
## 与 Node.js 版本的差异
|
||||
|
||||
1. **性能**: Go 版本具有更好的并发性能和更低的内存占用
|
||||
2. **部署**: Go 版本编译为单个可执行文件,部署更简单
|
||||
3. **UI**: Go 版本包含了一些 UI/UX 改进
|
||||
4. **功能**: 功能完全一致,100% 兼容
|
||||
5. **后台管理**: Go 版本新增了完整的后台管理系统
|
||||
|
||||
## 快速开始(完整流程)
|
||||
|
||||
```bash
|
||||
# 1. 检查 Go 版本
|
||||
go version
|
||||
|
||||
# 2. 进入项目目录
|
||||
cd go
|
||||
|
||||
# 3. 下载依赖
|
||||
go mod download
|
||||
|
||||
# 4. 整理依赖
|
||||
go mod tidy
|
||||
|
||||
# 5. 验证依赖
|
||||
go mod verify
|
||||
|
||||
# 6. 运行项目
|
||||
go run main.go
|
||||
|
||||
# 7. 访问应用
|
||||
# 浏览器打开: http://localhost:3355
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 作者
|
||||
|
||||
YMhut
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [QUICKSTART.md](./QUICKSTART.md) - 快速开始指南(推荐新手)
|
||||
- [ADMIN.md](./ADMIN.md) - 后台管理系统使用说明
|
||||
- [BUILD.md](./BUILD.md) - 打包编译详细说明
|
||||
- [CGO_FIX.md](./CGO_FIX.md) - CGO 问题解决方案(SQLite 相关)
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>YMhut Update Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2337
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "ymhut-update-admin",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"daisyui": "^5.0.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileJson,
|
||||
Lock,
|
||||
LogOut,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Shield,
|
||||
UserRound
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./styles.css";
|
||||
|
||||
type ApiResult<T> = T & {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type User = {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
is_admin: boolean;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
type ReleaseFile = {
|
||||
name: string;
|
||||
size: number;
|
||||
size_text: string;
|
||||
mod_time: string;
|
||||
url: string;
|
||||
kind: string;
|
||||
sha256?: string;
|
||||
};
|
||||
|
||||
type Manifest = {
|
||||
latestVersion?: string;
|
||||
channel?: string;
|
||||
createdAt?: string;
|
||||
published_at?: string;
|
||||
latest?: {
|
||||
version?: string;
|
||||
channel?: string;
|
||||
published_at?: string;
|
||||
fullInstaller?: ReleaseSummary;
|
||||
msix?: ReleaseSummary;
|
||||
};
|
||||
fullInstaller?: ReleaseSummary;
|
||||
msix?: ReleaseSummary;
|
||||
messages?: Record<string, string>;
|
||||
};
|
||||
|
||||
type ReleaseSummary = {
|
||||
fileName?: string;
|
||||
url?: string;
|
||||
sha256?: string;
|
||||
size?: number;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type SystemInfo = {
|
||||
users?: number;
|
||||
routes?: number;
|
||||
logs?: number;
|
||||
version?: string;
|
||||
server_time?: string;
|
||||
};
|
||||
|
||||
type LogEntry = {
|
||||
time: string;
|
||||
level: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<ApiResult<T>> {
|
||||
const response = await fetch(path, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
},
|
||||
...init
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error(data.message || data.error || response.statusText), {
|
||||
status: response.status,
|
||||
data
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [loginError, setLoginError] = useState("");
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const result = await api<{ user: User }>("/api/admin/me");
|
||||
setUser(result.user);
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSession();
|
||||
}, [refreshSession]);
|
||||
|
||||
async function handleLogin(username: string, password: string) {
|
||||
setLoginError("");
|
||||
try {
|
||||
const result = await api<{ user: User }>("/api/admin/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
setUser(result.user);
|
||||
} catch (error) {
|
||||
setLoginError(error instanceof Error ? error.message : "登录失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await api("/api/admin/logout", { method: "POST" });
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center p-6">
|
||||
<span className="loading loading-spinner loading-lg text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <UnauthorizedView error={loginError} onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
return <AdminDashboard user={user} onLogout={handleLogout} />;
|
||||
}
|
||||
|
||||
function UnauthorizedView({
|
||||
error,
|
||||
onLogin
|
||||
}: {
|
||||
error: string;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
}) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onLogin(username, password);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center px-4 py-10">
|
||||
<section className="surface w-full max-w-5xl rounded-lg p-5 lg:p-7">
|
||||
<div className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
||||
<div className="space-y-5">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-warning/15 text-warning">
|
||||
<Lock size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-warning">Unauthorized</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-normal text-base-content">未授权 / 请登录</h1>
|
||||
<p className="mt-3 max-w-xl text-base leading-7 text-base-content/70">
|
||||
后台 API 仅允许已认证管理员访问。登录成功后可管理完整安装包、MSIX 发布物、清单刷新和会话状态。
|
||||
</p>
|
||||
</div>
|
||||
<div className="alert border-warning/20 bg-warning/10 text-warning">
|
||||
<AlertTriangle size={18} />
|
||||
<span>未登录访问后台时只显示此授权边界,不会提前加载后台数据。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="rounded-lg border border-base-300 bg-base-100 p-5 shadow-sm" onSubmit={submit}>
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Shield size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">管理员登录</h2>
|
||||
<p className="text-sm text-base-content/60">使用已有管理员账号继续</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="form-control">
|
||||
<span className="label-text">用户名</span>
|
||||
<input
|
||||
className="input input-bordered mt-1"
|
||||
value={username}
|
||||
autoComplete="username"
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="form-control mt-3">
|
||||
<span className="label-text">密码</span>
|
||||
<input
|
||||
className="input input-bordered mt-1"
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="alert alert-error mt-4 py-2 text-sm">{error}</div>}
|
||||
<button className="btn btn-primary mt-5 w-full" disabled={submitting} type="submit">
|
||||
{submitting ? <span className="loading loading-spinner loading-sm" /> : <UserRound size={18} />}
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminDashboard({ user, onLogout }: { user: User; onLogout: () => Promise<void> }) {
|
||||
const [manifest, setManifest] = useState<Manifest | null>(null);
|
||||
const [files, setFiles] = useState<ReleaseFile[]>([]);
|
||||
const [system, setSystem] = useState<SystemInfo | null>(null);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const latest = manifest?.latest;
|
||||
const fullInstaller = latest?.fullInstaller ?? manifest?.fullInstaller;
|
||||
const msix = latest?.msix ?? manifest?.msix;
|
||||
const latestVersion = manifest?.latestVersion ?? latest?.version ?? fullInstaller?.version ?? "unknown";
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const [manifestResponse, fileResponse, systemResponse, logResponse] = await Promise.all([
|
||||
fetch("/update-info.json", { credentials: "include" }).then((response) => response.json()),
|
||||
api<{ files: ReleaseFile[] }>("/api/admin/releases/files"),
|
||||
api<SystemInfo>("/api/admin/system"),
|
||||
api<{ logs: LogEntry[] }>("/api/admin/logs?limit=8")
|
||||
]);
|
||||
setManifest(manifestResponse);
|
||||
setFiles(fileResponse.files ?? []);
|
||||
setSystem(systemResponse);
|
||||
setLogs(logResponse.logs ?? []);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "加载失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const metrics = useMemo(
|
||||
() => [
|
||||
{ label: "最新版本", value: latestVersion, icon: CheckCircle2 },
|
||||
{ label: "发布文件", value: String(files.length), icon: Download },
|
||||
{ label: "后台用户", value: String(system?.users ?? 0), icon: Shield },
|
||||
{ label: "日志数量", value: String(system?.logs ?? 0), icon: Server }
|
||||
],
|
||||
[files.length, latestVersion, system?.logs, system?.users]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="admin-sidebar p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary text-primary-content">
|
||||
<Boxes size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">YMhut Update</h1>
|
||||
<p className="text-xs text-base-content/60">Full installer / MSIX</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="menu mt-6 rounded-lg bg-base-100 p-2">
|
||||
<li>
|
||||
<a className="active">
|
||||
<Server size={16} />
|
||||
发布概览
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/update-info.json">
|
||||
<FileJson size={16} />
|
||||
清单 JSON
|
||||
</a>
|
||||
</li>
|
||||
</nav>
|
||||
|
||||
<div className="mt-6 rounded-lg border border-base-300 bg-base-100 p-3">
|
||||
<p className="text-xs text-base-content/60">当前会话</p>
|
||||
<p className="mt-1 font-medium">{user.username}</p>
|
||||
<p className="text-xs text-base-content/60">{user.is_admin ? "Administrator" : "User"}</p>
|
||||
<button className="btn btn-ghost btn-sm mt-3 w-full justify-start" onClick={() => void onLogout()}>
|
||||
<LogOut size={16} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="admin-main">
|
||||
<header className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-primary">Admin</p>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">更新发布后台</h2>
|
||||
</div>
|
||||
<div className="join">
|
||||
<button className="btn join-item" onClick={() => void refresh()} disabled={busy}>
|
||||
{busy ? <span className="loading loading-spinner loading-sm" /> : <RefreshCw size={17} />}
|
||||
刷新
|
||||
</button>
|
||||
<a className="btn btn-primary join-item" href="/" target="_blank" rel="noreferrer">
|
||||
前台
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="alert alert-error mb-4">{error}</div>}
|
||||
|
||||
<section className="metric-grid mb-4">
|
||||
{metrics.map((metric) => (
|
||||
<article className="surface rounded-lg p-4" key={metric.label}>
|
||||
<metric.icon className="mb-3 text-primary" size={20} />
|
||||
<p className="text-sm text-base-content/60">{metric.label}</p>
|
||||
<p className="mt-1 truncate text-xl font-semibold">{metric.value}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="content-grid">
|
||||
<div className="space-y-4">
|
||||
<Panel title="发布物" subtitle="downloads 白名单安装产物">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件</th>
|
||||
<th>类型</th>
|
||||
<th>大小</th>
|
||||
<th>更新时间</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file) => (
|
||||
<tr key={file.name}>
|
||||
<td className="max-w-[280px] truncate">{file.name}</td>
|
||||
<td>
|
||||
<span className="badge badge-outline rounded-md">{file.kind}</span>
|
||||
</td>
|
||||
<td>{file.size_text}</td>
|
||||
<td>{file.mod_time}</td>
|
||||
<td>
|
||||
<a className="btn btn-ghost btn-xs" href={file.url}>
|
||||
下载
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{files.length === 0 && (
|
||||
<tr>
|
||||
<td className="text-base-content/60" colSpan={5}>
|
||||
暂无发布文件
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="清单摘要" subtitle="公开清单仅包含完整安装包和 MSIX">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<SummaryCard title="完整安装包" item={fullInstaller} />
|
||||
<SummaryCard title="MSIX" item={msix} />
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Panel title="服务状态" subtitle={system?.server_time ?? "loading"}>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
<Info label="版本" value={system?.version ?? "-"} />
|
||||
<Info label="路由" value={String(system?.routes ?? 0)} />
|
||||
<Info label="用户" value={String(system?.users ?? 0)} />
|
||||
<Info label="日志" value={String(system?.logs ?? 0)} />
|
||||
</dl>
|
||||
</Panel>
|
||||
|
||||
<Panel title="最近日志" subtitle="后台运行事件">
|
||||
<div className="space-y-2">
|
||||
{logs.map((log) => (
|
||||
<div className="rounded-md bg-base-200/80 p-2 text-sm" key={`${log.time}-${log.message}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{log.level}</span>
|
||||
<span className="text-xs text-base-content/50">{log.time}</span>
|
||||
</div>
|
||||
<p className="mt-1 break-words text-base-content/70">{log.message}</p>
|
||||
</div>
|
||||
))}
|
||||
{logs.length === 0 && <p className="text-sm text-base-content/60">暂无日志</p>}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="surface rounded-lg p-4">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
{subtitle && <p className="text-sm text-base-content/60">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ title, item }: { title: string; item?: ReleaseSummary }) {
|
||||
return (
|
||||
<article className="rounded-lg border border-base-300 bg-base-100 p-3">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="mt-2 truncate text-sm text-base-content/70">{item?.fileName ?? "未配置"}</p>
|
||||
<p className="mono mt-2 truncate text-xs text-base-content/50">{item?.sha256 ?? "-"}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md bg-base-200/80 p-3">
|
||||
<dt className="text-base-content/55">{label}</dt>
|
||||
<dd className="mt-1 font-medium">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -0,0 +1,91 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui" {
|
||||
themes: light --default, dark --prefersdark;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "Segoe UI Variable", "Segoe UI", system-ui, sans-serif;
|
||||
background: #f5f7fb;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(244, 247, 251, 0.96)),
|
||||
#f5f7fb;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
min-width: 0;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.surface {
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 16px 42px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.28);
|
||||
}
|
||||
|
||||
.metric-grid,
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-main {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/admin/",
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
@echo off
|
||||
REM Windows 编译脚本
|
||||
|
||||
echo 开始编译 Go 项目...
|
||||
|
||||
set APP_NAME=software-download-center
|
||||
set BUILD_DIR=build
|
||||
set OUTPUT_DIR=%BUILD_DIR%\output
|
||||
|
||||
REM 创建输出目录
|
||||
if not exist %OUTPUT_DIR% mkdir %OUTPUT_DIR%
|
||||
|
||||
echo 编译 Windows 版本...
|
||||
set GOOS=windows
|
||||
set GOARCH=amd64
|
||||
go build -ldflags="-s -w" -o %OUTPUT_DIR%\%APP_NAME%_windows_amd64.exe .
|
||||
|
||||
echo 编译 Linux 版本...
|
||||
set GOOS=linux
|
||||
set GOARCH=amd64
|
||||
go build -ldflags="-s -w" -o %OUTPUT_DIR%\%APP_NAME%_linux_amd64.exe .
|
||||
|
||||
echo 编译 macOS 版本...
|
||||
set GOOS=darwin
|
||||
set GOARCH=amd64
|
||||
go build -ldflags="-s -w" -o %OUTPUT_DIR%\%APP_NAME%_darwin_amd64.exe .
|
||||
set GOARCH=arm64
|
||||
go build -ldflags="-s -w" -o %OUTPUT_DIR%\%APP_NAME%_darwin_arm64.exe .
|
||||
|
||||
REM 复制必要文件
|
||||
echo 复制必要文件...
|
||||
xcopy /E /I /Y public %OUTPUT_DIR%\public
|
||||
xcopy /E /I /Y views %OUTPUT_DIR%\views
|
||||
copy README.md %OUTPUT_DIR% >nul 2>&1
|
||||
copy ADMIN.md %OUTPUT_DIR% >nul 2>&1
|
||||
|
||||
REM 创建启动脚本
|
||||
echo @echo off > %OUTPUT_DIR%\start.bat
|
||||
echo REM Windows 启动脚本 >> %OUTPUT_DIR%\start.bat
|
||||
echo. >> %OUTPUT_DIR%\start.bat
|
||||
echo set PORT=3355 >> %OUTPUT_DIR%\start.bat
|
||||
echo if not "%%PORT%%"=="" set PORT=%%PORT%% >> %OUTPUT_DIR%\start.bat
|
||||
echo echo 启动服务器,端口: %%PORT%% >> %OUTPUT_DIR%\start.bat
|
||||
echo set PORT=%%PORT%% >> %OUTPUT_DIR%\start.bat
|
||||
echo %APP_NAME%_windows_amd64.exe >> %OUTPUT_DIR%\start.bat
|
||||
|
||||
echo.
|
||||
echo 编译完成!
|
||||
echo 输出目录: %OUTPUT_DIR%
|
||||
echo.
|
||||
echo 使用方法:
|
||||
echo Windows: 运行 start.bat 或直接运行 .exe 文件
|
||||
echo Linux: 运行 ./start.sh 或直接运行可执行文件
|
||||
echo macOS: 运行 ./start.sh 或直接运行可执行文件
|
||||
|
||||
pause
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 编译脚本 - 打包成服务端可执行文件
|
||||
|
||||
echo "开始编译 Go 项目..."
|
||||
|
||||
# 设置编译参数
|
||||
APP_NAME="software-download-center"
|
||||
VERSION=$(date +%Y%m%d_%H%M%S)
|
||||
BUILD_DIR="build"
|
||||
OUTPUT_DIR="${BUILD_DIR}/output"
|
||||
|
||||
# 创建输出目录
|
||||
mkdir -p ${OUTPUT_DIR}
|
||||
|
||||
# 编译不同平台的可执行文件
|
||||
echo "编译 Windows 版本..."
|
||||
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o ${OUTPUT_DIR}/${APP_NAME}_windows_amd64.exe .
|
||||
|
||||
echo "编译 Linux 版本..."
|
||||
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ${OUTPUT_DIR}/${APP_NAME}_linux_amd64 .
|
||||
|
||||
echo "编译 macOS 版本..."
|
||||
GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o ${OUTPUT_DIR}/${APP_NAME}_darwin_amd64 .
|
||||
GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o ${OUTPUT_DIR}/${APP_NAME}_darwin_arm64 .
|
||||
|
||||
# 复制必要文件
|
||||
echo "复制必要文件..."
|
||||
cp -r public ${OUTPUT_DIR}/ 2>/dev/null || true
|
||||
cp -r views ${OUTPUT_DIR}/ 2>/dev/null || true
|
||||
cp README.md ${OUTPUT_DIR}/ 2>/dev/null || true
|
||||
cp ADMIN.md ${OUTPUT_DIR}/ 2>/dev/null || true
|
||||
|
||||
# 创建启动脚本
|
||||
cat > ${OUTPUT_DIR}/start.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Linux/macOS 启动脚本
|
||||
|
||||
PORT=${PORT:-3355}
|
||||
echo "启动服务器,端口: $PORT"
|
||||
export PORT=$PORT
|
||||
./software-download-center_linux_amd64
|
||||
EOF
|
||||
|
||||
cat > ${OUTPUT_DIR}/start.bat << 'EOF'
|
||||
@echo off
|
||||
REM Windows 启动脚本
|
||||
|
||||
set PORT=3355
|
||||
if not "%PORT%"=="" set PORT=%PORT%
|
||||
echo 启动服务器,端口: %PORT%
|
||||
set PORT=%PORT%
|
||||
software-download-center_windows_amd64.exe
|
||||
EOF
|
||||
|
||||
chmod +x ${OUTPUT_DIR}/start.sh
|
||||
chmod +x ${OUTPUT_DIR}/*.exe 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "编译完成!"
|
||||
echo "输出目录: ${OUTPUT_DIR}"
|
||||
echo ""
|
||||
echo "可执行文件列表:"
|
||||
ls -lh ${OUTPUT_DIR}/*.exe ${OUTPUT_DIR}/*_linux_amd64 ${OUTPUT_DIR}/*_darwin_* 2>/dev/null | awk '{print $9, "(" $5 ")"}'
|
||||
echo ""
|
||||
echo "使用方法:"
|
||||
echo " Windows: 运行 start.bat 或直接运行 .exe 文件"
|
||||
echo " Linux: 运行 ./start.sh 或直接运行可执行文件"
|
||||
echo " macOS: 运行 ./start.sh 或直接运行可执行文件"
|
||||
@@ -0,0 +1,316 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"software-download-center/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestUpdateInfoContainsOnlyFullInstallerAndMSIX(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rootDir := t.TempDir()
|
||||
publicDir := filepath.Join(rootDir, "public")
|
||||
downloadsDir := filepath.Join(publicDir, "downloads")
|
||||
viewsDir := filepath.Join(rootDir, "views")
|
||||
mustMkdirAll(t, downloadsDir)
|
||||
mustMkdirAll(t, viewsDir)
|
||||
writeTestFile(t, downloadsDir, "YMhut_Box_WinUI_Setup_2.0.7.0.exe", "installer")
|
||||
writeTestFile(t, downloadsDir, "YMhut_Box_WinUI_2.0.7.0_x64.msix", "msix")
|
||||
writeTestFile(t, downloadsDir, "winui.appinstaller", "appinstaller")
|
||||
writeTestFile(t, downloadsDir, "YMhut_Box_WinUI_Setup_2.0.7.0_Light.exe", "light")
|
||||
writeTestFile(t, filepath.Join(downloadsDir, "incremental"), "YMhut_Box_Update_2.0.6.0_to_2.0.7.0.zip", "incremental")
|
||||
writeTestFile(t, publicDir, "update-info.json", `{
|
||||
"baselineVersion": "2.0.6.0",
|
||||
"minIncrementalVersion": "2.0.6.0",
|
||||
"lightInstaller": {"fileName":"light.exe"},
|
||||
"packages": [{"id":"external-tools"}],
|
||||
"incrementals": [{"id":"delta"}],
|
||||
"messages": {"static":"kept"}
|
||||
}`)
|
||||
writeTemplateFiles(t, viewsDir)
|
||||
|
||||
router := buildTestRouter(t, rootDir)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest("GET", "/update-info.json", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected update-info 200, got %d: %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertAbsent(t, body, "baselineVersion", "minIncrementalVersion", "lightInstaller", "packages", "incrementals", "requiredForFull", "modules")
|
||||
if body["latestVersion"] != "2.0.7.0" {
|
||||
t.Fatalf("expected latestVersion 2.0.7.0, got %#v", body["latestVersion"])
|
||||
}
|
||||
|
||||
latest := body["latest"].(map[string]interface{})
|
||||
assertAbsent(t, latest, "lightInstaller", "incrementals", "packages")
|
||||
fullInstaller := latest["fullInstaller"].(map[string]interface{})
|
||||
if fullInstaller["fileName"] != "YMhut_Box_WinUI_Setup_2.0.7.0.exe" {
|
||||
t.Fatalf("unexpected full installer: %#v", fullInstaller)
|
||||
}
|
||||
msix := latest["msix"].(map[string]interface{})
|
||||
if msix["fileName"] != "YMhut_Box_WinUI_2.0.7.0_x64.msix" {
|
||||
t.Fatalf("unexpected msix: %#v", msix)
|
||||
}
|
||||
appInstaller := latest["appInstaller"].(map[string]interface{})
|
||||
if appInstaller["fileName"] != "winui.appinstaller" {
|
||||
t.Fatalf("unexpected appinstaller: %#v", appInstaller)
|
||||
}
|
||||
|
||||
text := recorder.Body.String()
|
||||
for _, forbidden := range []string{"_Light.exe", "YMhut_Box_Update_", "external-tools", "baselineVersion", "incrementals"} {
|
||||
if strings.Contains(text, forbidden) {
|
||||
t.Fatalf("update-info leaked removed field/content %q: %s", forbidden, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovedDistributionRoutesReturnGone(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rootDir := t.TempDir()
|
||||
publicDir := filepath.Join(rootDir, "public")
|
||||
downloadsDir := filepath.Join(publicDir, "downloads")
|
||||
viewsDir := filepath.Join(rootDir, "views")
|
||||
mustMkdirAll(t, downloadsDir)
|
||||
mustMkdirAll(t, viewsDir)
|
||||
writeTemplateFiles(t, viewsDir)
|
||||
|
||||
router := buildTestRouter(t, rootDir)
|
||||
|
||||
for _, route := range []string{
|
||||
"/modules.json",
|
||||
"/api/modules",
|
||||
"/package-manifest.json",
|
||||
"/incremental-manifest.json",
|
||||
"/api/packages/manifest",
|
||||
"/api/incrementals",
|
||||
"/api/incrementals/manifest",
|
||||
"/api/packages/download/YMhut_Box_Tools_2.0.6.0.zip",
|
||||
"/api/incrementals/download/YMhut_Box_Update_2.0.6.0_to_2.0.7.0.zip",
|
||||
"/packages/YMhut_Box_Tools_2.0.6.0.zip",
|
||||
"/tool-packages/YMhut_Box_Tools_2.0.6.0.zip",
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest("GET", route, nil))
|
||||
if recorder.Code != http.StatusGone {
|
||||
t.Fatalf("expected %s to return 410, got %d: %s", route, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalUpdateInfoRoutesServeDirectlyAndLegacyRoutesRedirect(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rootDir := t.TempDir()
|
||||
publicDir := filepath.Join(rootDir, "public")
|
||||
downloadsDir := filepath.Join(publicDir, "downloads")
|
||||
viewsDir := filepath.Join(rootDir, "views")
|
||||
mustMkdirAll(t, downloadsDir)
|
||||
mustMkdirAll(t, viewsDir)
|
||||
writeTemplateFiles(t, viewsDir)
|
||||
|
||||
router := buildTestRouter(t, rootDir)
|
||||
|
||||
for _, route := range []string{"/update-info.json", "/update-info", "/api/update-info"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest("GET", route, nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected %s to return 200, got %d: %s", route, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
manifestRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(manifestRecorder, httptest.NewRequest("GET", "/manifest.json", nil))
|
||||
if manifestRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected /manifest.json compatibility response 200, got %d: %s", manifestRecorder.Code, manifestRecorder.Body.String())
|
||||
}
|
||||
if manifestRecorder.Header().Get("Deprecation") != "true" {
|
||||
t.Fatalf("expected /manifest.json to include Deprecation header")
|
||||
}
|
||||
if manifestRecorder.Header().Get("X-YMhut-Canonical-Manifest") != "/update-info.json" {
|
||||
t.Fatalf("expected /manifest.json to point at /update-info.json")
|
||||
}
|
||||
|
||||
for _, route := range []string{"/latest-version.json", "/api/releases/latest", "/latest.json"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest("GET", route, nil))
|
||||
if recorder.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("expected %s to redirect with 301, got %d", route, recorder.Code)
|
||||
}
|
||||
if location := recorder.Header().Get("Location"); location != "/update-info.json" {
|
||||
t.Fatalf("expected %s Location /update-info.json, got %q", route, location)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadsRouteAllowsOnlySingleInstallerArtifact(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rootDir := t.TempDir()
|
||||
publicDir := filepath.Join(rootDir, "public")
|
||||
downloadsDir := filepath.Join(publicDir, "downloads")
|
||||
viewsDir := filepath.Join(rootDir, "views")
|
||||
mustMkdirAll(t, downloadsDir)
|
||||
mustMkdirAll(t, viewsDir)
|
||||
writeTestFile(t, downloadsDir, "YMhut_Box_WinUI_Setup_2.0.7.0.exe", "installer")
|
||||
writeTestFile(t, downloadsDir, "YMhut_Box_WinUI_Setup_2.0.7.0_Light.exe", "light")
|
||||
writeTestFile(t, filepath.Join(downloadsDir, "incremental"), "YMhut_Box_Update_2.0.6.0_to_2.0.7.0.zip", "incremental")
|
||||
writeTemplateFiles(t, viewsDir)
|
||||
|
||||
router := buildTestRouter(t, rootDir)
|
||||
|
||||
okRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(okRecorder, httptest.NewRequest("GET", "/downloads/YMhut_Box_WinUI_Setup_2.0.7.0.exe", nil))
|
||||
if okRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected full installer download 200, got %d: %s", okRecorder.Code, okRecorder.Body.String())
|
||||
}
|
||||
if okRecorder.Body.String() != "installer" {
|
||||
t.Fatalf("unexpected installer body: %q", okRecorder.Body.String())
|
||||
}
|
||||
|
||||
for _, route := range []string{
|
||||
"/downloads/YMhut_Box_WinUI_Setup_2.0.7.0_Light.exe",
|
||||
"/downloads/incremental/YMhut_Box_Update_2.0.6.0_to_2.0.7.0.zip",
|
||||
"/downloads/..%2Fsecret.exe",
|
||||
"/downloads/subdir/file.exe",
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest("GET", route, nil))
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected %s to be forbidden, got %d: %s", route, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIRequiresAuthentication(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rootDir := t.TempDir()
|
||||
publicDir := filepath.Join(rootDir, "public")
|
||||
downloadsDir := filepath.Join(publicDir, "downloads")
|
||||
viewsDir := filepath.Join(rootDir, "views")
|
||||
mustMkdirAll(t, downloadsDir)
|
||||
mustMkdirAll(t, viewsDir)
|
||||
writeTemplateFiles(t, viewsDir)
|
||||
|
||||
router := buildTestRouter(t, rootDir)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest("GET", "/api/admin/releases/files", nil))
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for unauthenticated admin API, got %d: %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "UNAUTHORIZED") {
|
||||
t.Fatalf("expected structured unauthorized response, got %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPageShowsUnauthorizedShellWithoutAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rootDir := t.TempDir()
|
||||
publicDir := filepath.Join(rootDir, "public")
|
||||
downloadsDir := filepath.Join(publicDir, "downloads")
|
||||
viewsDir := filepath.Join(rootDir, "views")
|
||||
mustMkdirAll(t, downloadsDir)
|
||||
mustMkdirAll(t, viewsDir)
|
||||
writeTemplateFiles(t, viewsDir)
|
||||
|
||||
router := buildTestRouter(t, rootDir)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest("GET", "/admin/", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected admin shell 200, got %d: %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "未授权") {
|
||||
t.Fatalf("expected admin shell fallback to show unauthorized copy, got %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparePackageFileNamesSupportsNewAndLegacyInstallerNames(t *testing.T) {
|
||||
if comparePackageFileNames("YMhut_Box_WinUI_Setup_2.0.7.0.exe", "YMhut_Box_Setup_2.0.6.0.exe") <= 0 {
|
||||
t.Fatal("expected WinUI 2.0.7.0 installer to be newer than legacy 2.0.6.0 installer")
|
||||
}
|
||||
if comparePackageFileNames("YMhut_Box_Setup_2.0.7.0.exe", "YMhut_Box_WinUI_Setup_2.0.7.0.exe") == 0 {
|
||||
t.Fatal("expected stable tie-breaker for same-version installer names")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductsInfoRecognizesMSIXAndSkipsIncrementalSubdirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestFile(t, dir, "YMhut_Box_WinUI_2.0.7.0_x64.msix", "msix")
|
||||
writeTestFile(t, filepath.Join(dir, "incremental"), "YMhut_Box_Update_2.0.6.0_to_2.0.7.0.zip", "incremental")
|
||||
|
||||
products := utils.GetProductsInfo(dir, utils.NewLogger())
|
||||
if len(products) == 0 {
|
||||
t.Fatalf("expected MSIX package to be detected")
|
||||
}
|
||||
for _, releases := range products {
|
||||
for _, release := range releases {
|
||||
if release.FileName == "YMhut_Box_Update_2.0.6.0_to_2.0.7.0.zip" {
|
||||
t.Fatalf("incremental package from subdirectory should not be listed as product: %#v", products)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildTestRouter(t *testing.T, rootDir string) *gin.Engine {
|
||||
t.Helper()
|
||||
oldWorkingDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(rootDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(oldWorkingDir)
|
||||
})
|
||||
|
||||
router := gin.New()
|
||||
RegisterRoutes(router, utils.NewLogger())
|
||||
return router
|
||||
}
|
||||
|
||||
func writeTemplateFiles(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
writeTestFile(t, dir, "index.html", "{{.pageTitle}}")
|
||||
writeTestFile(t, dir, "404.html", "{{.title}}")
|
||||
writeTestFile(t, dir, "500.html", "{{.title}}")
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, dir string, name string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMkdirAll(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAbsent(t *testing.T, body map[string]interface{}, keys ...string) {
|
||||
t.Helper()
|
||||
for _, key := range keys {
|
||||
if _, ok := body[key]; ok {
|
||||
t.Fatalf("expected field %s to be absent in %#v", key, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"software-download-center/database"
|
||||
"software-download-center/handlers"
|
||||
"software-download-center/middleware"
|
||||
"software-download-center/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ProductMeta struct {
|
||||
Icon string
|
||||
Description string
|
||||
ThemeColor string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
var (
|
||||
productMeta = map[string]ProductMeta{
|
||||
"YMhut Box": {
|
||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
|
||||
Description: "多功能工具箱,整合系统、网络、图像和日常效率工具。",
|
||||
ThemeColor: "#166534",
|
||||
Tags: []string{"桌面工具", "效率", "多平台"},
|
||||
},
|
||||
"YmhutBox": {
|
||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
|
||||
Description: "多功能工具箱,整合系统、网络、图像和日常效率工具。",
|
||||
ThemeColor: "#166534",
|
||||
Tags: []string{"桌面工具", "效率", "多平台"},
|
||||
},
|
||||
"弓福小筑": {
|
||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>`,
|
||||
Description: "轻量入口应用,提供站点访问与定制下载入口。",
|
||||
ThemeColor: "#b45309",
|
||||
Tags: []string{"轻量", "入口", "桌面"},
|
||||
},
|
||||
}
|
||||
|
||||
defaultMeta = ProductMeta{
|
||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2v20"/><path d="M2 12h20"/></svg>`,
|
||||
Description: "自动从 downloads 目录识别出的安装包分组。",
|
||||
ThemeColor: "#57534e",
|
||||
Tags: []string{"自动识别", "安装包", "下载"},
|
||||
}
|
||||
)
|
||||
|
||||
func RegisterRoutes(r *gin.Engine, logger *utils.Logger) {
|
||||
logger.System("\n开始注册路由...\n")
|
||||
|
||||
rootDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("获取工作目录失败: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
publicDir := filepath.Join(rootDir, "public")
|
||||
viewsDir := filepath.Join(rootDir, "views")
|
||||
downloadsDir := filepath.Join(publicDir, "downloads")
|
||||
|
||||
r.SetFuncMap(template.FuncMap{
|
||||
"safeHTML": func(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
},
|
||||
"marshalJSON": func(v interface{}) string {
|
||||
data, _ := json.Marshal(v)
|
||||
return string(data)
|
||||
},
|
||||
"slice": func(slice interface{}, start int, args ...int) interface{} {
|
||||
v := reflect.ValueOf(slice)
|
||||
if v.Kind() != reflect.Slice {
|
||||
return slice
|
||||
}
|
||||
end := v.Len()
|
||||
if len(args) > 0 {
|
||||
end = args[0]
|
||||
}
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end > v.Len() {
|
||||
end = v.Len()
|
||||
}
|
||||
if start >= end {
|
||||
return reflect.MakeSlice(v.Type(), 0, 0).Interface()
|
||||
}
|
||||
return v.Slice(start, end).Interface()
|
||||
},
|
||||
})
|
||||
r.LoadHTMLGlob(filepath.Join(viewsDir, "*.html"))
|
||||
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
products := utils.GetProductsInfo(downloadsDir, logger)
|
||||
|
||||
errorMessage := ""
|
||||
if products == nil {
|
||||
errorMessage = "无法读取 downloads 目录,请检查目录权限和文件配置。"
|
||||
} else if len(products) == 0 {
|
||||
errorMessage = "downloads 目录中暂时没有可识别的安装包。"
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"products": products,
|
||||
"productMeta": productMeta,
|
||||
"defaultMeta": defaultMeta,
|
||||
"pageTitle": "YMhut 下载中心",
|
||||
"errorMessage": errorMessage,
|
||||
})
|
||||
})
|
||||
logger.Info("注册路由成功 [GET] /")
|
||||
|
||||
registerDynamicUpdateInfoRoutes(r, logger, publicDir, downloadsDir)
|
||||
registerReleaseAPIRoutes(r, logger, publicDir, downloadsDir)
|
||||
|
||||
jsonRoutes := []struct {
|
||||
path string
|
||||
file string
|
||||
cacheControl string
|
||||
}{
|
||||
{"/tool-status.json", "tool-status.json", "public, max-age=600"},
|
||||
{"/tool-status", "tool-status.json", "public, max-age=600"},
|
||||
{"/media-types.json", "media-types.json", "public, max-age=3600"},
|
||||
{"/media-types", "media-types.json", "public, max-age=3600"},
|
||||
{"/plugins", "plugins.json", "public, max-age=3600"},
|
||||
{"/plugins.json", "plugins.json", "public, max-age=3600"},
|
||||
{"/modules", "modules.json", "public, max-age=600"},
|
||||
{"/modules.json", "modules.json", "public, max-age=600"},
|
||||
}
|
||||
|
||||
for _, route := range jsonRoutes {
|
||||
filePath := filepath.Join(publicDir, route.file)
|
||||
fp := filePath
|
||||
cc := route.cacheControl
|
||||
path := route.path
|
||||
fileName := route.file
|
||||
if utils.FileExists(filePath) {
|
||||
r.GET(path, func(c *gin.Context) {
|
||||
if cached, ok := utils.GetCachedConfig(fileName); ok {
|
||||
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||
c.Header("Cache-Control", cc)
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := utils.ReadJSONFile(fp)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("读取 JSON 文件失败: %s - %s", fp, err.Error()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "文件读取失败"})
|
||||
return
|
||||
}
|
||||
|
||||
utils.SaveConfig(fileName, data)
|
||||
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||
c.Header("Cache-Control", cc)
|
||||
c.JSON(http.StatusOK, data)
|
||||
})
|
||||
logger.Info(fmt.Sprintf("注册路由成功 [GET] %s", path))
|
||||
} else {
|
||||
logger.Warn(fmt.Sprintf("跳过路由,文件不存在: %s", path))
|
||||
}
|
||||
}
|
||||
|
||||
fileRoutes := []struct {
|
||||
path string
|
||||
file string
|
||||
cacheControl string
|
||||
headers map[string]string
|
||||
}{
|
||||
{"/lang/zh-CN.json", "lang/zh-CN.json", "public, max-age=86400", map[string]string{"Content-Disposition": `attachment; filename="zh-CN.json"`}},
|
||||
{"/lang/en-US.json", "lang/en-US.json", "public, max-age=86400", map[string]string{"Content-Disposition": `attachment; filename="en-US.json"`}},
|
||||
{"/fonts/MeiGanShouXieTi-2.ttf", "fonts/MeiGanShouXieTi-2.ttf", "public, max-age=86400", map[string]string{"Content-Disposition": `attachment; filename="MeiGanShouXieTi-2.ttf"`}},
|
||||
{"/fonts/QianTuBiFengShouXieTi-2.ttf", "fonts/QianTuBiFengShouXieTi-2.ttf", "public, max-age=86400", map[string]string{"Content-Disposition": `attachment; filename="QianTuBiFengShouXieTi-2.ttf"`}},
|
||||
{"/fonts/YOzBS-2.otf", "fonts/YOzBS-2.otf", "public, max-age=86400", map[string]string{"Content-Disposition": `attachment; filename="YOzBS-2.otf"`}},
|
||||
{"/favicon.ico", "img/favicon.png", "public, max-age=604800", nil},
|
||||
}
|
||||
|
||||
for _, route := range fileRoutes {
|
||||
filePath := filepath.Join(publicDir, route.file)
|
||||
fp := filePath
|
||||
cc := route.cacheControl
|
||||
hdrs := route.headers
|
||||
path := route.path
|
||||
if utils.FileExists(filePath) {
|
||||
r.GET(path, func(c *gin.Context) {
|
||||
mimeType := utils.GetMimeType(fp)
|
||||
c.Header("Content-Type", mimeType)
|
||||
c.Header("Cache-Control", cc)
|
||||
if hdrs != nil {
|
||||
for k, v := range hdrs {
|
||||
c.Header(k, v)
|
||||
}
|
||||
}
|
||||
c.File(fp)
|
||||
})
|
||||
logger.Info(fmt.Sprintf("注册路由成功 [GET] %s", path))
|
||||
} else {
|
||||
logger.Warn(fmt.Sprintf("跳过路由,文件不存在: %s", path))
|
||||
}
|
||||
}
|
||||
|
||||
r.Static("/css", filepath.Join(publicDir, "css"))
|
||||
r.Static("/img", filepath.Join(publicDir, "img"))
|
||||
|
||||
r.GET("/downloads/:filename", func(c *gin.Context) {
|
||||
filename := c.Param("filename")
|
||||
if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
|
||||
logger.Warn(fmt.Sprintf("拒绝下载请求: %s (IP: %s)", filename, c.ClientIP()))
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "禁止访问"})
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(downloadsDir, filename)
|
||||
resolvedPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("解析文件路径失败: %s", err.Error()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "内部错误"})
|
||||
return
|
||||
}
|
||||
|
||||
normalizedDir, _ := filepath.Abs(downloadsDir)
|
||||
if !strings.HasPrefix(resolvedPath, normalizedDir) {
|
||||
logger.Warn(fmt.Sprintf("下载路径越界: %s (IP: %s)", filename, c.ClientIP()))
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "禁止访问"})
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.FileExists(filePath) {
|
||||
logger.Error(fmt.Sprintf("下载失败,文件不存在: %s", filename))
|
||||
c.HTML(http.StatusNotFound, "404.html", gin.H{
|
||||
"title": "文件未找到",
|
||||
"path": c.Request.URL.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info(fmt.Sprintf("下载成功: %s (IP: %s)", filename, c.ClientIP()))
|
||||
c.File(filePath)
|
||||
})
|
||||
|
||||
r.GET("/admin", middleware.AuthMiddleware(), func(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "admin.html", gin.H{"title": "后台管理"})
|
||||
})
|
||||
|
||||
r.GET("/admin/login", func(c *gin.Context) {
|
||||
if token, _ := c.Cookie("token"); token != "" {
|
||||
c.Redirect(http.StatusFound, "/admin")
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "login.html", gin.H{"title": "登录"})
|
||||
})
|
||||
|
||||
r.GET("/admin/register", func(c *gin.Context) {
|
||||
if token, _ := c.Cookie("token"); token != "" {
|
||||
c.Redirect(http.StatusFound, "/admin")
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "register.html", gin.H{"title": "注册"})
|
||||
})
|
||||
|
||||
r.GET("/admin/settings", middleware.AuthMiddleware(), middleware.AdminMiddleware(), func(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "settings.html", gin.H{"title": "系统设置"})
|
||||
})
|
||||
|
||||
r.GET("/admin/install", func(c *gin.Context) {
|
||||
if database.IsDBInitialized() {
|
||||
c.Redirect(http.StatusFound, "/admin/login")
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "install.html", gin.H{"title": "数据库配置"})
|
||||
})
|
||||
|
||||
r.GET("/admin/install/status", handlers.CheckInstallStatus)
|
||||
r.POST("/admin/install/database", handlers.InstallDatabase)
|
||||
|
||||
adminRoutes := r.Group("/admin")
|
||||
{
|
||||
adminRoutes.POST("/register", handlers.Register)
|
||||
adminRoutes.POST("/login", handlers.Login)
|
||||
adminRoutes.POST("/logout", handlers.Logout)
|
||||
adminRoutes.GET("/me", middleware.AuthMiddleware(), handlers.GetCurrentUser)
|
||||
|
||||
adminAPI := adminRoutes.Group("/api")
|
||||
adminAPI.Use(middleware.AuthMiddleware(), middleware.AdminMiddleware())
|
||||
{
|
||||
adminAPI.GET("/logs", handlers.GetLogs)
|
||||
adminAPI.GET("/routes", handlers.GetRoutes)
|
||||
adminAPI.POST("/routes", handlers.CreateRoute)
|
||||
adminAPI.PUT("/routes/:id", handlers.UpdateRoute)
|
||||
adminAPI.DELETE("/routes/:id", handlers.DeleteRoute)
|
||||
|
||||
adminAPI.GET("/files", handlers.GetFiles)
|
||||
adminAPI.GET("/file", handlers.ReadFile)
|
||||
adminAPI.POST("/file", handlers.SaveFile)
|
||||
|
||||
adminAPI.PUT("/config", handlers.UpdateJSONConfig)
|
||||
adminAPI.GET("/system", handlers.GetSystemInfo)
|
||||
|
||||
adminAPI.GET("/database", handlers.GetDatabaseInfo)
|
||||
adminAPI.GET("/database/config", handlers.GetDatabaseConfig)
|
||||
adminAPI.POST("/database/config", handlers.UpdateDatabaseConfig)
|
||||
adminAPI.POST("/database/convert", handlers.ConvertDatabase)
|
||||
adminAPI.POST("/database/password", handlers.UpdateDatabasePassword)
|
||||
|
||||
adminAPI.POST("/reload", handlers.ReloadRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
fullURL := c.Request.URL.String()
|
||||
logger.Warn(fmt.Sprintf("404 Not Found - %s", fullURL))
|
||||
c.HTML(http.StatusNotFound, "404.html", gin.H{
|
||||
"title": "页面未找到",
|
||||
"path": fullURL,
|
||||
})
|
||||
})
|
||||
|
||||
r.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
||||
logger.Error(fmt.Sprintf("500 Server Error - 路径: %s - 错误: %v", c.Request.URL.Path, recovered))
|
||||
c.HTML(http.StatusInternalServerError, "500.html", gin.H{
|
||||
"title": "服务器错误",
|
||||
"message": "服务器内部错误,请稍后重试。",
|
||||
})
|
||||
c.Abort()
|
||||
}))
|
||||
|
||||
logger.System("路由注册完成。\n")
|
||||
}
|
||||
|
||||
func registerDynamicUpdateInfoRoutes(
|
||||
r *gin.Engine,
|
||||
logger *utils.Logger,
|
||||
publicDir string,
|
||||
downloadsDir string,
|
||||
) {
|
||||
updateInfoPath := filepath.Join(publicDir, "update-info.json")
|
||||
for _, path := range []string{"/update-info.json", "/update-info"} {
|
||||
routePath := path
|
||||
r.GET(routePath, func(c *gin.Context) {
|
||||
payload := map[string]interface{}{}
|
||||
if utils.FileExists(updateInfoPath) {
|
||||
if data, err := utils.ReadJSONFile(updateInfoPath); err == nil {
|
||||
payload = data
|
||||
}
|
||||
}
|
||||
|
||||
products := utils.GetProductsInfo(downloadsDir, logger)
|
||||
productName, latest := utils.GetLatestProductRelease(products, "YMhut Box")
|
||||
if latest != nil {
|
||||
baseURL := requestBaseURL(c)
|
||||
payload["app_version"] = latest.Version
|
||||
payload["download_url"] = baseURL + latest.DownloadPath
|
||||
payload["download_mirrors"] = []map[string]interface{}{
|
||||
{
|
||||
"id": "primary",
|
||||
"name": "官方直连",
|
||||
"url": baseURL + latest.DownloadPath,
|
||||
"type": "direct",
|
||||
"sha256": sha256File(filepath.Join(downloadsDir, latest.FileName)),
|
||||
"enabled": true,
|
||||
},
|
||||
}
|
||||
payload["detected_product"] = productName
|
||||
payload["detected_packages"] = products
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||
c.Header("Cache-Control", "public, max-age=300")
|
||||
c.JSON(http.StatusOK, payload)
|
||||
})
|
||||
logger.Info(fmt.Sprintf("注册动态更新信息路由 [GET] %s", routePath))
|
||||
}
|
||||
}
|
||||
|
||||
func requestBaseURL(c *gin.Context) string {
|
||||
scheme := c.GetHeader("X-Forwarded-Proto")
|
||||
if scheme == "" {
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
}
|
||||
return scheme + "://" + c.Request.Host
|
||||
}
|
||||
|
||||
func registerReleaseAPIRoutes(
|
||||
r *gin.Engine,
|
||||
logger *utils.Logger,
|
||||
publicDir string,
|
||||
downloadsDir string,
|
||||
) {
|
||||
r.GET("/api/releases", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, buildReleaseManifest(c, logger, publicDir, downloadsDir))
|
||||
})
|
||||
r.GET("/api/modules", func(c *gin.Context) {
|
||||
manifest := buildReleaseManifest(c, logger, publicDir, downloadsDir)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"manifest_version": manifest["manifest_version"],
|
||||
"modules": manifest["modules"],
|
||||
})
|
||||
})
|
||||
r.GET("/api/update-info", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, buildReleaseManifest(c, logger, publicDir, downloadsDir))
|
||||
})
|
||||
}
|
||||
|
||||
func buildReleaseManifest(
|
||||
c *gin.Context,
|
||||
logger *utils.Logger,
|
||||
publicDir string,
|
||||
downloadsDir string,
|
||||
) map[string]interface{} {
|
||||
payload := map[string]interface{}{}
|
||||
updateInfoPath := filepath.Join(publicDir, "update-info.json")
|
||||
if utils.FileExists(updateInfoPath) {
|
||||
if data, err := utils.ReadJSONFile(updateInfoPath); err == nil {
|
||||
payload = data
|
||||
}
|
||||
}
|
||||
|
||||
baseURL := requestBaseURL(c)
|
||||
products := utils.GetProductsInfo(downloadsDir, logger)
|
||||
packages := make([]map[string]interface{}, 0)
|
||||
for productName, releases := range products {
|
||||
for _, release := range releases {
|
||||
filePath := filepath.Join(downloadsDir, release.FileName)
|
||||
platform, arch := detectPackagePlatform(release.FileName, release.Extension)
|
||||
packages = append(packages, map[string]interface{}{
|
||||
"id": packageID(productName, platform, arch, release.Version),
|
||||
"name": productName,
|
||||
"version": release.Version,
|
||||
"platform": platform,
|
||||
"arch": arch,
|
||||
"url": baseURL + release.DownloadPath,
|
||||
"sha256": sha256File(filePath),
|
||||
"size": release.SizeBytes,
|
||||
"required": utils.IsSameProduct(productName, "YMhut Box"),
|
||||
"enabled": true,
|
||||
"changelog": map[string]string{},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
modulesPath := filepath.Join(publicDir, "modules.json")
|
||||
modules := []interface{}{}
|
||||
if utils.FileExists(modulesPath) {
|
||||
if data, err := utils.ReadJSONFile(modulesPath); err == nil {
|
||||
if raw, ok := data["modules"].([]interface{}); ok {
|
||||
modules = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
payload["manifest_version"] = 2
|
||||
payload["packages"] = packages
|
||||
payload["modules"] = modules
|
||||
payload["assets"] = []interface{}{}
|
||||
|
||||
productName, latest := utils.GetLatestProductRelease(products, "YMhut Box")
|
||||
if latest != nil {
|
||||
latestURL := baseURL + latest.DownloadPath
|
||||
payload["app_version"] = latest.Version
|
||||
payload["download_url"] = latestURL
|
||||
payload["download_mirrors"] = []map[string]interface{}{
|
||||
{
|
||||
"id": "primary",
|
||||
"name": "官方下载",
|
||||
"url": latestURL,
|
||||
"type": "direct",
|
||||
"sha256": sha256File(filepath.Join(downloadsDir, latest.FileName)),
|
||||
"enabled": true,
|
||||
},
|
||||
}
|
||||
payload["detected_product"] = productName
|
||||
payload["detected_packages"] = products
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
func packageID(productName string, platform string, arch string, version string) string {
|
||||
value := strings.ToLower(productName + "-" + platform + "-" + arch + "-" + version)
|
||||
value = strings.NewReplacer(" ", "-", "_", "-").Replace(value)
|
||||
return value
|
||||
}
|
||||
|
||||
func detectPackagePlatform(fileName string, ext string) (string, string) {
|
||||
lower := strings.ToLower(fileName)
|
||||
platform := "unknown"
|
||||
switch strings.ToLower(ext) {
|
||||
case "exe", "msi":
|
||||
platform = "windows"
|
||||
case "apk":
|
||||
platform = "android"
|
||||
case "dmg", "pkg":
|
||||
platform = "macos"
|
||||
case "deb", "rpm", "appimage", "tar.gz":
|
||||
platform = "linux"
|
||||
}
|
||||
|
||||
arch := "x64"
|
||||
if strings.Contains(lower, "arm64") || strings.Contains(lower, "aarch64") {
|
||||
arch = "arm64"
|
||||
} else if strings.Contains(lower, "x86") && !strings.Contains(lower, "x64") {
|
||||
arch = "x86"
|
||||
} else if platform == "android" {
|
||||
arch = "universal"
|
||||
}
|
||||
return platform, arch
|
||||
}
|
||||
|
||||
func sha256File(path string) string {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DBConfigFile 数据库配置文件结构
|
||||
type DBConfigFile struct {
|
||||
Type string `json:"type"` // "sqlite" 或 "mysql"
|
||||
Host string `json:"host"` // MySQL 主机
|
||||
Port string `json:"port"` // MySQL 端口
|
||||
User string `json:"user"` // MySQL 用户名
|
||||
Password string `json:"password"` // MySQL 密码
|
||||
Database string `json:"database"` // MySQL 数据库名
|
||||
TablePrefix string `json:"table_prefix"` // 表前缀
|
||||
DSN string `json:"dsn"` // SQLite 数据目录
|
||||
Initialized bool `json:"initialized"` // 是否已初始化
|
||||
}
|
||||
|
||||
var (
|
||||
configFile *DBConfigFile
|
||||
configFileLock sync.RWMutex
|
||||
configFilePath = "data/db-config.json"
|
||||
)
|
||||
|
||||
// LoadDBConfig 加载数据库配置
|
||||
func LoadDBConfig() (*DBConfigFile, error) {
|
||||
configFileLock.RLock()
|
||||
if configFile != nil {
|
||||
configFileLock.RUnlock()
|
||||
return configFile, nil
|
||||
}
|
||||
configFileLock.RUnlock()
|
||||
|
||||
configFileLock.Lock()
|
||||
defer configFileLock.Unlock()
|
||||
|
||||
// 双重检查
|
||||
if configFile != nil {
|
||||
return configFile, nil
|
||||
}
|
||||
|
||||
// 确保目录存在
|
||||
dir := filepath.Dir(configFilePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建配置目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 读取配置文件
|
||||
data, err := os.ReadFile(configFilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// 配置文件不存在,返回默认配置
|
||||
configFile = &DBConfigFile{
|
||||
Type: "mysql",
|
||||
Host: "localhost",
|
||||
Port: "3306",
|
||||
User: "root",
|
||||
Password: "",
|
||||
Database: "software_download_center",
|
||||
TablePrefix: "",
|
||||
DSN: "data",
|
||||
Initialized: false,
|
||||
}
|
||||
return configFile, nil
|
||||
}
|
||||
return nil, fmt.Errorf("读取配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
configFile = &DBConfigFile{}
|
||||
if err := json.Unmarshal(data, configFile); err != nil {
|
||||
return nil, fmt.Errorf("解析配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
return configFile, nil
|
||||
}
|
||||
|
||||
// SaveDBConfig 保存数据库配置
|
||||
func SaveDBConfig(config *DBConfigFile) error {
|
||||
configFileLock.Lock()
|
||||
defer configFileLock.Unlock()
|
||||
|
||||
// 确保目录存在
|
||||
dir := filepath.Dir(configFilePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("创建配置目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 序列化配置
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化配置失败: %w", err)
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
if err := os.WriteFile(configFilePath, data, 0644); err != nil {
|
||||
return fmt.Errorf("写入配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
configFile = config
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDBInitialized 检查数据库是否已初始化
|
||||
func IsDBInitialized() bool {
|
||||
config, err := LoadDBConfig()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return config.Initialized
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"software-download-center/models"
|
||||
"software-download-center/utils"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
gormLogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
DB *gorm.DB
|
||||
dbType string // "sqlite" 或 "mysql"
|
||||
dbLogger *utils.Logger
|
||||
)
|
||||
|
||||
// DatabaseConfig 数据库配置
|
||||
type DatabaseConfig struct {
|
||||
Type string // "sqlite" 或 "mysql"
|
||||
DSN string // 数据库连接字符串
|
||||
Host string // MySQL 主机
|
||||
Port string // MySQL 端口
|
||||
User string // MySQL 用户名
|
||||
Password string // MySQL 密码
|
||||
Database string // MySQL 数据库名
|
||||
TablePrefix string // 表前缀
|
||||
}
|
||||
|
||||
// InitDB 初始化数据库(延迟初始化,允许失败)
|
||||
func InitDB() error {
|
||||
logger := utils.NewLogger()
|
||||
dbLogger = logger
|
||||
|
||||
// 检测操作系统
|
||||
osInfo := utils.DetectOS()
|
||||
logger.System(fmt.Sprintf("🖥️ 检测到操作系统: %s (%s)", osInfo.OS, osInfo.Arch))
|
||||
|
||||
// 检查是否已初始化
|
||||
if IsDBInitialized() {
|
||||
// 从配置文件读取数据库配置
|
||||
fileConfig, err := LoadDBConfig()
|
||||
if err != nil {
|
||||
logger.Warn(fmt.Sprintf("⚠️ 读取数据库配置失败: %s,使用环境变量", err.Error()))
|
||||
config := getDatabaseConfig(osInfo)
|
||||
return connectDB(config, logger)
|
||||
}
|
||||
|
||||
config := &DatabaseConfig{
|
||||
Type: fileConfig.Type,
|
||||
Host: fileConfig.Host,
|
||||
Port: fileConfig.Port,
|
||||
User: fileConfig.User,
|
||||
Password: fileConfig.Password,
|
||||
Database: fileConfig.Database,
|
||||
TablePrefix: fileConfig.TablePrefix,
|
||||
DSN: fileConfig.DSN,
|
||||
}
|
||||
return connectDB(config, logger)
|
||||
}
|
||||
|
||||
// 未初始化,不强制连接
|
||||
logger.System("ℹ️ 数据库未初始化,等待管理员配置")
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectDB 连接数据库
|
||||
func connectDB(config *DatabaseConfig, logger *utils.Logger) error {
|
||||
|
||||
// 确保 data 目录存在(仅 SQLite 需要)
|
||||
if config.Type == "sqlite" {
|
||||
if err := os.MkdirAll(config.DSN, 0755); err != nil {
|
||||
return fmt.Errorf("创建数据目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
dbType = config.Type
|
||||
|
||||
if config.Type == "mysql" {
|
||||
// 使用 MySQL
|
||||
logger.System("📊 使用 MySQL 数据库")
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
config.User, config.Password, config.Host, config.Port, config.Database)
|
||||
|
||||
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: gormLogger.Default.LogMode(gormLogger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("MySQL 连接失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
// 使用 SQLite
|
||||
logger.System("📊 使用 SQLite 数据库")
|
||||
|
||||
osInfo := utils.DetectOS()
|
||||
// 检查 CGO 支持
|
||||
if !osInfo.IsCGO {
|
||||
logger.Warn("⚠️ 检测到 CGO 未启用,SQLite 可能需要 CGO 支持")
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(config.DSN, "app.db")
|
||||
logger.System(fmt.Sprintf("📁 数据库文件路径: %s", dbPath))
|
||||
|
||||
DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: gormLogger.Default.LogMode(gormLogger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("SQLite 连接失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if DB == nil {
|
||||
return fmt.Errorf("数据库连接失败")
|
||||
}
|
||||
|
||||
// 设置表前缀
|
||||
models.SetTablePrefix(config.TablePrefix)
|
||||
|
||||
// 自动迁移
|
||||
logger.System("🔄 开始数据库迁移...")
|
||||
if err := DB.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Route{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("数据库迁移失败: %w", err)
|
||||
}
|
||||
logger.System("✅ 数据库迁移完成")
|
||||
|
||||
// 记录数据库信息
|
||||
var userCount int64
|
||||
DB.Model(&models.User{}).Count(&userCount)
|
||||
logger.System(fmt.Sprintf("📊 数据库类型: %s", strings.ToUpper(dbType)))
|
||||
logger.System(fmt.Sprintf("👥 当前用户数: %d", userCount))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitDBWithConfig 使用配置初始化数据库
|
||||
func InitDBWithConfig(config *DatabaseConfig) error {
|
||||
logger := utils.NewLogger()
|
||||
dbLogger = logger
|
||||
|
||||
// 连接数据库
|
||||
if err := connectDB(config, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
fileConfig := &DBConfigFile{
|
||||
Type: config.Type,
|
||||
Host: config.Host,
|
||||
Port: config.Port,
|
||||
User: config.User,
|
||||
Password: config.Password,
|
||||
Database: config.Database,
|
||||
TablePrefix: config.TablePrefix,
|
||||
DSN: config.DSN,
|
||||
Initialized: true,
|
||||
}
|
||||
|
||||
if err := SaveDBConfig(fileConfig); err != nil {
|
||||
logger.Warn(fmt.Sprintf("⚠️ 保存数据库配置失败: %s", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDatabaseConfig 获取数据库配置(从环境变量)
|
||||
func getDatabaseConfig(osInfo *utils.OSInfo) *DatabaseConfig {
|
||||
config := &DatabaseConfig{
|
||||
Type: getEnvOrDefault("DB_TYPE", "sqlite"),
|
||||
Host: getEnvOrDefault("DB_HOST", "localhost"),
|
||||
Port: getEnvOrDefault("DB_PORT", "3306"),
|
||||
User: getEnvOrDefault("DB_USER", "root"),
|
||||
Password: getEnvOrDefault("DB_PASSWORD", ""),
|
||||
Database: getEnvOrDefault("DB_NAME", "software_download_center"),
|
||||
TablePrefix: getEnvOrDefault("DB_TABLE_PREFIX", ""),
|
||||
}
|
||||
|
||||
// 数据目录
|
||||
config.DSN = osInfo.DataDir
|
||||
if config.DSN == "" {
|
||||
config.DSN = "data"
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// GetDatabaseConfigFromFile 从配置文件获取数据库配置
|
||||
func GetDatabaseConfigFromFile() (*DatabaseConfig, error) {
|
||||
fileConfig, err := LoadDBConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DatabaseConfig{
|
||||
Type: fileConfig.Type,
|
||||
Host: fileConfig.Host,
|
||||
Port: fileConfig.Port,
|
||||
User: fileConfig.User,
|
||||
Password: fileConfig.Password,
|
||||
Database: fileConfig.Database,
|
||||
TablePrefix: fileConfig.TablePrefix,
|
||||
DSN: fileConfig.DSN,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDatabaseConfig 获取当前数据库配置(供外部调用)
|
||||
func GetDatabaseConfig() *DatabaseConfig {
|
||||
// 优先从配置文件读取
|
||||
if config, err := GetDatabaseConfigFromFile(); err == nil && config != nil {
|
||||
return config
|
||||
}
|
||||
// 回退到环境变量
|
||||
osInfo := utils.DetectOS()
|
||||
return getDatabaseConfig(osInfo)
|
||||
}
|
||||
|
||||
// VerifyMySQLPassword 验证 MySQL 密码
|
||||
func VerifyMySQLPassword(password string) error {
|
||||
config := GetDatabaseConfig()
|
||||
if config.Type != "mysql" {
|
||||
return fmt.Errorf("当前数据库类型不是 MySQL")
|
||||
}
|
||||
|
||||
// 尝试使用提供的密码连接数据库
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
config.User, password, config.Host, config.Port, config.Database)
|
||||
|
||||
testDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: gormLogger.Default.LogMode(gormLogger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码验证失败: %w", err)
|
||||
}
|
||||
|
||||
// 关闭测试连接
|
||||
sqlDB, _ := testDB.DB()
|
||||
if sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateMySQLPassword 更新 MySQL root 密码
|
||||
func UpdateMySQLPassword(newPassword string) error {
|
||||
config := GetDatabaseConfig()
|
||||
if config.Type != "mysql" {
|
||||
return fmt.Errorf("当前数据库类型不是 MySQL")
|
||||
}
|
||||
|
||||
// 使用当前密码连接
|
||||
currentDSN := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
config.User, config.Password, config.Host, config.Port, config.Database)
|
||||
|
||||
db, err := gorm.Open(mysql.Open(currentDSN), &gorm.Config{
|
||||
Logger: gormLogger.Default.LogMode(gormLogger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接数据库失败: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
defer sqlDB.Close()
|
||||
|
||||
// 执行 ALTER USER 语句更新密码
|
||||
// MySQL 8.0+ 使用 ALTER USER,旧版本使用 SET PASSWORD
|
||||
updateSQL := fmt.Sprintf("ALTER USER '%s'@'%s' IDENTIFIED BY '%s'", config.User, "%", newPassword)
|
||||
|
||||
// 尝试使用 ALTER USER(MySQL 5.7.6+ 和 8.0+)
|
||||
_, err = sqlDB.Exec(updateSQL)
|
||||
if err != nil {
|
||||
// 如果失败,尝试使用 SET PASSWORD(兼容旧版本)
|
||||
setPasswordSQL := fmt.Sprintf("SET PASSWORD FOR '%s'@'%s' = PASSWORD('%s')", config.User, "%", newPassword)
|
||||
_, err2 := sqlDB.Exec(setPasswordSQL)
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("更新密码失败: %w (ALTER USER 失败: %v)", err2, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新权限
|
||||
_, err = sqlDB.Exec("FLUSH PRIVILEGES")
|
||||
if err != nil {
|
||||
// 刷新权限失败不影响密码更新,只记录警告
|
||||
utils.NewLogger().Warn(fmt.Sprintf("刷新权限失败: %s", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getEnvOrDefault 获取环境变量或返回默认值
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// GetDB 获取数据库实例
|
||||
func GetDB() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
|
||||
// GetDBType 获取数据库类型
|
||||
func GetDBType() string {
|
||||
return dbType
|
||||
}
|
||||
|
||||
// ConvertDatabase 转换数据库(MySQL <-> SQLite)
|
||||
func ConvertDatabase(targetType string, logger *utils.Logger) error {
|
||||
if dbType == targetType {
|
||||
return fmt.Errorf("数据库类型已经是 %s", targetType)
|
||||
}
|
||||
|
||||
logger.System(fmt.Sprintf("🔄 开始数据库转换: %s -> %s", strings.ToUpper(dbType), strings.ToUpper(targetType)))
|
||||
|
||||
// 导出当前数据库数据
|
||||
users, routes, err := exportData()
|
||||
if err != nil {
|
||||
return fmt.Errorf("导出数据失败: %w", err)
|
||||
}
|
||||
logger.System(fmt.Sprintf("📤 已导出 %d 个用户, %d 个路由", len(users), len(routes)))
|
||||
|
||||
// 关闭当前数据库连接
|
||||
if DB != nil {
|
||||
sqlDB, _ := DB.DB()
|
||||
if sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化新数据库
|
||||
osInfo := utils.DetectOS()
|
||||
config := getDatabaseConfig(osInfo)
|
||||
config.Type = targetType
|
||||
dbType = targetType
|
||||
|
||||
var newDB *gorm.DB
|
||||
if targetType == "mysql" {
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
config.User, config.Password, config.Host, config.Port, config.Database)
|
||||
newDB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: gormLogger.Default.LogMode(gormLogger.Silent),
|
||||
})
|
||||
} else {
|
||||
dbPath := filepath.Join(config.DSN, "app.db")
|
||||
newDB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: gormLogger.Default.LogMode(gormLogger.Silent),
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接新数据库失败: %w", err)
|
||||
}
|
||||
|
||||
// 迁移表结构
|
||||
if err := newDB.AutoMigrate(&models.User{}, &models.Route{}); err != nil {
|
||||
return fmt.Errorf("迁移表结构失败: %w", err)
|
||||
}
|
||||
|
||||
// 导入数据
|
||||
if err := importData(newDB, users, routes, logger); err != nil {
|
||||
return fmt.Errorf("导入数据失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新全局数据库实例
|
||||
DB = newDB
|
||||
logger.System(fmt.Sprintf("✅ 数据库转换完成: %s", strings.ToUpper(targetType)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// exportData 导出数据
|
||||
func exportData() ([]models.User, []models.Route, error) {
|
||||
var users []models.User
|
||||
var routes []models.Route
|
||||
|
||||
if err := DB.Find(&users).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err := DB.Find(&routes).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return users, routes, nil
|
||||
}
|
||||
|
||||
// importData 导入数据
|
||||
func importData(db *gorm.DB, users []models.User, routes []models.Route, logger *utils.Logger) error {
|
||||
// 导入用户
|
||||
if len(users) > 0 {
|
||||
if err := db.Create(&users).Error; err != nil {
|
||||
logger.Warn(fmt.Sprintf("⚠️ 导入用户时出现错误: %s", err.Error()))
|
||||
// 尝试逐个导入
|
||||
for _, user := range users {
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
logger.Warn(fmt.Sprintf("⚠️ 跳过用户 %s: %s", user.Username, err.Error()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.System(fmt.Sprintf("✅ 成功导入 %d 个用户", len(users)))
|
||||
}
|
||||
}
|
||||
|
||||
// 导入路由
|
||||
if len(routes) > 0 {
|
||||
if err := db.Create(&routes).Error; err != nil {
|
||||
logger.Warn(fmt.Sprintf("⚠️ 导入路由时出现错误: %s", err.Error()))
|
||||
// 尝试逐个导入
|
||||
for _, route := range routes {
|
||||
if err := db.Create(&route).Error; err != nil {
|
||||
logger.Warn(fmt.Sprintf("⚠️ 跳过路由 %s %s: %s", route.Method, route.Path, err.Error()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.System(fmt.Sprintf("✅ 成功导入 %d 个路由", len(routes)))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
module software-download-center
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
golang.org/x/crypto v0.17.0
|
||||
gorm.io/driver/mysql v1.5.2
|
||||
gorm.io/driver/sqlite v1.5.4
|
||||
gorm.io/gorm v1.25.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.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.14.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.17 // 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.0.8 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sys v0.15.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
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.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
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.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
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.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
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.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
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.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
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.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
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/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/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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
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.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
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=
|
||||
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
|
||||
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
|
||||
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
|
||||
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
||||
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -0,0 +1,447 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"software-download-center/database"
|
||||
"software-download-center/models"
|
||||
"software-download-center/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LogEntry 日志条目
|
||||
type LogEntry struct {
|
||||
Time string `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
var logBuffer []LogEntry
|
||||
var maxLogEntries = 1000
|
||||
|
||||
// AddLog 添加日志到缓冲区
|
||||
func AddLog(level, message string) {
|
||||
entry := LogEntry{
|
||||
Time: time.Now().Format("2006-01-02 15:04:05"),
|
||||
Level: level,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
logBuffer = append(logBuffer, entry)
|
||||
|
||||
// 同时输出到控制台(使用 utils.Logger)
|
||||
logger := utils.NewLogger()
|
||||
switch level {
|
||||
case "ERROR":
|
||||
logger.Error(message)
|
||||
case "WARN":
|
||||
logger.Warn(message)
|
||||
case "INFO":
|
||||
logger.Info(message)
|
||||
default:
|
||||
logger.System(message)
|
||||
}
|
||||
|
||||
// 保持缓冲区大小
|
||||
if len(logBuffer) > maxLogEntries {
|
||||
logBuffer = logBuffer[len(logBuffer)-maxLogEntries:]
|
||||
}
|
||||
}
|
||||
|
||||
// GetLogBuffer 获取日志缓冲区(用于外部访问)
|
||||
func GetLogBuffer() []LogEntry {
|
||||
return logBuffer
|
||||
}
|
||||
|
||||
// GetLogs 获取日志
|
||||
func GetLogs(c *gin.Context) {
|
||||
limit := 100
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
fmt.Sscanf(limitStr, "%d", &limit)
|
||||
}
|
||||
|
||||
start := len(logBuffer) - limit
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
logs := logBuffer[start:]
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": logs,
|
||||
"total": len(logBuffer),
|
||||
})
|
||||
}
|
||||
|
||||
// GetRoutes 获取所有路由
|
||||
func GetRoutes(c *gin.Context) {
|
||||
var routes []models.Route
|
||||
if err := database.DB.Order("`order` ASC, id ASC").Find(&routes).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "获取路由失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"routes": routes,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateRoute 创建路由
|
||||
func CreateRoute(c *gin.Context) {
|
||||
var route models.Route
|
||||
if err := c.ShouldBindJSON(&route); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请求参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证路径是否已存在
|
||||
var existingRoute models.Route
|
||||
if err := database.DB.Where("path = ? AND method = ?", route.Path, route.Method).First(&existingRoute).Error; err == nil {
|
||||
AddLog("WARN", fmt.Sprintf("创建路由失败(已存在): %s %s (IP: %s)", route.Method, route.Path, c.ClientIP()))
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "路由已存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&route).Error; err != nil {
|
||||
AddLog("ERROR", fmt.Sprintf("创建路由数据库错误: %s %s - %s (IP: %s)", route.Method, route.Path, err.Error(), c.ClientIP()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "创建路由失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
AddLog("INFO", fmt.Sprintf("创建路由成功: %s %s (类型: %s, IP: %s)", route.Method, route.Path, route.Type, c.ClientIP()))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "路由创建成功",
|
||||
"route": route,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRoute 更新路由
|
||||
func UpdateRoute(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var route models.Route
|
||||
oldPath := route.Path
|
||||
oldMethod := route.Method
|
||||
|
||||
if err := database.DB.First(&route, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
AddLog("WARN", fmt.Sprintf("更新路由失败(不存在): ID=%s (IP: %s)", id, c.ClientIP()))
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "路由不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
AddLog("ERROR", fmt.Sprintf("查询路由失败: ID=%s - %s (IP: %s)", id, err.Error(), c.ClientIP()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "查询路由失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
oldPath = route.Path
|
||||
oldMethod = route.Method
|
||||
|
||||
if err := c.ShouldBindJSON(&route); err != nil {
|
||||
AddLog("WARN", fmt.Sprintf("更新路由请求参数错误: ID=%s - %s (IP: %s)", id, err.Error(), c.ClientIP()))
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请求参数错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Save(&route).Error; err != nil {
|
||||
AddLog("ERROR", fmt.Sprintf("更新路由数据库错误: %s %s - %s (IP: %s)", route.Method, route.Path, err.Error(), c.ClientIP()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "更新路由失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
AddLog("INFO", fmt.Sprintf("更新路由成功: %s %s -> %s %s (IP: %s)", oldMethod, oldPath, route.Method, route.Path, c.ClientIP()))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "路由更新成功",
|
||||
"route": route,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRoute 删除路由
|
||||
func DeleteRoute(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var route models.Route
|
||||
if err := database.DB.First(&route, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
AddLog("WARN", fmt.Sprintf("删除路由失败(不存在): ID=%s (IP: %s)", id, c.ClientIP()))
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "路由不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
AddLog("ERROR", fmt.Sprintf("查询路由失败: ID=%s - %s (IP: %s)", id, err.Error(), c.ClientIP()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "查询路由失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
routeInfo := fmt.Sprintf("%s %s", route.Method, route.Path)
|
||||
|
||||
if err := database.DB.Delete(&route).Error; err != nil {
|
||||
AddLog("ERROR", fmt.Sprintf("删除路由数据库错误: %s - %s (IP: %s)", routeInfo, err.Error(), c.ClientIP()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "删除路由失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
AddLog("INFO", fmt.Sprintf("删除路由成功: %s (IP: %s)", routeInfo, c.ClientIP()))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "路由删除成功",
|
||||
})
|
||||
}
|
||||
|
||||
// GetFiles 获取文件列表
|
||||
func GetFiles(c *gin.Context) {
|
||||
dir := c.Query("dir")
|
||||
if dir == "" {
|
||||
dir = "public/downloads"
|
||||
}
|
||||
|
||||
// 安全检查:防止目录遍历
|
||||
if strings.Contains(dir, "..") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "无效的目录路径",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "读取目录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var fileList []gin.H
|
||||
for _, file := range files {
|
||||
info, _ := file.Info()
|
||||
fileList = append(fileList, gin.H{
|
||||
"name": file.Name(),
|
||||
"size": info.Size(),
|
||||
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
"is_dir": file.IsDir(),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"files": fileList,
|
||||
"path": dir,
|
||||
})
|
||||
}
|
||||
|
||||
// SaveFile 保存文件
|
||||
func SaveFile(c *gin.Context) {
|
||||
var req struct {
|
||||
Path string `json:"path" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请求参数错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 安全检查
|
||||
if strings.Contains(req.Path, "..") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "无效的文件路径",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 确保目录存在
|
||||
dir := filepath.Dir(req.Path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "创建目录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
if err := os.WriteFile(req.Path, []byte(req.Content), 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "保存文件失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
AddLog("INFO", fmt.Sprintf("保存文件: %s", req.Path))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "文件保存成功",
|
||||
})
|
||||
}
|
||||
|
||||
// ReadFile 读取文件
|
||||
func ReadFile(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "缺少文件路径参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 安全检查
|
||||
if strings.Contains(path, "..") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "无效的文件路径",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
AddLog("ERROR", fmt.Sprintf("读取文件失败: %s - %s (IP: %s)", path, err.Error(), c.ClientIP()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "读取文件失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
AddLog("INFO", fmt.Sprintf("读取文件: %s (大小: %d 字节, IP: %s)", path, len(content), c.ClientIP()))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"content": string(content),
|
||||
"path": path,
|
||||
"size": len(content),
|
||||
})
|
||||
}
|
||||
|
||||
// ReloadRoutes 热重载路由和配置
|
||||
func ReloadRoutes(c *gin.Context) {
|
||||
var req struct {
|
||||
Type string `json:"type"` // "config" 或 "all"
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req.Type = "all"
|
||||
}
|
||||
|
||||
if req.Type == "config" || req.Type == "all" {
|
||||
// 重新加载所有配置文件
|
||||
files := []string{"tool-status.json", "update-info.json", "media-types.json"}
|
||||
successCount := 0
|
||||
for _, file := range files {
|
||||
if _, err := os.Stat(filepath.Join("public", file)); err == nil {
|
||||
if err := utils.ReloadConfig(file); err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
AddLog("INFO", fmt.Sprintf("重新加载配置文件: %d/%d 成功 (IP: %s)", successCount, len(files), c.ClientIP()))
|
||||
}
|
||||
|
||||
// 注意:Gin 不支持动态路由重载,路由更改需要重启服务器
|
||||
if req.Type == "routes" || req.Type == "all" {
|
||||
AddLog("INFO", fmt.Sprintf("路由热重载请求(需要重启服务器才能生效)(IP: %s)", c.ClientIP()))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "配置已重新加载",
|
||||
"note": "路由更改需要重启服务器才能生效,建议使用进程管理器(如 systemd、supervisor)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "配置已重新加载",
|
||||
})
|
||||
}
|
||||
|
||||
// GetSystemInfo 获取系统信息
|
||||
func GetSystemInfo(c *gin.Context) {
|
||||
var userCount int64
|
||||
var routeCount int64
|
||||
database.DB.Model(&models.User{}).Count(&userCount)
|
||||
database.DB.Model(&models.Route{}).Count(&routeCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": userCount,
|
||||
"routes": routeCount,
|
||||
"logs": len(logBuffer),
|
||||
"version": "1.0.0",
|
||||
"server_time": time.Now().Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateJSONConfig 更新 JSON 配置文件
|
||||
func UpdateJSONConfig(c *gin.Context) {
|
||||
var req struct {
|
||||
File string `json:"file" binding:"required"`
|
||||
Content map[string]interface{} `json:"content" binding:"required"`
|
||||
Reload bool `json:"reload"` // 是否立即重新加载
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请求参数错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件路径
|
||||
allowedFiles := []string{"tool-status.json", "update-info.json", "media-types.json"}
|
||||
allowed := false
|
||||
for _, f := range allowedFiles {
|
||||
if req.File == f {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "不允许修改此文件",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用配置工具保存并更新缓存
|
||||
if err := utils.SaveConfig(req.File, req.Content); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "保存文件失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
message := "配置文件更新成功"
|
||||
if req.Reload {
|
||||
// 重新加载配置到缓存
|
||||
if err := utils.ReloadConfig(req.File); err != nil {
|
||||
AddLog("WARN", fmt.Sprintf("配置文件已保存但重新加载失败: %s - %s", req.File, err.Error()))
|
||||
message = "配置文件已保存,但重新加载时出现警告"
|
||||
} else {
|
||||
AddLog("INFO", fmt.Sprintf("配置文件已保存并立即加载: %s", req.File))
|
||||
message = "配置文件已保存并立即生效"
|
||||
}
|
||||
} else {
|
||||
AddLog("INFO", fmt.Sprintf("更新配置文件: %s", req.File))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": message,
|
||||
"file": req.File,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"software-download-center/database"
|
||||
"software-download-center/models"
|
||||
"software-download-center/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterRequest 注册请求
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
// LoginRequest 登录请求
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
func Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请求参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证密码强度
|
||||
if err := utils.ValidatePasswordStrength(req.Password); err != nil {
|
||||
AddLog("WARN", fmt.Sprintf("注册失败(密码强度不足): 用户名=%s, IP=%s", req.Username, c.ClientIP()))
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
var existingUser models.User
|
||||
if err := database.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
|
||||
AddLog("WARN", fmt.Sprintf("注册失败(用户名已存在): 用户名=%s, IP=%s", req.Username, c.ClientIP()))
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "用户名已存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
if err := database.DB.Where("email = ?", req.Email).First(&existingUser).Error; err == nil {
|
||||
AddLog("WARN", fmt.Sprintf("注册失败(邮箱已被注册): 邮箱=%s, IP=%s", req.Email, c.ClientIP()))
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "邮箱已被注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是第一个用户(自动成为管理员)
|
||||
var userCount int64
|
||||
database.DB.Model(&models.User{}).Count(&userCount)
|
||||
isAdmin := userCount == 0
|
||||
|
||||
// 加密密码
|
||||
hashedPassword, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "密码加密失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
user := models.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: hashedPassword,
|
||||
IsAdmin: isAdmin,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
AddLog("ERROR", fmt.Sprintf("创建用户数据库错误: 用户名=%s - %s, IP=%s", req.Username, err.Error(), c.ClientIP()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "创建用户失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
AddLog("INFO", fmt.Sprintf("用户注册成功: 用户名=%s, 邮箱=%s, 管理员=%v, IP=%s", user.Username, user.Email, user.IsAdmin, c.ClientIP()))
|
||||
|
||||
// 生成 token
|
||||
token, err := utils.GenerateToken(user.ID, user.Username, user.IsAdmin)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "生成 token 失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置 cookie
|
||||
c.SetCookie("token", token, 24*3600, "/", "", false, true)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "注册成功",
|
||||
"token": token,
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"is_admin": user.IsAdmin,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请求参数错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查数据库是否已初始化
|
||||
if !database.IsDBInitialized() {
|
||||
// 使用默认管理员账号
|
||||
if req.Username == "admin" && req.Password == "admin123456" {
|
||||
// 生成临时 token(用于安装页面)
|
||||
token, err := utils.GenerateToken(0, "admin", true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "生成 token 失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
AddLog("INFO", fmt.Sprintf("默认管理员登录成功: 用户名=%s, IP=%s", req.Username, c.ClientIP()))
|
||||
c.SetCookie("token", token, 24*3600, "/", "", false, true)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "登录成功,请配置数据库",
|
||||
"token": token,
|
||||
"user": gin.H{
|
||||
"id": 0,
|
||||
"username": "admin",
|
||||
"is_admin": true,
|
||||
"is_setup": false, // 标记需要安装
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "数据库未初始化,请使用默认管理员账号登录(admin/admin123456)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
var user models.User
|
||||
if err := database.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
AddLog("WARN", fmt.Sprintf("登录失败(用户不存在): 用户名=%s, IP=%s", req.Username, c.ClientIP()))
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "用户名或密码错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
AddLog("ERROR", fmt.Sprintf("查询用户失败: 用户名=%s - %s, IP=%s", req.Username, err.Error(), c.ClientIP()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "查询用户失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户是否激活
|
||||
if !user.IsActive {
|
||||
AddLog("WARN", fmt.Sprintf("登录失败(账户已禁用): 用户名=%s, IP=%s", req.Username, c.ClientIP()))
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "账户已被禁用",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if !utils.CheckPassword(req.Password, user.Password) {
|
||||
AddLog("WARN", fmt.Sprintf("登录失败(密码错误): 用户名=%s, IP=%s", req.Username, c.ClientIP()))
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "用户名或密码错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
AddLog("INFO", fmt.Sprintf("用户登录成功: 用户名=%s, 管理员=%v, IP=%s", user.Username, user.IsAdmin, c.ClientIP()))
|
||||
|
||||
// 生成 token
|
||||
token, err := utils.GenerateToken(user.ID, user.Username, user.IsAdmin)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "生成 token 失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置 cookie
|
||||
c.SetCookie("token", token, 24*3600, "/", "", false, true)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "登录成功",
|
||||
"token": token,
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"is_admin": user.IsAdmin,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Logout 用户登出
|
||||
func Logout(c *gin.Context) {
|
||||
c.SetCookie("token", "", -1, "/", "", false, true)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "登出成功",
|
||||
})
|
||||
}
|
||||
|
||||
// GetCurrentUser 获取当前用户信息
|
||||
func GetCurrentUser(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "未授权",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "用户不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"is_admin": user.IsAdmin,
|
||||
"is_active": user.IsActive,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"software-download-center/database"
|
||||
"software-download-center/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetDatabaseInfo 获取数据库信息
|
||||
func GetDatabaseInfo(c *gin.Context) {
|
||||
dbType := database.GetDBType()
|
||||
osInfo := utils.GetOSInfo()
|
||||
|
||||
var dbInfo gin.H
|
||||
if dbType == "mysql" {
|
||||
dbInfo = gin.H{
|
||||
"type": "MySQL",
|
||||
"status": "connected",
|
||||
}
|
||||
} else {
|
||||
dbInfo = gin.H{
|
||||
"type": "SQLite",
|
||||
"status": "connected",
|
||||
"file": "data/app.db",
|
||||
"cgo_support": osInfo.IsCGO,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"database": dbInfo,
|
||||
"os": gin.H{
|
||||
"os": osInfo.OS,
|
||||
"arch": osInfo.Arch,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ConvertDatabaseRequest 数据库转换请求
|
||||
type ConvertDatabaseRequest struct {
|
||||
TargetType string `json:"target_type" binding:"required,oneof=sqlite mysql"`
|
||||
}
|
||||
|
||||
// ConvertDatabase 转换数据库
|
||||
func ConvertDatabase(c *gin.Context) {
|
||||
var req ConvertDatabaseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请求参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger := utils.NewLogger()
|
||||
|
||||
if err := database.ConvertDatabase(req.TargetType, logger); err != nil {
|
||||
AddLog("ERROR", fmt.Sprintf("数据库转换失败: %s", err.Error()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "数据库转换失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
AddLog("INFO", fmt.Sprintf("数据库转换成功: %s", req.TargetType))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": fmt.Sprintf("数据库已成功转换为 %s", strings.ToUpper(req.TargetType)),
|
||||
"type": req.TargetType,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDatabasePasswordRequest 更新数据库密码请求
|
||||
type UpdateDatabasePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=1"`
|
||||
ConfirmPassword string `json:"confirm_password" binding:"required"`
|
||||
}
|
||||
|
||||
// UpdateDatabasePassword 更新数据库 root 密码
|
||||
func UpdateDatabasePassword(c *gin.Context) {
|
||||
var req UpdateDatabasePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
AddLog("WARN", fmt.Sprintf("更新数据库密码请求参数错误: %s (IP: %s)", err.Error(), c.ClientIP()))
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请求参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证新密码和确认密码是否一致
|
||||
if req.NewPassword != req.ConfirmPassword {
|
||||
AddLog("WARN", fmt.Sprintf("更新数据库密码失败(密码不一致)(IP: %s)", c.ClientIP()))
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "新密码和确认密码不一致",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查当前数据库类型
|
||||
dbType := database.GetDBType()
|
||||
if dbType != "mysql" {
|
||||
AddLog("WARN", fmt.Sprintf("更新数据库密码失败(当前数据库不是 MySQL)(IP: %s)", c.ClientIP()))
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "当前数据库类型不是 MySQL,无法修改密码",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证当前密码
|
||||
if err := database.VerifyMySQLPassword(req.CurrentPassword); err != nil {
|
||||
AddLog("WARN", fmt.Sprintf("更新数据库密码失败(当前密码验证失败)(IP: %s)", c.ClientIP()))
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "当前密码错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
if err := database.UpdateMySQLPassword(req.NewPassword); err != nil {
|
||||
AddLog("ERROR", fmt.Sprintf("更新数据库密码失败: %s (IP: %s)", err.Error(), c.ClientIP()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "更新密码失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
AddLog("INFO", fmt.Sprintf("数据库 root 密码更新成功 (IP: %s)", c.ClientIP()))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "数据库密码更新成功!请更新环境变量 DB_PASSWORD 并重启服务器以使新密码生效。",
|
||||
})
|
||||
}
|
||||
|
||||
// GetDatabaseConfig 获取数据库配置信息(不包含敏感信息)
|
||||
func GetDatabaseConfig(c *gin.Context) {
|
||||
config := database.GetDatabaseConfig()
|
||||
|
||||
// 隐藏敏感信息
|
||||
safeConfig := gin.H{
|
||||
"type": config.Type,
|
||||
"host": config.Host,
|
||||
"port": config.Port,
|
||||
"user": config.User,
|
||||
"database": config.Database,
|
||||
"table_prefix": config.TablePrefix,
|
||||
"has_password": config.Password != "",
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"config": safeConfig,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDatabaseConfigRequest 更新数据库配置请求
|
||||
type UpdateDatabaseConfigRequest struct {
|
||||
Type string `json:"type" binding:"required,oneof=sqlite mysql"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
TablePrefix string `json:"table_prefix"`
|
||||
DSN string `json:"dsn"`
|
||||
}
|
||||
|
||||
// UpdateDatabaseConfig 更新数据库配置(需要重新连接)
|
||||
func UpdateDatabaseConfig(c *gin.Context) {
|
||||
var req UpdateDatabaseConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请求参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if req.Type == "mysql" {
|
||||
if req.Host == "" || req.Port == "" || req.User == "" || req.Database == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "MySQL 配置不完整:需要 host, port, user, database",
|
||||
})
|
||||
return
|
||||
}
|
||||
} else if req.Type == "sqlite" {
|
||||
if req.DSN == "" {
|
||||
req.DSN = "data"
|
||||
}
|
||||
}
|
||||
|
||||
// 构建数据库配置
|
||||
config := &database.DatabaseConfig{
|
||||
Type: req.Type,
|
||||
Host: req.Host,
|
||||
Port: req.Port,
|
||||
User: req.User,
|
||||
Password: req.Password,
|
||||
Database: req.Database,
|
||||
TablePrefix: req.TablePrefix,
|
||||
DSN: req.DSN,
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
if err := database.InitDBWithConfig(config); err != nil {
|
||||
AddLog("ERROR", fmt.Sprintf("数据库配置更新失败: %s (IP: %s)", err.Error(), c.ClientIP()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "数据库连接失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
AddLog("INFO", fmt.Sprintf("数据库配置更新成功: 类型=%s (IP: %s)", req.Type, c.ClientIP()))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "数据库配置已更新,请重启服务器以应用更改",
|
||||
"type": req.Type,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"software-download-center/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DefaultAdminUsername 默认管理员用户名
|
||||
const DefaultAdminUsername = "admin"
|
||||
|
||||
// DefaultAdminPassword 默认管理员密码
|
||||
const DefaultAdminPassword = "admin123456"
|
||||
|
||||
// CheckInstallStatus 检查安装状态
|
||||
func CheckInstallStatus(c *gin.Context) {
|
||||
isInitialized := database.IsDBInitialized()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"initialized": isInitialized,
|
||||
"default_admin": gin.H{
|
||||
"username": DefaultAdminUsername,
|
||||
"password": DefaultAdminPassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// InstallDatabaseRequest 数据库安装请求
|
||||
type InstallDatabaseRequest struct {
|
||||
Type string `json:"type" binding:"required,oneof=sqlite mysql"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
TablePrefix string `json:"table_prefix"`
|
||||
DSN string `json:"dsn"` // SQLite 数据目录
|
||||
}
|
||||
|
||||
// InstallDatabase 安装数据库
|
||||
func InstallDatabase(c *gin.Context) {
|
||||
// 检查是否已初始化
|
||||
if database.IsDBInitialized() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "数据库已初始化,请使用设置页面修改配置",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req InstallDatabaseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "请求参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if req.Type == "mysql" {
|
||||
if req.Host == "" || req.Port == "" || req.User == "" || req.Database == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "MySQL 配置不完整:需要 host, port, user, database",
|
||||
})
|
||||
return
|
||||
}
|
||||
} else if req.Type == "sqlite" {
|
||||
if req.DSN == "" {
|
||||
req.DSN = "data"
|
||||
}
|
||||
}
|
||||
|
||||
// 构建数据库配置
|
||||
config := &database.DatabaseConfig{
|
||||
Type: req.Type,
|
||||
Host: req.Host,
|
||||
Port: req.Port,
|
||||
User: req.User,
|
||||
Password: req.Password,
|
||||
Database: req.Database,
|
||||
TablePrefix: req.TablePrefix,
|
||||
DSN: req.DSN,
|
||||
}
|
||||
|
||||
// 初始化数据库
|
||||
if err := database.InitDBWithConfig(config); err != nil {
|
||||
AddLog("ERROR", fmt.Sprintf("数据库安装失败: %s (IP: %s)", err.Error(), c.ClientIP()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "数据库连接失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
AddLog("INFO", fmt.Sprintf("数据库安装成功: 类型=%s (IP: %s)", req.Type, c.ClientIP()))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "数据库安装成功",
|
||||
"type": req.Type,
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyDefaultAdmin 验证默认管理员登录
|
||||
func VerifyDefaultAdmin(username, password string) bool {
|
||||
return username == DefaultAdminUsername && password == DefaultAdminPassword
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"software-download-center/config"
|
||||
"software-download-center/database"
|
||||
"software-download-center/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 初始化日志
|
||||
logger := utils.NewLogger()
|
||||
|
||||
// 检测操作系统
|
||||
osInfo := utils.DetectOS()
|
||||
logger.System(fmt.Sprintf("🖥️ 操作系统: %s (%s)", osInfo.OS, osInfo.Arch))
|
||||
|
||||
// 尝试初始化数据库(允许失败,等待管理员配置)
|
||||
if err := database.InitDB(); err != nil {
|
||||
logger.Warn("⚠️ 数据库初始化失败: " + err.Error())
|
||||
logger.System("ℹ️ 系统将在管理员配置数据库后自动连接")
|
||||
} else {
|
||||
logger.System("✅ 数据库初始化成功")
|
||||
}
|
||||
|
||||
// 初始化配置缓存
|
||||
if err := utils.InitConfigCache(); err != nil {
|
||||
logger.Warn("配置缓存初始化失败: " + err.Error())
|
||||
} else {
|
||||
logger.System("✅ 配置缓存初始化成功")
|
||||
}
|
||||
|
||||
// 设置 Gin 模式
|
||||
if os.Getenv("GIN_MODE") == "" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// 创建 Gin 引擎
|
||||
r := gin.Default()
|
||||
|
||||
// 注册所有路由
|
||||
config.RegisterRoutes(r, logger)
|
||||
|
||||
// 启动服务器
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "3355"
|
||||
}
|
||||
|
||||
logger.System("=============================================")
|
||||
logger.System("✅ 服务器启动成功")
|
||||
logger.System("📡 访问地址: http://localhost:" + port)
|
||||
env := os.Getenv("GIN_MODE")
|
||||
if env == "" {
|
||||
env = "production"
|
||||
}
|
||||
logger.System("🌍 当前环境: " + env)
|
||||
logger.System("🔄 兼容旧版访问:支持 /tool-status.json /update-info.json /media-types.json")
|
||||
logger.System("=============================================")
|
||||
|
||||
if err := r.Run(":" + port); err != nil {
|
||||
log.Fatal("服务器启动失败:", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"software-download-center/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthMiddleware 认证中间件
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 从请求头获取 token
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
// 尝试从 cookie 获取
|
||||
token, err := c.Cookie("token")
|
||||
if err != nil || token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "未授权,请先登录",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
authHeader = "Bearer " + token
|
||||
}
|
||||
|
||||
// 提取 token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "无效的认证格式",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
|
||||
// 解析 token
|
||||
claims, err := utils.ParseToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "无效的 token",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息存储到上下文
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("is_admin", claims.IsAdmin)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminMiddleware 管理员中间件
|
||||
func AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
isAdmin, exists := c.Get("is_admin")
|
||||
if !exists || !isAdmin.(bool) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "需要管理员权限",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Route 路由模型
|
||||
type Route struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Method string `gorm:"not null;size:10" json:"method"` // GET, POST, PUT, DELETE等
|
||||
Path string `gorm:"not null;size:255;uniqueIndex" json:"path"` // 路由路径
|
||||
Type string `gorm:"not null;size:50" json:"type"` // view, json, file, static, custom
|
||||
Handler string `gorm:"type:text" json:"handler"` // 处理函数或文件路径
|
||||
Description string `gorm:"size:500" json:"description"` // 路由描述
|
||||
IsActive bool `gorm:"default:true" json:"is_active"` // 是否启用
|
||||
Order int `gorm:"default:0" json:"order"` // 排序
|
||||
}
|
||||
|
||||
// TableName 指定表名(支持前缀)
|
||||
func (r Route) TableName() string {
|
||||
if tablePrefix != "" {
|
||||
return tablePrefix + "routes"
|
||||
}
|
||||
return "routes"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var tablePrefix = ""
|
||||
|
||||
// SetTablePrefix 设置表前缀(由 database 包调用)
|
||||
func SetTablePrefix(prefix string) {
|
||||
tablePrefix = prefix
|
||||
}
|
||||
|
||||
// User 用户模型
|
||||
type User struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Username string `gorm:"uniqueIndex;not null;size:50" json:"username"`
|
||||
Email string `gorm:"uniqueIndex;not null;size:100" json:"email"`
|
||||
Password string `gorm:"not null;size:255" json:"-"` // 不返回密码
|
||||
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
}
|
||||
|
||||
// TableName 指定表名(支持前缀)
|
||||
func (u User) TableName() string {
|
||||
if tablePrefix != "" {
|
||||
return tablePrefix + "users"
|
||||
}
|
||||
return "users"
|
||||
}
|
||||
@@ -0,0 +1,851 @@
|
||||
/* 简约风格的后台管理样式 */
|
||||
|
||||
:root {
|
||||
--primary: #0071e3;
|
||||
--success: #34c759;
|
||||
--danger: #ef4444;
|
||||
--warning: #ff9500;
|
||||
--bg: #f5f5f7;
|
||||
--card: #ffffff;
|
||||
--text: #1d1d1f;
|
||||
--text-secondary: #86868b;
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--shadow-hover: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 认证页面 */
|
||||
.auth-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.auth-box {
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: var(--shadow-hover);
|
||||
animation: fadeInUp 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 1rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.1);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.password-strength {
|
||||
margin-top: 0.5rem;
|
||||
height: 3px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.password-strength::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--danger);
|
||||
transition: width 0.3s, background 0.3s;
|
||||
}
|
||||
|
||||
.password-strength.weak::after {
|
||||
width: 33%;
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.password-strength.medium::after {
|
||||
width: 66%;
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.password-strength.strong::after {
|
||||
width: 100%;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0051a5;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 113, 227, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e0e0e0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fee;
|
||||
color: var(--danger);
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
.error-message.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.auth-link:hover {
|
||||
color: #0051a5;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 管理界面 */
|
||||
.admin-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: var(--card);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#current-user {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 260px;
|
||||
background: var(--card);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--bg);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(0, 113, 227, 0.08);
|
||||
color: var(--primary);
|
||||
border-left-color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: none;
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
.page.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.table-container {
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table thead {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 模态框 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s;
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-hover);
|
||||
animation: fadeInUp 0.3s;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* 文件浏览器 */
|
||||
.file-browser {
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.file-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* 编辑器 */
|
||||
.editor-container {
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.code-editor {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
resize: vertical;
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.code-editor:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.1);
|
||||
}
|
||||
|
||||
.config-controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-controls select {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 日志 */
|
||||
.logs-container {
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.logs-content {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.log-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: var(--text-secondary);
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
font-weight: 600;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.log-level.INFO {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.log-level.WARN {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.log-level.ERROR {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 设置页面 */
|
||||
.settings-container {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.database-info,
|
||||
.system-stats,
|
||||
.os-info {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-item,
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.info-item:last-child,
|
||||
.stat-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label,
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.info-value,
|
||||
.stat-value {
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
background: rgba(52, 199, 89, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.database-password,
|
||||
.database-convert {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.password-warning,
|
||||
.convert-warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid var(--warning);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #856404;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.password-form .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#password-result,
|
||||
#convert-result {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#password-result.success,
|
||||
#convert-result.success {
|
||||
background: rgba(52, 199, 89, 0.1);
|
||||
border: 1px solid var(--success);
|
||||
color: var(--success);
|
||||
display: block;
|
||||
}
|
||||
|
||||
#password-result.error,
|
||||
#convert-result.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.convert-form {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.convert-form select {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.admin-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
:root {
|
||||
--background: #fbf7ef;
|
||||
--foreground: #1c1917;
|
||||
--card: rgba(255, 255, 255, 0.9);
|
||||
--card-foreground: #1c1917;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1c1917;
|
||||
--primary: #5f6f45;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f5f5f4;
|
||||
--secondary-foreground: #1c1917;
|
||||
--muted: #f5f5f4;
|
||||
--muted-foreground: #57534e;
|
||||
--accent: #b56e45;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #b91c1c;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #d6d3d1;
|
||||
--input: #d6d3d1;
|
||||
--ring: #7a8a67;
|
||||
--radius: 0.75rem;
|
||||
--shadow: 0 18px 40px rgba(28, 25, 23, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body.shad-app {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
color: var(--foreground);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(181, 110, 69, 0.1), transparent 20rem),
|
||||
radial-gradient(circle at top right, rgba(122, 138, 103, 0.1), transparent 20rem),
|
||||
var(--background);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
border-bottom: 1px solid rgba(214, 211, 209, 0.8);
|
||||
padding: 34px 0 30px;
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header-inner h1 {
|
||||
margin: 10px 0 0;
|
||||
max-width: 760px;
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: 1.08;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.header-inner p {
|
||||
margin: 14px 0 0;
|
||||
max-width: 760px;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.summary-card span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.summary-card p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title h2 {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.section-title p {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
code {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.125rem 0.35rem;
|
||||
background: var(--muted);
|
||||
font-family: "Cascadia Code", "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.shad-container {
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.shad-hero {
|
||||
border-bottom: 1px solid rgba(214, 211, 209, 0.8);
|
||||
}
|
||||
|
||||
.shad-hero__inner {
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 48px 0 40px;
|
||||
}
|
||||
|
||||
.shad-hero__copy {
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
.shad-hero__title {
|
||||
margin: 16px 0 0;
|
||||
font-size: clamp(2rem, 4vw, 3.25rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.shad-hero__text {
|
||||
margin: 16px 0 0;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.shad-main {
|
||||
padding: 28px 0 56px;
|
||||
}
|
||||
|
||||
.shad-section__head {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.shad-section__head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.shad-section__head p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.shad-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.shad-card,
|
||||
.shad-dialog__content {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
color: var(--card-foreground);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.shad-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.shad-card--state {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.shad-card--danger {
|
||||
border-color: rgba(185, 28, 28, 0.24);
|
||||
}
|
||||
|
||||
.shad-card--state h3,
|
||||
.shad-product__title h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shad-card--state p,
|
||||
.shad-product__title p,
|
||||
.shad-history__item p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.shad-product {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.shad-product__head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.shad-product__icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex: 0 0 52px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.875rem;
|
||||
background: rgba(22, 101, 52, 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.shad-product__icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.shad-stack {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shad-stack--row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.shad-stack--wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.shad-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(22, 101, 52, 0.1);
|
||||
color: var(--primary);
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shad-badge--outline {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.shad-badge--muted {
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.shad-badge--subtle {
|
||||
background: rgba(217, 119, 6, 0.12);
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.shad-panel {
|
||||
border: 1px solid rgba(214, 211, 209, 0.8);
|
||||
border-radius: calc(var(--radius) - 0.15rem);
|
||||
padding: 16px;
|
||||
background: linear-gradient(180deg, rgba(28, 25, 23, 0.02), rgba(28, 25, 23, 0.04));
|
||||
}
|
||||
|
||||
.shad-panel__main {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.shad-panel__main strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 1.4rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.shad-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.shad-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shad-muted {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.shad-truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shad-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.shad-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: calc(var(--radius) - 0.2rem);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.shad-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.shad-button--primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.shad-button--primary:hover {
|
||||
background: #14532d;
|
||||
}
|
||||
|
||||
.shad-button--secondary {
|
||||
background: var(--secondary);
|
||||
color: var(--secondary-foreground);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.shad-button--secondary:hover {
|
||||
background: #ede9e7;
|
||||
}
|
||||
|
||||
.shad-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.shad-dialog.is-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.shad-dialog__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(28, 25, 23, 0.45);
|
||||
}
|
||||
|
||||
.shad-dialog__content {
|
||||
position: relative;
|
||||
width: min(680px, 100%);
|
||||
max-height: min(80vh, 720px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shad-dialog__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.shad-dialog__header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shad-dialog__body {
|
||||
padding: 8px 20px 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.shad-icon-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--popover);
|
||||
color: var(--popover-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shad-history {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shad-history__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid rgba(214, 211, 209, 0.8);
|
||||
}
|
||||
|
||||
.shad-history__item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.shad-footer {
|
||||
border-top: 1px solid rgba(214, 211, 209, 0.8);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.shad-footer__inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 0 24px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.header-inner,
|
||||
.section-title {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-actions,
|
||||
.summary-grid {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.shad-hero__inner {
|
||||
min-height: auto;
|
||||
padding: 40px 0 32px;
|
||||
}
|
||||
|
||||
.shad-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.shad-actions,
|
||||
.shad-footer__inner,
|
||||
.shad-panel__main,
|
||||
.shad-history__item,
|
||||
.shad-product__head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.shad-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 5.5 MiB |
@@ -0,0 +1,532 @@
|
||||
// 后台管理 JavaScript
|
||||
|
||||
// 获取 Cookie
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
return null;
|
||||
}
|
||||
|
||||
// API 请求封装
|
||||
async function apiRequest(url, options = {}) {
|
||||
const token = getCookie('token');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// 如果是401,跳转到登录页
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/admin/login';
|
||||
return;
|
||||
}
|
||||
throw new Error(data.error || '请求失败');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载当前用户信息
|
||||
async function loadCurrentUser() {
|
||||
try {
|
||||
const data = await apiRequest('/admin/me');
|
||||
const userEl = document.getElementById('current-user');
|
||||
if (userEl && data.user) {
|
||||
userEl.textContent = `${data.user.username}${data.user.is_admin ? ' (管理员)' : ''}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load user error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 登出
|
||||
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await apiRequest('/admin/logout', { method: 'POST' });
|
||||
document.cookie = 'token=; path=/; max-age=0';
|
||||
window.location.href = '/admin/login';
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
// 即使失败也跳转
|
||||
document.cookie = 'token=; path=/; max-age=0';
|
||||
window.location.href = '/admin/login';
|
||||
}
|
||||
});
|
||||
|
||||
// 页面导航
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const page = item.dataset.page;
|
||||
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
|
||||
item.classList.add('active');
|
||||
document.getElementById(`page-${page}`).classList.add('active');
|
||||
|
||||
// 加载对应页面数据
|
||||
if (page === 'routes') {
|
||||
loadRoutes();
|
||||
} else if (page === 'logs') {
|
||||
loadLogs();
|
||||
} else if (page === 'files') {
|
||||
loadFiles();
|
||||
} else if (page === 'config') {
|
||||
loadConfig();
|
||||
} else if (page === 'database') {
|
||||
loadDatabaseInfo();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 加载数据库信息
|
||||
async function loadDatabaseInfo() {
|
||||
try {
|
||||
const data = await apiRequest('/admin/api/database');
|
||||
const dbDetails = document.getElementById('db-details');
|
||||
const osDetails = document.getElementById('os-details');
|
||||
const dbConfig = document.getElementById('db-config');
|
||||
const passwordSection = document.getElementById('database-password-section');
|
||||
|
||||
dbDetails.innerHTML = `
|
||||
<p><strong>类型:</strong> ${data.database.type}</p>
|
||||
<p><strong>状态:</strong> ${data.database.status}</p>
|
||||
${data.database.file ? `<p><strong>文件:</strong> ${data.database.file}</p>` : ''}
|
||||
${data.database.cgo_support !== undefined ? `<p><strong>CGO 支持:</strong> ${data.database.cgo_support ? '是' : '否'}</p>` : ''}
|
||||
`;
|
||||
|
||||
osDetails.innerHTML = `
|
||||
<p><strong>操作系统:</strong> ${data.os.os}</p>
|
||||
<p><strong>架构:</strong> ${data.os.arch}</p>
|
||||
`;
|
||||
|
||||
// 加载数据库配置
|
||||
try {
|
||||
const configData = await apiRequest('/admin/api/database/config');
|
||||
dbConfig.innerHTML = `
|
||||
<p><strong>类型:</strong> ${configData.config.type}</p>
|
||||
<p><strong>主机:</strong> ${configData.config.host}</p>
|
||||
<p><strong>端口:</strong> ${configData.config.port}</p>
|
||||
<p><strong>用户:</strong> ${configData.config.user}</p>
|
||||
<p><strong>数据库:</strong> ${configData.config.database}</p>
|
||||
<p><strong>已设置密码:</strong> ${configData.config.has_password ? '是' : '否'}</p>
|
||||
`;
|
||||
|
||||
// 如果是 MySQL,显示密码修改界面
|
||||
if (configData.config.type === 'mysql') {
|
||||
passwordSection.style.display = 'block';
|
||||
} else {
|
||||
passwordSection.style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load database config error:', error);
|
||||
dbConfig.innerHTML = '<p>无法加载配置信息</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load database info error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新数据库信息
|
||||
document.getElementById('refresh-db-btn')?.addEventListener('click', loadDatabaseInfo);
|
||||
|
||||
// 转换数据库
|
||||
document.getElementById('convert-db-btn')?.addEventListener('click', async () => {
|
||||
const targetType = document.getElementById('target-db-type').value;
|
||||
|
||||
if (!confirm(`确定要转换数据库类型吗?\n\n目标类型: ${targetType.toUpperCase()}\n\n此操作会导出当前数据并导入到新数据库。请确保已备份数据!`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resultEl = document.getElementById('convert-result');
|
||||
resultEl.className = '';
|
||||
resultEl.textContent = '正在转换...';
|
||||
resultEl.style.display = 'block';
|
||||
|
||||
try {
|
||||
const result = await apiRequest('/admin/api/database/convert', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
target_type: targetType,
|
||||
}),
|
||||
});
|
||||
|
||||
resultEl.className = 'success';
|
||||
resultEl.textContent = result.message || '数据库转换成功!';
|
||||
loadDatabaseInfo();
|
||||
loadLogs();
|
||||
} catch (error) {
|
||||
resultEl.className = 'error';
|
||||
resultEl.textContent = '转换失败: ' + error.message;
|
||||
}
|
||||
});
|
||||
|
||||
// 更新数据库密码
|
||||
document.getElementById('password-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const currentPassword = document.getElementById('current-password').value;
|
||||
const newPassword = document.getElementById('new-password').value;
|
||||
const confirmPassword = document.getElementById('confirm-password').value;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('新密码和确认密码不一致!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('确定要更新数据库 root 密码吗?\n\n更新后需要修改环境变量 DB_PASSWORD 并重启服务器!')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resultEl = document.getElementById('password-result');
|
||||
resultEl.className = '';
|
||||
resultEl.textContent = '正在更新密码...';
|
||||
resultEl.style.display = 'block';
|
||||
|
||||
try {
|
||||
const result = await apiRequest('/admin/api/database/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
confirm_password: confirmPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
resultEl.className = 'success';
|
||||
resultEl.textContent = result.message || '密码更新成功!';
|
||||
|
||||
// 清空表单
|
||||
document.getElementById('password-form').reset();
|
||||
|
||||
loadLogs();
|
||||
} catch (error) {
|
||||
resultEl.className = 'error';
|
||||
resultEl.textContent = '更新失败: ' + error.message;
|
||||
}
|
||||
});
|
||||
|
||||
// 加载系统信息
|
||||
async function loadSystemInfo() {
|
||||
try {
|
||||
const data = await apiRequest('/admin/api/system');
|
||||
document.getElementById('stat-users').textContent = data.users;
|
||||
document.getElementById('stat-routes').textContent = data.routes;
|
||||
document.getElementById('stat-logs').textContent = data.logs;
|
||||
document.getElementById('stat-time').textContent = data.server_time;
|
||||
} catch (error) {
|
||||
console.error('Load system info error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载路由
|
||||
async function loadRoutes() {
|
||||
try {
|
||||
const data = await apiRequest('/admin/api/routes');
|
||||
const tbody = document.getElementById('routes-table-body');
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (data.routes && data.routes.length > 0) {
|
||||
data.routes.forEach(route => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${route.id}</td>
|
||||
<td><span class="badge">${route.method}</span></td>
|
||||
<td>${route.path}</td>
|
||||
<td>${route.type}</td>
|
||||
<td>${route.description || '-'}</td>
|
||||
<td>${route.is_active ? '✅' : '❌'}</td>
|
||||
<td>
|
||||
<button class="btn btn-secondary" onclick="editRoute(${route.id})">编辑</button>
|
||||
<button class="btn btn-danger" onclick="deleteRoute(${route.id})">删除</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">暂无路由</td></tr>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load routes error:', error);
|
||||
const tbody = document.getElementById('routes-table-body');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">加载失败: ' + error.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加路由
|
||||
document.getElementById('add-route-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('route-modal-title').textContent = '添加路由';
|
||||
document.getElementById('route-form').reset();
|
||||
document.getElementById('route-id').value = '';
|
||||
document.getElementById('route-modal').classList.add('show');
|
||||
});
|
||||
|
||||
// 编辑路由
|
||||
window.editRoute = async function(id) {
|
||||
try {
|
||||
const data = await apiRequest('/admin/api/routes');
|
||||
const route = data.routes.find(r => r.id === id);
|
||||
|
||||
if (route) {
|
||||
document.getElementById('route-modal-title').textContent = '编辑路由';
|
||||
document.getElementById('route-id').value = route.id;
|
||||
document.getElementById('route-method').value = route.method;
|
||||
document.getElementById('route-path').value = route.path;
|
||||
document.getElementById('route-type').value = route.type;
|
||||
document.getElementById('route-handler').value = route.handler;
|
||||
document.getElementById('route-description').value = route.description || '';
|
||||
document.getElementById('route-active').checked = route.is_active;
|
||||
document.getElementById('route-order').value = route.order;
|
||||
document.getElementById('route-modal').classList.add('show');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Edit route error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除路由
|
||||
window.deleteRoute = async function(id) {
|
||||
if (!confirm('确定要删除这个路由吗?')) return;
|
||||
|
||||
try {
|
||||
await apiRequest(`/admin/api/routes/${id}`, { method: 'DELETE' });
|
||||
loadRoutes();
|
||||
} catch (error) {
|
||||
alert('删除失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存路由
|
||||
document.getElementById('route-save-btn')?.addEventListener('click', async () => {
|
||||
const id = document.getElementById('route-id').value;
|
||||
const routeData = {
|
||||
method: document.getElementById('route-method').value,
|
||||
path: document.getElementById('route-path').value,
|
||||
type: document.getElementById('route-type').value,
|
||||
handler: document.getElementById('route-handler').value,
|
||||
description: document.getElementById('route-description').value,
|
||||
is_active: document.getElementById('route-active').checked,
|
||||
order: parseInt(document.getElementById('route-order').value) || 0,
|
||||
};
|
||||
|
||||
try {
|
||||
if (id) {
|
||||
await apiRequest(`/admin/api/routes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(routeData),
|
||||
});
|
||||
} else {
|
||||
await apiRequest('/admin/api/routes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(routeData),
|
||||
});
|
||||
}
|
||||
document.getElementById('route-modal').classList.remove('show');
|
||||
loadRoutes();
|
||||
} catch (error) {
|
||||
alert('保存失败: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 关闭模态框
|
||||
document.querySelectorAll('.modal-close, #route-cancel-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.getElementById('route-modal').classList.remove('show');
|
||||
});
|
||||
});
|
||||
|
||||
// 加载日志
|
||||
async function loadLogs() {
|
||||
try {
|
||||
const data = await apiRequest('/admin/api/logs?limit=100');
|
||||
const logsEl = document.getElementById('logs-content');
|
||||
if (!logsEl) return;
|
||||
|
||||
logsEl.innerHTML = '';
|
||||
|
||||
if (data.logs && data.logs.length > 0) {
|
||||
data.logs.forEach(log => {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'log-entry';
|
||||
entry.innerHTML = `
|
||||
<span class="log-time">${log.time || ''}</span>
|
||||
<span class="log-level ${log.level || 'INFO'}">${log.level || 'INFO'}</span>
|
||||
<span class="log-message">${log.message || ''}</span>
|
||||
`;
|
||||
logsEl.appendChild(entry);
|
||||
});
|
||||
|
||||
logsEl.scrollTop = logsEl.scrollHeight;
|
||||
} else {
|
||||
logsEl.innerHTML = '<div class="empty-state">暂无日志</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load logs error:', error);
|
||||
const logsEl = document.getElementById('logs-content');
|
||||
if (logsEl) {
|
||||
logsEl.innerHTML = '<div class="empty-state">加载失败: ' + error.message + '</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新日志
|
||||
document.getElementById('refresh-logs-btn')?.addEventListener('click', loadLogs);
|
||||
|
||||
// 清空日志
|
||||
document.getElementById('clear-logs-btn')?.addEventListener('click', () => {
|
||||
const logsEl = document.getElementById('logs-content');
|
||||
if (logsEl) {
|
||||
logsEl.innerHTML = '<div class="empty-state">日志已清空</div>';
|
||||
}
|
||||
});
|
||||
|
||||
// 加载文件
|
||||
async function loadFiles() {
|
||||
const fileBrowser = document.getElementById('file-browser');
|
||||
if (!fileBrowser) return;
|
||||
|
||||
try {
|
||||
const data = await apiRequest('/admin/api/files?dir=public/downloads');
|
||||
fileBrowser.innerHTML = '';
|
||||
|
||||
if (data.files && data.files.length > 0) {
|
||||
data.files.forEach(file => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'file-item';
|
||||
item.innerHTML = `
|
||||
<div>
|
||||
<strong>${file.name}</strong>
|
||||
<small style="display: block; color: #86868b;">
|
||||
${file.is_dir ? '📁 目录' : `📄 ${formatBytes(file.size)}`} • ${file.mod_time || ''}
|
||||
</small>
|
||||
</div>
|
||||
${!file.is_dir ? `<button class="btn btn-secondary" onclick="readFile('${file.name}')">查看</button>` : ''}
|
||||
`;
|
||||
fileBrowser.appendChild(item);
|
||||
});
|
||||
} else {
|
||||
fileBrowser.innerHTML = '<div class="empty-state">暂无文件</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load files error:', error);
|
||||
fileBrowser.innerHTML = '<div class="empty-state">加载失败: ' + error.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('refresh-files-btn')?.addEventListener('click', loadFiles);
|
||||
|
||||
// 读取文件
|
||||
window.readFile = async function(filename) {
|
||||
const fullPath = `public/downloads/${filename}`;
|
||||
|
||||
try {
|
||||
const data = await apiRequest(`/admin/api/file?path=${encodeURIComponent(fullPath)}`);
|
||||
// 显示文件内容在模态框中或新窗口
|
||||
const content = data.content.substring(0, 5000) + (data.content.length > 5000 ? '\n\n... (内容过长,已截断)' : '');
|
||||
alert('文件内容:\n\n' + content);
|
||||
} catch (error) {
|
||||
alert('读取文件失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载配置
|
||||
async function loadConfig() {
|
||||
const file = document.getElementById('config-select').value;
|
||||
try {
|
||||
const response = await fetch(`/public/${file}`);
|
||||
const data = await response.json();
|
||||
document.getElementById('config-editor').value = JSON.stringify(data, null, 2);
|
||||
} catch (error) {
|
||||
console.error('Load config error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('load-config-btn')?.addEventListener('click', loadConfig);
|
||||
|
||||
// 保存配置
|
||||
document.getElementById('save-config-btn')?.addEventListener('click', async () => {
|
||||
const file = document.getElementById('config-select').value;
|
||||
const content = document.getElementById('config-editor').value;
|
||||
|
||||
try {
|
||||
const jsonData = JSON.parse(content);
|
||||
const result = await apiRequest('/admin/api/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
file: file,
|
||||
content: jsonData,
|
||||
reload: false, // 仅保存
|
||||
}),
|
||||
});
|
||||
alert(result.message || '配置保存成功!');
|
||||
} catch (error) {
|
||||
alert('保存失败: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 保存并立即加载配置
|
||||
document.getElementById('save-reload-config-btn')?.addEventListener('click', async () => {
|
||||
const file = document.getElementById('config-select').value;
|
||||
const content = document.getElementById('config-editor').value;
|
||||
|
||||
try {
|
||||
const jsonData = JSON.parse(content);
|
||||
const result = await apiRequest('/admin/api/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
file: file,
|
||||
content: jsonData,
|
||||
reload: true, // 保存并立即加载
|
||||
}),
|
||||
});
|
||||
alert(result.message || '配置已保存并立即生效!');
|
||||
// 刷新日志以显示加载信息
|
||||
if (document.getElementById('page-logs')?.classList.contains('active')) {
|
||||
loadLogs();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('保存失败: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 工具函数
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 页面加载时初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadCurrentUser();
|
||||
loadSystemInfo();
|
||||
|
||||
// 定期更新系统信息
|
||||
setInterval(loadSystemInfo, 30000); // 每30秒更新一次
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
// 认证相关 JavaScript
|
||||
|
||||
// 检查是否已登录
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const token = getCookie('token');
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch('/admin/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// 已登录,跳转到管理页面
|
||||
window.location.href = '/admin';
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check error:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取 Cookie
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
return null;
|
||||
}
|
||||
|
||||
// API 请求封装
|
||||
async function apiRequest(url, options = {}) {
|
||||
const token = getCookie('token');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: '请求失败' }));
|
||||
throw new Error(error.error || '请求失败');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// 显示错误消息
|
||||
function showError(message) {
|
||||
const errorEl = document.getElementById('auth-error');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message;
|
||||
errorEl.classList.add('show');
|
||||
setTimeout(() => {
|
||||
errorEl.classList.remove('show');
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 登录表单处理
|
||||
const loginForm = document.getElementById('login-form');
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(loginForm);
|
||||
const data = {
|
||||
username: formData.get('username'),
|
||||
password: formData.get('password')
|
||||
};
|
||||
|
||||
const submitBtn = loginForm.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span>登录中...</span>';
|
||||
|
||||
try {
|
||||
const result = await apiRequest('/admin/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
// 设置 token cookie
|
||||
document.cookie = `token=${result.token}; path=/; max-age=86400; SameSite=Lax`;
|
||||
|
||||
// 跳转到管理页面
|
||||
window.location.href = '/admin';
|
||||
} catch (error) {
|
||||
showError(error.message || '登录失败');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 注册表单处理
|
||||
const registerForm = document.getElementById('register-form');
|
||||
if (registerForm) {
|
||||
// 密码强度检测
|
||||
const passwordInput = document.getElementById('password');
|
||||
const passwordStrength = document.getElementById('password-strength');
|
||||
|
||||
if (passwordInput && passwordStrength) {
|
||||
passwordInput.addEventListener('input', (e) => {
|
||||
const password = e.target.value;
|
||||
let strength = 'weak';
|
||||
|
||||
if (password.length >= 8) {
|
||||
const hasUpper = /[A-Z]/.test(password);
|
||||
const hasLower = /[a-z]/.test(password);
|
||||
const hasNumber = /[0-9]/.test(password);
|
||||
const hasSpecial = /[^A-Za-z0-9]/.test(password);
|
||||
|
||||
const score = [hasUpper, hasLower, hasNumber, hasSpecial].filter(Boolean).length;
|
||||
|
||||
if (score >= 3) {
|
||||
strength = 'strong';
|
||||
} else if (score >= 2) {
|
||||
strength = 'medium';
|
||||
}
|
||||
}
|
||||
|
||||
passwordStrength.className = `password-strength ${strength}`;
|
||||
});
|
||||
}
|
||||
|
||||
registerForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const password = document.getElementById('password').value;
|
||||
const confirmPassword = document.getElementById('confirm-password').value;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showError('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(registerForm);
|
||||
const data = {
|
||||
username: formData.get('username'),
|
||||
email: formData.get('email'),
|
||||
password: password
|
||||
};
|
||||
|
||||
const submitBtn = registerForm.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span>注册中...</span>';
|
||||
|
||||
try {
|
||||
const result = await apiRequest('/admin/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
// 设置 token cookie
|
||||
document.cookie = `token=${result.token}; path=/; max-age=86400; SameSite=Lax`;
|
||||
|
||||
// 跳转到管理页面
|
||||
window.location.href = '/admin';
|
||||
} catch (error) {
|
||||
showError(error.message || '注册失败');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 页面加载时检查认证状态
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 如果已经在登录/注册页面,不需要检查
|
||||
if (window.location.pathname.includes('/admin/login') ||
|
||||
window.location.pathname.includes('/admin/register')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已登录
|
||||
checkAuth();
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// 设置页面 JavaScript
|
||||
|
||||
// 加载数据库信息
|
||||
async function loadDatabaseInfo() {
|
||||
try {
|
||||
const data = await apiRequest('/admin/api/database');
|
||||
const configData = await apiRequest('/admin/api/database/config');
|
||||
|
||||
document.getElementById('db-type').textContent = data.database.type;
|
||||
document.getElementById('db-status').textContent = data.database.status;
|
||||
document.getElementById('db-status').className = 'info-value status-badge status-connected';
|
||||
|
||||
if (data.database.file) {
|
||||
document.getElementById('db-file-item').style.display = 'flex';
|
||||
document.getElementById('db-file').textContent = data.database.file;
|
||||
}
|
||||
|
||||
// 如果是 MySQL,显示密码修改界面
|
||||
if (configData.config.type === 'mysql') {
|
||||
document.getElementById('database-password-section').style.display = 'block';
|
||||
}
|
||||
|
||||
// 操作系统信息
|
||||
document.getElementById('os-type').textContent = data.os.os;
|
||||
document.getElementById('os-arch').textContent = data.os.arch;
|
||||
} catch (error) {
|
||||
console.error('Load database info error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载系统信息
|
||||
async function loadSystemInfo() {
|
||||
try {
|
||||
const data = await apiRequest('/admin/api/system');
|
||||
document.getElementById('stat-users').textContent = data.users;
|
||||
document.getElementById('stat-routes').textContent = data.routes;
|
||||
document.getElementById('stat-logs').textContent = data.logs;
|
||||
document.getElementById('stat-time').textContent = data.server_time;
|
||||
} catch (error) {
|
||||
console.error('Load system info error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新数据库密码
|
||||
const passwordForm = document.getElementById('password-form');
|
||||
if (passwordForm) {
|
||||
passwordForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const currentPassword = document.getElementById('current-password').value;
|
||||
const newPassword = document.getElementById('new-password').value;
|
||||
const confirmPassword = document.getElementById('confirm-password').value;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('新密码和确认密码不一致!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('确定要更新数据库 root 密码吗?\n\n更新后需要修改环境变量 DB_PASSWORD 并重启服务器!')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resultEl = document.getElementById('password-result');
|
||||
resultEl.className = '';
|
||||
resultEl.textContent = '正在更新密码...';
|
||||
resultEl.style.display = 'block';
|
||||
|
||||
try {
|
||||
const result = await apiRequest('/admin/api/database/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
confirm_password: confirmPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
resultEl.className = 'success';
|
||||
resultEl.textContent = result.message || '密码更新成功!';
|
||||
passwordForm.reset();
|
||||
} catch (error) {
|
||||
resultEl.className = 'error';
|
||||
resultEl.textContent = '更新失败: ' + error.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 转换数据库
|
||||
const convertDbBtn = document.getElementById('convert-db-btn');
|
||||
if (convertDbBtn) {
|
||||
convertDbBtn.addEventListener('click', async () => {
|
||||
const targetType = document.getElementById('target-db-type').value;
|
||||
|
||||
if (!confirm(`确定要转换数据库类型吗?\n\n目标类型: ${targetType.toUpperCase()}\n\n此操作会导出当前数据并导入到新数据库。请确保已备份数据!`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resultEl = document.getElementById('convert-result');
|
||||
resultEl.className = '';
|
||||
resultEl.textContent = '正在转换...';
|
||||
resultEl.style.display = 'block';
|
||||
|
||||
try {
|
||||
const result = await apiRequest('/admin/api/database/convert', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
target_type: targetType,
|
||||
}),
|
||||
});
|
||||
|
||||
resultEl.className = 'success';
|
||||
resultEl.textContent = result.message || '数据库转换成功!';
|
||||
loadDatabaseInfo();
|
||||
} catch (error) {
|
||||
resultEl.className = 'error';
|
||||
resultEl.textContent = '转换失败: ' + error.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 页面加载时初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadDatabaseInfo();
|
||||
loadSystemInfo();
|
||||
|
||||
// 定期更新系统信息
|
||||
setInterval(loadSystemInfo, 30000); // 每30秒更新一次
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"comment": "English Language Pack",
|
||||
"nav.home": "Home",
|
||||
"nav.toolbox": "Toolbox",
|
||||
"nav.logs": "Logs",
|
||||
"nav.settings": "Settings",
|
||||
"home.greeting.morning": "Good morning, have a wonderful day.",
|
||||
"home.greeting.noon": "Good afternoon, time for a break.",
|
||||
"home.greeting.afternoon": "Good afternoon, keep up the good work.",
|
||||
"home.greeting.evening": "Good evening, time to relax.",
|
||||
"home.greeting.night": "It's late, please rest.",
|
||||
"home.welcome": "Welcome to YMhut Box, wish you a great day.",
|
||||
"home.announcement": "Announcement",
|
||||
"home.announcement.failed": "Failed to load announcement...",
|
||||
"home.announcement.viewFull": "View Full Announcement",
|
||||
"home.search.placeholder": "Smart Search: Enter query...",
|
||||
"home.search.button": "Search",
|
||||
"home.search.disabled": "Smart Search is currently disabled",
|
||||
"home.search.results.stats": "Found about {count} results (in {time}ms)",
|
||||
"home.search.results.viewFull": "View Full Results (incl. Advanced Options)",
|
||||
"home.search.results.empty.title": "No results found for \"{query}\"",
|
||||
"home.search.results.empty.sub": "Please try different keywords.",
|
||||
"home.search.failed.title": "Search Failed",
|
||||
"home.updates": "Update Log",
|
||||
"home.updates.close": "Close",
|
||||
"home.updates.modal.title": "Full Announcement",
|
||||
"tool.smartSearch.name": "Smart Search",
|
||||
"tool.smartSearch.desc": "AI aggregated search for high-quality results",
|
||||
"common.loading": "Loading...",
|
||||
"common.search": "Search",
|
||||
"common.backToToolbox": "Back to Toolbox",
|
||||
"common.options": "Advanced Options",
|
||||
"common.error": "Error",
|
||||
"common.loading.tool": "Loading tool module...",
|
||||
"common.notification.title.success": "Success",
|
||||
"common.notification.title.error": "Error",
|
||||
"common.notification.title.info": "Info",
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.updates": "Update Management",
|
||||
"settings.about": "About",
|
||||
"settings.status.monitor": "Status Monitor",
|
||||
"settings.status.cpu": "CPU",
|
||||
"settings.status.mem": "Memory",
|
||||
"settings.status.gpu": "GPU",
|
||||
"settings.status.uptime": "Uptime",
|
||||
"settings.appearance.title": "Appearance Settings",
|
||||
"settings.appearance.theme": "Theme",
|
||||
"settings.appearance.language": "Language",
|
||||
"settings.appearance.language.auto": "Auto (Default)",
|
||||
"settings.appearance.language.zh-CN": "简体中文",
|
||||
"settings.appearance.language.en-US": "English",
|
||||
"settings.appearance.language.restartMsg": "Language change will take effect after restart.",
|
||||
"settings.appearance.bg": "Custom Background",
|
||||
"settings.appearance.bg.select": "Select",
|
||||
"settings.appearance.bg.clear": "Clear",
|
||||
"settings.appearance.bg.opacity": "Background Opacity",
|
||||
"settings.appearance.card.opacity": "Card Opacity",
|
||||
"settings.traffic.title": "Traffic Statistics",
|
||||
"settings.traffic.total": "Total Usage",
|
||||
"settings.traffic.chart.empty.title": "No traffic history",
|
||||
"settings.traffic.chart.empty.sub": "Data will be recorded starting today.",
|
||||
"settings.update.title": "Update Management",
|
||||
"settings.update.checkBtn": "Check for Updates",
|
||||
"settings.update.checking": "Checking...",
|
||||
"settings.update.checkDefault": "Click button to check for new version",
|
||||
"settings.about.title": "About & Environment",
|
||||
"settings.about.version": "Current Version",
|
||||
"settings.about.developer": "Developer",
|
||||
"settings.about.moreInfo": "More Info & Credits",
|
||||
"settings.about.env.title": "Installed Environments"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"comment": "简体中文语言包",
|
||||
"nav.home": "主页",
|
||||
"nav.toolbox": "工具箱",
|
||||
"nav.logs": "日志",
|
||||
"nav.settings": "设置",
|
||||
"home.greeting.morning": "早上好, 新的一天元气满满",
|
||||
"home.greeting.noon": "中午好, 午休时间到了",
|
||||
"home.greeting.afternoon": "下午好, 继续努力吧",
|
||||
"home.greeting.evening": "晚上好, 放松一下吧",
|
||||
"home.greeting.night": "凌晨了, 注意休息哦",
|
||||
"home.welcome": "欢迎使用 YMhut Box, 愿你拥有美好的一天。",
|
||||
"home.announcement": "公告",
|
||||
"home.announcement.failed": "公告加载失败...",
|
||||
"home.announcement.viewFull": "查看完整公告",
|
||||
"home.search.placeholder": "智能搜索:输入查询内容...",
|
||||
"home.search.button": "搜索",
|
||||
"home.search.disabled": "智能搜索工具当前不可用",
|
||||
"home.search.results.stats": "找到约 {count} 条结果 (耗时 {time}ms)",
|
||||
"home.search.results.viewFull": "查看完整结果 (含高级选项)",
|
||||
"home.search.results.empty.title": "未找到关于 \"{query}\" 的结果",
|
||||
"home.search.results.empty.sub": "请尝试更换关键词。",
|
||||
"home.search.failed.title": "搜索失败",
|
||||
"home.updates": "更新日志",
|
||||
"home.updates.close": "关闭",
|
||||
"home.updates.modal.title": "完整公告",
|
||||
"tool.smartSearch.name": "智能搜索",
|
||||
"tool.smartSearch.desc": "AI 聚合搜索,获取高质量结果",
|
||||
"common.loading": "加载中...",
|
||||
"common.search": "搜索",
|
||||
"common.backToToolbox": "返回工具箱",
|
||||
"common.options": "高级选项",
|
||||
"common.error": "错误",
|
||||
"common.loading.tool": "正在初始化工具模块...",
|
||||
"common.notification.title.success": "成功",
|
||||
"common.notification.title.error": "错误",
|
||||
"common.notification.title.info": "提示",
|
||||
"settings.appearance": "外观",
|
||||
"settings.updates": "更新管理",
|
||||
"settings.about": "关于",
|
||||
"settings.status.monitor": "状态监控",
|
||||
"settings.status.cpu": "CPU",
|
||||
"settings.status.mem": "内存",
|
||||
"settings.status.gpu": "GPU",
|
||||
"settings.status.uptime": "运行时长",
|
||||
"settings.appearance.title": "外观设置",
|
||||
"settings.appearance.theme": "界面主题",
|
||||
"settings.appearance.language": "语言 (Language)",
|
||||
"settings.appearance.language.auto": "自动 (Auto)",
|
||||
"settings.appearance.language.zh-CN": "简体中文",
|
||||
"settings.appearance.language.en-US": "English",
|
||||
"settings.appearance.language.restartMsg": "语言设置将在重启后生效。",
|
||||
"settings.appearance.bg": "自定义背景",
|
||||
"settings.appearance.bg.select": "选择",
|
||||
"settings.appearance.bg.clear": "清除",
|
||||
"settings.appearance.bg.opacity": "背景透明度",
|
||||
"settings.appearance.card.opacity": "卡片透明度",
|
||||
"settings.traffic.title": "流量统计",
|
||||
"settings.traffic.total": "累计使用流量",
|
||||
"settings.traffic.chart.empty.title": "暂无历史流量数据",
|
||||
"settings.traffic.chart.empty.sub": "数据将从今天开始记录",
|
||||
"settings.update.title": "更新管理",
|
||||
"settings.update.checkBtn": "检查更新",
|
||||
"settings.update.checking": "正在检查...",
|
||||
"settings.update.checkDefault": "点击按钮检查新版本",
|
||||
"settings.about.title": "关于与软件环境",
|
||||
"settings.about.version": "当前版本",
|
||||
"settings.about.developer": "开发者",
|
||||
"settings.about.moreInfo": "更多信息与鸣谢",
|
||||
"settings.about.env.title": "已安装的开发环境"
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"enabled": true,
|
||||
"icon": "fas fa-image",
|
||||
"id": "image",
|
||||
"layout": {
|
||||
"aspect_ratio": "16:9",
|
||||
"columns": 1,
|
||||
"show_preview": true,
|
||||
"transition_effect": "fade"
|
||||
},
|
||||
"name": "随机图片",
|
||||
"subcategories": [
|
||||
{
|
||||
"api_url": "https://xjj.ymhut.bid/xjj",
|
||||
"description": "精选小姐姐图片",
|
||||
"downloadable": true,
|
||||
"id": "xjj",
|
||||
"name": "小姐姐",
|
||||
"refresh_interval": 30,
|
||||
"supported_formats": [
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"webp"
|
||||
],
|
||||
"thumbnail_url": "https://pic2.zhimg.com/v2-379be37e0b4d372aa60046f9ce771f12_r.jpg"
|
||||
},
|
||||
{
|
||||
"api_url": "https://v2.xxapi.cn/api/baisi?return=302",
|
||||
"description": "随机白丝图片",
|
||||
"downloadable": true,
|
||||
"id": "baisi",
|
||||
"name": "白丝",
|
||||
"refresh_interval": 30,
|
||||
"supported_formats": [
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"webp"
|
||||
],
|
||||
"thumbnail_url": "https://n.sinaimg.cn/sinacn10112/760/w640h920/20200126/4b00-innckcf8208822.jpg"
|
||||
},
|
||||
{
|
||||
"api_url": "https://v2.xxapi.cn/api/heisi?return=302",
|
||||
"description": "随机黑丝图片",
|
||||
"downloadable": true,
|
||||
"id": "heisi",
|
||||
"name": "黑丝",
|
||||
"refresh_interval": 30,
|
||||
"supported_formats": [
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"webp"
|
||||
],
|
||||
"thumbnail_url": "https://img-baofun.zhhainiao.com/pcwallpaper_ugc_mobile/static/6902725194a8c081767ee82373d3b017.jpeg"
|
||||
},
|
||||
{
|
||||
"api_url": "https://api.pearapi.ai/api/beautifulgirl?type=image",
|
||||
"description": "三坑少女图(包含动漫、漫画、游戏)",
|
||||
"downloadable": true,
|
||||
"id": "third_girl",
|
||||
"name": "三坑少女(4K)",
|
||||
"refresh_interval": 30,
|
||||
"supported_formats": [
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"webp"
|
||||
],
|
||||
"thumbnail_url": "https://www.sgpjbg.com/FileUpload/News/c358f121-6683-490b-beed-6debb44e4824.jpg"
|
||||
},
|
||||
{
|
||||
"api_url": "https://apii.ctose.cn/api/cy/api/",
|
||||
"description": "miku的随机图",
|
||||
"downloadable": true,
|
||||
"id": "miku",
|
||||
"name": "初音未来",
|
||||
"refresh_interval": 30,
|
||||
"supported_formats": [
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"webp"
|
||||
],
|
||||
"thumbnail_url": "https://apii.ctose.cn/api/cy/api/"
|
||||
},
|
||||
{
|
||||
"api_url": "https://api.suyanw.cn/api/mao.php",
|
||||
"description": "猫羽雫的随机图",
|
||||
"downloadable": true,
|
||||
"id": "猫羽雫",
|
||||
"name": "猫羽雫",
|
||||
"refresh_interval": 30,
|
||||
"supported_formats": [
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"webp"
|
||||
],
|
||||
"thumbnail_url": "https://api.suyanw.cn/api/mao.php"
|
||||
},
|
||||
{
|
||||
"api_url": "https://api.suyanw.cn/api/scenery.php",
|
||||
"description": "随机高清壁纸",
|
||||
"downloadable": true,
|
||||
"id": "wappller",
|
||||
"name": "高清壁纸",
|
||||
"refresh_interval": 30,
|
||||
"supported_formats": [
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"webp"
|
||||
],
|
||||
"thumbnail_url": "https://api.suyanw.cn/api/scenery.php"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"icon": "fas fa-video",
|
||||
"id": "video",
|
||||
"layout": {
|
||||
"aspect_ratio": "16:9",
|
||||
"auto_play": false,
|
||||
"columns": 1,
|
||||
"show_preview": true,
|
||||
"transition_effect": "slide"
|
||||
},
|
||||
"name": "随机视频",
|
||||
"subcategories": [
|
||||
{
|
||||
"api_url": "https://dh.lt6.ltd/xjj/video.php",
|
||||
"description": "随机风格类型视频",
|
||||
"downloadable": true,
|
||||
"id": "radom_xjj_leixing",
|
||||
"name": "小姐姐不同风格视频",
|
||||
"refresh_interval": 60,
|
||||
"supported_formats": [
|
||||
"mp4",
|
||||
"webm"
|
||||
],
|
||||
"thumbnail_url": "https://n.sinaimg.cn/sinacn19/176/w888h888/20181119/0c26-hmhhnqt1050818.jpg"
|
||||
},
|
||||
{
|
||||
"api_url": "https://api.mmp.cc/api/miss?type=mp4",
|
||||
"description": "随机风格小姐姐的视频",
|
||||
"downloadable": true,
|
||||
"id": "radom_xjj_short",
|
||||
"name": "短视频",
|
||||
"refresh_interval": 60,
|
||||
"supported_formats": [
|
||||
"mp4",
|
||||
"webm"
|
||||
],
|
||||
"thumbnail_url": "https://weather-real.oss-cn-shanghai.aliyuncs.com/weather/2025-06-17/1750091559255t7FNOX.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"last_updated": "2025-09-9T17:45:00Z",
|
||||
"layout_version": "1.0.6",
|
||||
"ui_config": {
|
||||
"animations": {
|
||||
"duration": 300,
|
||||
"transition_effect": "fade"
|
||||
},
|
||||
"dark_mode": false,
|
||||
"default_view": "grid",
|
||||
"show_thumbnails": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"modules": []
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"ai-translation": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"baidu-hot": {
|
||||
"enabled": true,
|
||||
"message": "百度热榜接口正在维护,预计短时间内不会恢复。"
|
||||
},
|
||||
"bili-hot-ranking": {
|
||||
"enabled": true,
|
||||
"message": "B站热搜接口正在维护,预计短时间内不会恢复。"
|
||||
},
|
||||
"comment": "工具状态控制文件。 'enabled: false' 将禁用该工具。",
|
||||
"comment_screening_room": "随机放映室使用 '分类ID.子分类ID' 作为键",
|
||||
"dns-query": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"image.baisi": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"image.heisi": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"image.wappller": {
|
||||
"enabled": true,
|
||||
"message": "高清壁纸 暂时下线,请先浏览其他分类。"
|
||||
},
|
||||
"image.xjj": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"image.猫羽雫": {
|
||||
"enabled": true,
|
||||
"message": "猫羽雫 暂时下线,请先浏览其他分类。"
|
||||
},
|
||||
"ip-info": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"ip-query": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"smart-search": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"video.radom_xjj_leixing": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"api_keys": {
|
||||
"uapipro": ""
|
||||
},
|
||||
"app_version": "2.0.6.2",
|
||||
"build": "2",
|
||||
"channel": "stable",
|
||||
"title": "YMhut Box 2.0.6.2",
|
||||
"message": "本版本重点修复覆盖安装后白屏退出、用户目录 runtime 占用、语言包膨胀和设置页初始化问题,并继续完善 WinUI 3 工具型工作台体验。",
|
||||
"message_md": "# YMhut Box 2.0.6.2\n\n本版本继续收尾 WinUI 3 工具型工作台:修复覆盖安装/直启稳定性、用户目录 runtime 残留、自检结果页卡顿、排行榜/资讯显示、中文日志和设置页初始化问题,并新增安装器输出框、Markdown 公告、媒体播放器与随机放映室增强、价格/指标图表化展示。",
|
||||
"release_notes": "修复 EXE/latest 直启和覆盖安装后因语言资源布局导致的白屏退出;发布布局改为纯 lang\\zh-CN 与 lang\\en-US,移除多余语言包和旧 resources\\lang 压缩依赖;启动与自检链路禁止在用户数据目录保存 Runtime/runtime/Runtimes/runtimes 等运行时副本,旧残留会在启动和安装时清理;完善启动自检、安装完整性检查、服务状态结果页、工具箱与工具详情布局、结果/原始输出渲染、排行榜/资讯结构化显示、设置页控制中心和系统概况实时图表;修复设置页初始化失败、中文模式英文漏出、部分日志英文展示、天气胶囊图标缺失以及关闭确认记住选择等问题。",
|
||||
"release_notes_md": "## Bug 修复\n\n- 修复 EXE/latest 直启和覆盖安装后因语言资源布局导致的白屏退出,失败时写入清晰日志并显示可读错误。\n- 杜绝用户数据目录生成 Runtime/runtime/Runtimes/runtimes 或完整程序 payload 副本,旧残留会在启动和安装时清理。\n- 修复自检结果页加载大量历史明细时卡顿或短暂无响应的问题,改为确认后摘要优先、明细按需加载。\n- 修复中文模式下部分日志仍显示英文的问题,错误码、HTTP 状态和反馈状态仍保留必要原文。\n- 修复排行榜、热榜和资讯类工具被“已隐藏远程地址,仅展示脱敏来源名称”提示干扰后无法生成卡片的问题,同时继续隐藏远程 URL。\n- 修复设置页初始化失败、关闭确认记住选择、天气胶囊部分状态缺少动画图标等问题。\n\n## 支持增强\n\n- 发布布局统一为纯 `lang\\zh-CN` / `lang\\en-US`,移除多余语言包、根目录 culture 目录和旧 `resources\\lang` 压缩依赖。\n- 客户端公告、首页公告、关于页更新弹窗和更新日志弹窗支持 Markdown 标题、列表、表格、代码与链接,解析失败时回退纯文本。\n- 随机放映室支持远程媒体重定向解析,并为图片、视频、音频加载提供进度提示。\n- 数据类工具增强价格/指标结果展示,黄金价格等结果可同时显示摘要、表格和趋势折线图。\n\n## 新增能力\n\n- 安装引导程序在提取文件阶段新增只读输出框,展示旧版本清理、payload 提取、依赖检查和安装收尾进度。\n- 新增启动/安装完整性自检结果入口,服务状态页可确认后查看结果、复制摘要和导出 JSON。\n- 媒体播放器补齐播放列表、常用播放控制、倍速、音量、全屏、图片/视频/音频混播和常见系统解码格式入口。\n- 随机放映室迁移旧版展示思路,改为图片、视频、音频三段式远程媒体浏览。\n\n## 体验重构\n\n- 工具箱与工具详情页继续向高密度 WinUI 工具工作台收束,结果区固定提供“结果”和“原始输出”。\n- 设置控制中心增加分页滚动提示,系统概况图表补齐网格、坐标和实时信息。\n- 主题继续参考 Microsoft Store 与 Windows 媒体播放器的中性 Fluent 风格,不使用渐变,蓝色为主强调,橙/红仅用于警示。\n- 随机放映室和播放器 UI 更接近 Win11 媒体体验,加载、失败、保存、全屏等状态更清晰。",
|
||||
"category_list": [
|
||||
{
|
||||
"icon": "monitor",
|
||||
"id": "system",
|
||||
"name": "系统工具"
|
||||
},
|
||||
{
|
||||
"icon": "code",
|
||||
"id": "developer",
|
||||
"name": "开发工具"
|
||||
},
|
||||
{
|
||||
"icon": "image",
|
||||
"id": "image",
|
||||
"name": "图像工具"
|
||||
}
|
||||
],
|
||||
"detected_product": "YMhut Box",
|
||||
"detected_packages": {
|
||||
"YMhut Box": [
|
||||
{
|
||||
"version": "2.0.6.2",
|
||||
"extension": "exe",
|
||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.6.2.exe",
|
||||
"downloadPath": "/downloads/YMhut_Box_WinUI_Setup_2.0.6.2.exe",
|
||||
"size": "待发布",
|
||||
"sizeBytes": 0,
|
||||
"updateDate": "2026-06-14",
|
||||
"updateTime": "2026-06-14 00:00:00"
|
||||
},
|
||||
{
|
||||
"version": "2.0.6.2",
|
||||
"extension": "exe",
|
||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.6.2_Light.exe",
|
||||
"downloadPath": "/downloads/YMhut_Box_WinUI_Setup_2.0.6.2_Light.exe",
|
||||
"size": "待发布",
|
||||
"sizeBytes": 0,
|
||||
"updateDate": "2026-06-14",
|
||||
"updateTime": "2026-06-14 00:00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
"download_mirrors": [
|
||||
{
|
||||
"enabled": true,
|
||||
"id": "primary",
|
||||
"name": "官方直连",
|
||||
"sha256": "",
|
||||
"type": "direct",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.6.2.exe"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"id": "light",
|
||||
"name": "轻量安装包",
|
||||
"sha256": "",
|
||||
"type": "direct",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.6.2_Light.exe"
|
||||
}
|
||||
],
|
||||
"download_url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.6.2.exe",
|
||||
"home_notes": "v2.0.6.2 聚焦安装布局、启动稳定性和 WinUI 工作台体验:覆盖安装会清理旧程序布局和 runtime 残留,用户目录只保留设置、日志、缓存和轻量状态;语言资源只保留中英双语并归入 lang;工具箱、工具详情、结果渲染、排行榜/资讯卡片、设置控制中心、系统概况和服务状态页继续向原生 Fluent 工具软件风格收束。",
|
||||
"last_update_notes": {
|
||||
"v2.0.5.3 稳定性": "延续启动首页、插件扫描、反馈中心和日志展示修复;本版本进一步处理覆盖安装、语言资源和用户目录 runtime 残留。",
|
||||
"v2.0.5.3 安装包体积": "上一版新增轻量安装包通道;本版本继续收束语言包和旧资源布局,避免无关文件混入发布目录。",
|
||||
"v2.0.5.3 设置外观": "上一版加入窗口材质设置;本版本继续完善设置页控制中心、系统概况实时图表和中文本地化。"
|
||||
},
|
||||
"last_updated": "2026-06-14T00:00:00Z",
|
||||
"tool_metadata": {},
|
||||
"update_notes": {
|
||||
"启动与覆盖安装": "修复覆盖安装后启动白屏或应用自行退出的问题;旧压缩语言布局不再回退复制到用户目录,失败时写入清晰日志并显示可读错误。",
|
||||
"用户目录瘦身": "用户数据目录不再生成 Runtime、runtime、Runtimes 或 runtimes 文件夹;启动、自检和安装器都会清理旧 runtime 残留,避免大型运行时副本占用本机存储。",
|
||||
"语言资源布局": "发布布局统一为纯 lang,保留 lang\\zh-CN 与 lang\\en-US;移除其他语言包、根目录 culture 目录和 resources\\lang 压缩残留。",
|
||||
"启动自检": "迁移并改造旧版启动初始化思路:快速预检负责用户目录、设置、数据库、下载队列、安装根和关键资源;月度完整性检查在窗口可用后后台运行。",
|
||||
"自检结果页": "服务状态页新增启动自检与安装完整性入口;查看结果前增加确认提示和加载进度,历史先加载摘要,用户选择后再读取完整明细,降低大量结果渲染造成的卡顿。",
|
||||
"排行榜与资讯": "修复部分排行榜、热榜和资讯类工具因“已隐藏远程地址,仅展示脱敏来源名称”提示混入结构化解析而无法显示卡片内容的问题,同时继续隐藏远程地址。",
|
||||
"工具箱与工具详情": "工具箱改为更高密度的原生 WinUI 工作台布局;工具内容区域按功能类型优化输入、结果、原始输出、复制、搜索和导出体验。",
|
||||
"设置页": "修复设置页初始化失败;控制中心分页限制为 5 项可视并增加动态上下箭头提示;系统概况加入网格坐标、暗色绘图区和更多实时系统信息。",
|
||||
"主题与视觉": "主题改为微软商店/媒体播放器式中性 Fluent 风格,移除渐变主视觉,蓝色作为主强调色,橙红仅用于更新、警告和风险行为。",
|
||||
"天气胶囊": "补齐阴天、未知、离线等状态的基础图标和轻量动效,遵守关闭动画与高对比度设置。",
|
||||
"本地化与日志": "修复工具箱与安全、风险确认、默认工具范围、设置弹窗和高频日志的中文模式英文漏出;反馈码和原始错误信息仍保留必要英文。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ConfigCache 配置缓存
|
||||
type ConfigCache struct {
|
||||
mu sync.RWMutex
|
||||
toolStatus map[string]interface{}
|
||||
updateInfo map[string]interface{}
|
||||
mediaTypes map[string]interface{}
|
||||
}
|
||||
|
||||
var configCache = &ConfigCache{}
|
||||
|
||||
// LoadConfig 加载配置文件
|
||||
func LoadConfig(filename string) (map[string]interface{}, error) {
|
||||
filePath := filepath.Join("public", filename)
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// SaveConfig 保存配置文件并更新缓存
|
||||
func SaveConfig(filename string, config map[string]interface{}) error {
|
||||
filePath := filepath.Join("public", filename)
|
||||
|
||||
// 转换为 JSON
|
||||
jsonData, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
if err := os.WriteFile(filePath, jsonData, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
configCache.mu.Lock()
|
||||
defer configCache.mu.Unlock()
|
||||
|
||||
switch filename {
|
||||
case "tool-status.json":
|
||||
configCache.toolStatus = config
|
||||
case "update-info.json":
|
||||
configCache.updateInfo = config
|
||||
case "media-types.json":
|
||||
configCache.mediaTypes = config
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCachedConfig 获取缓存的配置
|
||||
func GetCachedConfig(filename string) (map[string]interface{}, bool) {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
|
||||
switch filename {
|
||||
case "tool-status.json":
|
||||
if configCache.toolStatus != nil {
|
||||
return configCache.toolStatus, true
|
||||
}
|
||||
case "update-info.json":
|
||||
if configCache.updateInfo != nil {
|
||||
return configCache.updateInfo, true
|
||||
}
|
||||
case "media-types.json":
|
||||
if configCache.mediaTypes != nil {
|
||||
return configCache.mediaTypes, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ReloadConfig 重新加载配置
|
||||
func ReloadConfig(filename string) error {
|
||||
config, err := LoadConfig(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SaveConfig(filename, config)
|
||||
}
|
||||
|
||||
// InitConfigCache 初始化配置缓存
|
||||
func InitConfigCache() error {
|
||||
files := []string{"tool-status.json", "update-info.json", "media-types.json"}
|
||||
|
||||
for _, file := range files {
|
||||
if _, err := os.Stat(filepath.Join("public", file)); err == nil {
|
||||
config, err := LoadConfig(file)
|
||||
if err == nil {
|
||||
SaveConfig(file, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestYmhutWinUIInstallerAliasesMatchLegacyInstaller(t *testing.T) {
|
||||
cases := []string{
|
||||
"YMhut_Box_WinUI_Setup_2.0.6.0.exe",
|
||||
"YMhut_Box_Setup_2.0.5.exe",
|
||||
}
|
||||
|
||||
for _, fileName := range cases {
|
||||
productName, version, _, ok := parsePackageFileName(fileName)
|
||||
if !ok {
|
||||
t.Fatalf("expected %s to be parsed", fileName)
|
||||
}
|
||||
if !IsSameProduct(productName, "YMhut Box") {
|
||||
t.Fatalf("expected %q from %s to match YMhut Box", productName, fileName)
|
||||
}
|
||||
if version == "" {
|
||||
t.Fatalf("expected version from %s", fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var jwtSecret = []byte("your-secret-key-change-in-production") // 生产环境应该使用环境变量
|
||||
|
||||
// Claims JWT 声明
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateToken 生成 JWT Token
|
||||
func GenerateToken(userID uint, username string, isAdmin bool) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
IsAdmin: isAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(jwtSecret)
|
||||
}
|
||||
|
||||
// ParseToken 解析 JWT Token
|
||||
func ParseToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("无效的 token")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ANSI 颜色代码
|
||||
const (
|
||||
ColorReset = "\033[0m"
|
||||
ColorBright = "\033[1m"
|
||||
ColorRed = "\033[31m"
|
||||
ColorGreen = "\033[32m"
|
||||
ColorYellow = "\033[33m"
|
||||
ColorBlue = "\033[34m"
|
||||
ColorCyan = "\033[36m"
|
||||
ColorGray = "\033[90m"
|
||||
)
|
||||
|
||||
// Logger 日志记录器
|
||||
type Logger struct{}
|
||||
|
||||
// NewLogger 创建新的日志记录器
|
||||
func NewLogger() *Logger {
|
||||
return &Logger{}
|
||||
}
|
||||
|
||||
// log 格式化日志条目
|
||||
func (l *Logger) log(level, color, message string) {
|
||||
timestamp := time.Now().Format(time.RFC3339)
|
||||
levelTag := fmt.Sprintf("%s%-7s%s", color, level, ColorReset)
|
||||
|
||||
// 格式化时间戳
|
||||
formattedTime := fmt.Sprintf("%s%s%s", ColorGray, timestamp, ColorReset)
|
||||
|
||||
// 根据级别选择输出方式
|
||||
if level == "ERROR" {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s] %s\n", formattedTime, levelTag, message)
|
||||
} else {
|
||||
fmt.Printf("%s [%s] %s\n", formattedTime, levelTag, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Info 绿色 "INFO" 级别日志
|
||||
func (l *Logger) Info(message string) {
|
||||
l.log("INFO", ColorGreen, message)
|
||||
}
|
||||
|
||||
// Warn 黄色 "WARN" 级别日志
|
||||
func (l *Logger) Warn(message string) {
|
||||
l.log("WARN", ColorYellow, message)
|
||||
}
|
||||
|
||||
// Error 红色 "ERROR" 级别日志
|
||||
func (l *Logger) Error(message string) {
|
||||
l.log("ERROR", ColorBright+ColorRed, message)
|
||||
}
|
||||
|
||||
// System 青色 "SYSTEM" 级别日志
|
||||
func (l *Logger) System(message string) {
|
||||
l.log("SYSTEM", ColorCyan, message)
|
||||
}
|
||||
|
||||
// HTTP 蓝色 "HTTP" 级别日志
|
||||
func (l *Logger) HTTP(message string) {
|
||||
l.log("HTTP", ColorBlue, message)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OSInfo 操作系统信息
|
||||
type OSInfo struct {
|
||||
OS string // 操作系统类型: windows, linux, darwin
|
||||
Arch string // 架构: amd64, arm64, 386
|
||||
IsCGO bool // 是否支持 CGO
|
||||
DataDir string // 数据目录路径
|
||||
}
|
||||
|
||||
var currentOS *OSInfo
|
||||
|
||||
// DetectOS 检测操作系统环境
|
||||
func DetectOS() *OSInfo {
|
||||
if currentOS != nil {
|
||||
return currentOS
|
||||
}
|
||||
|
||||
osType := runtime.GOOS
|
||||
arch := runtime.GOARCH
|
||||
|
||||
// 检测 CGO 支持
|
||||
// 注意:CGO 支持在编译时确定,运行时无法准确检测
|
||||
// 这里通过检查环境变量或尝试使用 SQLite 来判断
|
||||
isCGO := true
|
||||
// 如果设置了 CGO_ENABLED=0,则不支持 CGO
|
||||
if cgoEnv := os.Getenv("CGO_ENABLED"); cgoEnv == "0" {
|
||||
isCGO = false
|
||||
}
|
||||
|
||||
// 确定数据目录
|
||||
dataDir := "data"
|
||||
if osType == "windows" {
|
||||
// Windows 使用相对路径
|
||||
dataDir = "data"
|
||||
} else {
|
||||
// Linux/macOS 使用相对路径
|
||||
dataDir = "data"
|
||||
}
|
||||
|
||||
currentOS = &OSInfo{
|
||||
OS: normalizeOS(osType),
|
||||
Arch: normalizeArch(arch),
|
||||
IsCGO: isCGO,
|
||||
DataDir: dataDir,
|
||||
}
|
||||
|
||||
return currentOS
|
||||
}
|
||||
|
||||
// normalizeOS 标准化操作系统名称
|
||||
func normalizeOS(os string) string {
|
||||
os = strings.ToLower(os)
|
||||
switch os {
|
||||
case "windows":
|
||||
return "windows"
|
||||
case "linux":
|
||||
return "linux"
|
||||
case "darwin":
|
||||
return "darwin"
|
||||
case "freebsd", "openbsd", "netbsd":
|
||||
return "unix"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeArch 标准化架构名称
|
||||
func normalizeArch(arch string) string {
|
||||
arch = strings.ToLower(arch)
|
||||
switch arch {
|
||||
case "amd64", "x86_64":
|
||||
return "amd64"
|
||||
case "386", "i386", "i686":
|
||||
return "386"
|
||||
case "arm64", "aarch64":
|
||||
return "arm64"
|
||||
case "arm":
|
||||
return "arm"
|
||||
default:
|
||||
return arch
|
||||
}
|
||||
}
|
||||
|
||||
// GetOSInfo 获取操作系统信息
|
||||
func GetOSInfo() *OSInfo {
|
||||
return DetectOS()
|
||||
}
|
||||
|
||||
// IsWindows 判断是否为 Windows
|
||||
func IsWindows() bool {
|
||||
return DetectOS().OS == "windows"
|
||||
}
|
||||
|
||||
// IsLinux 判断是否为 Linux
|
||||
func IsLinux() bool {
|
||||
return DetectOS().OS == "linux"
|
||||
}
|
||||
|
||||
// IsDarwin 判断是否为 macOS
|
||||
func IsDarwin() bool {
|
||||
return DetectOS().OS == "darwin"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPasswordTooShort = errors.New("密码长度至少为8个字符")
|
||||
ErrPasswordNoUpper = errors.New("密码必须包含至少一个大写字母")
|
||||
ErrPasswordNoLower = errors.New("密码必须包含至少一个小写字母")
|
||||
ErrPasswordNoDigit = errors.New("密码必须包含至少一个数字")
|
||||
ErrPasswordNoSpecial = errors.New("密码必须包含至少一个特殊字符")
|
||||
ErrPasswordCommon = errors.New("密码不能是常见弱密码")
|
||||
ErrPasswordSameChars = errors.New("密码不能全部是相同字符")
|
||||
)
|
||||
|
||||
// 常见弱密码列表
|
||||
var commonPasswords = []string{
|
||||
"password", "12345678", "123456789", "1234567890",
|
||||
"qwerty", "abc123", "password123", "admin123",
|
||||
"123456", "1234567", "12345", "1234",
|
||||
"admin", "root", "user", "test",
|
||||
}
|
||||
|
||||
// ValidatePasswordStrength 验证密码强度
|
||||
func ValidatePasswordStrength(password string) error {
|
||||
// 检查长度
|
||||
if len(password) < 8 {
|
||||
return ErrPasswordTooShort
|
||||
}
|
||||
|
||||
// 检查是否全部相同字符
|
||||
allSame := true
|
||||
for i := 1; i < len(password); i++ {
|
||||
if password[i] != password[0] {
|
||||
allSame = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allSame {
|
||||
return ErrPasswordSameChars
|
||||
}
|
||||
|
||||
// 检查是否包含大写字母
|
||||
hasUpper := false
|
||||
// 检查是否包含小写字母
|
||||
hasLower := false
|
||||
// 检查是否包含数字
|
||||
hasDigit := false
|
||||
// 检查是否包含特殊字符
|
||||
hasSpecial := false
|
||||
|
||||
for _, char := range password {
|
||||
switch {
|
||||
case unicode.IsUpper(char):
|
||||
hasUpper = true
|
||||
case unicode.IsLower(char):
|
||||
hasLower = true
|
||||
case unicode.IsDigit(char):
|
||||
hasDigit = true
|
||||
case unicode.IsPunct(char) || unicode.IsSymbol(char):
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUpper {
|
||||
return ErrPasswordNoUpper
|
||||
}
|
||||
if !hasLower {
|
||||
return ErrPasswordNoLower
|
||||
}
|
||||
if !hasDigit {
|
||||
return ErrPasswordNoDigit
|
||||
}
|
||||
if !hasSpecial {
|
||||
return ErrPasswordNoSpecial
|
||||
}
|
||||
|
||||
// 检查是否是常见弱密码
|
||||
lowerPassword := regexp.MustCompile(`[^a-z]`).ReplaceAllString(password, "")
|
||||
for _, common := range commonPasswords {
|
||||
if lowerPassword == common {
|
||||
return ErrPasswordCommon
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HashPassword 加密密码
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPassword 验证密码
|
||||
func CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FileInfo struct {
|
||||
Version string `json:"version"`
|
||||
Extension string `json:"extension"`
|
||||
FileName string `json:"fileName"`
|
||||
DownloadPath string `json:"downloadPath"`
|
||||
Size string `json:"size"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
UpdateDate string `json:"updateDate"`
|
||||
UpdateTime string `json:"updateTime"`
|
||||
}
|
||||
|
||||
type ProductsInfo map[string][]FileInfo
|
||||
|
||||
var (
|
||||
supportedPackageExtOrder = []string{
|
||||
".tar.gz",
|
||||
".appimage",
|
||||
".msi",
|
||||
".exe",
|
||||
".zip",
|
||||
".pkg",
|
||||
".dmg",
|
||||
".apk",
|
||||
".deb",
|
||||
".rpm",
|
||||
}
|
||||
versionPattern = regexp.MustCompile(`(?i)(?:^|[ _-])v?(\d+(?:\.\d+){1,4})(?:$|[ _-])`)
|
||||
)
|
||||
|
||||
func FormatBytes(bytes int64, precision int) string {
|
||||
if bytes == 0 {
|
||||
return "0 B"
|
||||
}
|
||||
|
||||
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||||
if precision == 0 {
|
||||
precision = 2
|
||||
}
|
||||
|
||||
bytesFloat := float64(bytes)
|
||||
if bytesFloat < 0 {
|
||||
bytesFloat = 0
|
||||
}
|
||||
|
||||
pow := 0
|
||||
if bytesFloat > 0 {
|
||||
pow = int(float64(len(fmt.Sprintf("%.0f", bytesFloat))-1) / 3.321928)
|
||||
}
|
||||
if pow >= len(units) {
|
||||
pow = len(units) - 1
|
||||
}
|
||||
|
||||
divisor := int64(1)
|
||||
for i := 0; i < pow; i++ {
|
||||
divisor *= 1024
|
||||
}
|
||||
|
||||
converted := bytesFloat / float64(divisor)
|
||||
return fmt.Sprintf("%.*f %s", precision, converted, units[pow])
|
||||
}
|
||||
|
||||
func GetProductsInfo(downloadDir string, logger *Logger) ProductsInfo {
|
||||
products := make(ProductsInfo)
|
||||
|
||||
if _, err := os.Stat(downloadDir); os.IsNotExist(err) {
|
||||
logger.Error(fmt.Sprintf("读取下载目录失败: %s - 错误: %s", downloadDir, err.Error()))
|
||||
return nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(downloadDir)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("读取下载目录失败: %s - 错误: %s", downloadDir, err.Error()))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
fileName := entry.Name()
|
||||
productName, version, ext, ok := parsePackageFileName(fileName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(downloadDir, fileName)
|
||||
stat, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
logger.Warn(fmt.Sprintf("获取文件统计信息失败: %s - 错误: %s", fileName, err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
fileInfoData := FileInfo{
|
||||
Version: version,
|
||||
Extension: strings.TrimPrefix(ext, "."),
|
||||
FileName: fileName,
|
||||
DownloadPath: "/downloads/" + fileName,
|
||||
Size: FormatBytes(stat.Size(), 2),
|
||||
SizeBytes: stat.Size(),
|
||||
UpdateDate: stat.ModTime().Format("2006-01-02"),
|
||||
UpdateTime: stat.ModTime().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
products[productName] = append(products[productName], fileInfoData)
|
||||
}
|
||||
|
||||
for productName := range products {
|
||||
sort.Slice(products[productName], func(i, j int) bool {
|
||||
v1 := products[productName][i].Version
|
||||
v2 := products[productName][j].Version
|
||||
if compareVersions(v1, v2) == 0 {
|
||||
return products[productName][i].UpdateTime > products[productName][j].UpdateTime
|
||||
}
|
||||
return compareVersions(v1, v2) > 0
|
||||
})
|
||||
}
|
||||
|
||||
return products
|
||||
}
|
||||
|
||||
func GetLatestProductRelease(products ProductsInfo, preferredProduct string) (string, *FileInfo) {
|
||||
if len(products) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var selectedProduct string
|
||||
var selectedRelease *FileInfo
|
||||
|
||||
if preferredProduct != "" {
|
||||
for productName, releases := range products {
|
||||
if !IsSameProduct(productName, preferredProduct) || len(releases) == 0 {
|
||||
continue
|
||||
}
|
||||
candidate := releases[0]
|
||||
if selectedRelease == nil || isNewerRelease(candidate, *selectedRelease) {
|
||||
selectedProduct = productName
|
||||
selectedRelease = &candidate
|
||||
}
|
||||
}
|
||||
if selectedRelease != nil {
|
||||
return selectedProduct, selectedRelease
|
||||
}
|
||||
}
|
||||
|
||||
for productName, releases := range products {
|
||||
if len(releases) == 0 {
|
||||
continue
|
||||
}
|
||||
candidate := releases[0]
|
||||
if selectedRelease == nil || isNewerRelease(candidate, *selectedRelease) {
|
||||
selectedProduct = productName
|
||||
selectedRelease = &candidate
|
||||
}
|
||||
}
|
||||
|
||||
return selectedProduct, selectedRelease
|
||||
}
|
||||
|
||||
func IsSameProduct(productName string, preferredProduct string) bool {
|
||||
return productAliasKey(productName) == productAliasKey(preferredProduct)
|
||||
}
|
||||
|
||||
func productAliasKey(name string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
key = strings.NewReplacer(" ", "", "_", "", "-", "", ".", "").Replace(key)
|
||||
switch key {
|
||||
case "ymhutbox", "ymhut":
|
||||
return "ymhutbox"
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func isNewerRelease(candidate FileInfo, current FileInfo) bool {
|
||||
versionCompare := compareVersions(candidate.Version, current.Version)
|
||||
if versionCompare != 0 {
|
||||
return versionCompare > 0
|
||||
}
|
||||
return candidate.UpdateTime > current.UpdateTime
|
||||
}
|
||||
|
||||
func parsePackageFileName(fileName string) (string, string, string, bool) {
|
||||
ext, stem, ok := splitPackageExtension(fileName)
|
||||
if !ok {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
version := extractVersion(stem)
|
||||
productName := normalizeProductName(stem, version)
|
||||
if productName == "" {
|
||||
productName = stem
|
||||
}
|
||||
|
||||
if version == "" {
|
||||
version = "未标注"
|
||||
}
|
||||
|
||||
return productName, version, ext, true
|
||||
}
|
||||
|
||||
func splitPackageExtension(fileName string) (string, string, bool) {
|
||||
lower := strings.ToLower(fileName)
|
||||
for _, ext := range supportedPackageExtOrder {
|
||||
if strings.HasSuffix(lower, ext) {
|
||||
return ext, fileName[:len(fileName)-len(ext)], true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func extractVersion(stem string) string {
|
||||
matches := versionPattern.FindStringSubmatch(stem)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeProductName(stem string, version string) string {
|
||||
name := stem
|
||||
if version != "" {
|
||||
name = versionPattern.ReplaceAllString(name, " ")
|
||||
}
|
||||
|
||||
replacements := []string{
|
||||
"setup", "installer", "install", "release", "portable",
|
||||
"windows", "window", "win", "linux", "android", "macos", "darwin",
|
||||
"x64", "x86", "arm64", "amd64", "universal", "desktop",
|
||||
}
|
||||
|
||||
for _, token := range replacements {
|
||||
re := regexp.MustCompile(`(?i)(^|[ _-])` + regexp.QuoteMeta(token) + `($|[ _-])`)
|
||||
name = re.ReplaceAllString(name, " ")
|
||||
}
|
||||
|
||||
name = strings.NewReplacer("_", " ", "-", " ").Replace(name)
|
||||
name = strings.Join(strings.Fields(name), " ")
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
func compareVersions(v1, v2 string) int {
|
||||
if v1 == "未标注" && v2 == "未标注" {
|
||||
return 0
|
||||
}
|
||||
if v1 == "未标注" {
|
||||
return -1
|
||||
}
|
||||
if v2 == "未标注" {
|
||||
return 1
|
||||
}
|
||||
|
||||
parts1 := strings.Split(v1, ".")
|
||||
parts2 := strings.Split(v2, ".")
|
||||
|
||||
maxLen := len(parts1)
|
||||
if len(parts2) > maxLen {
|
||||
maxLen = len(parts2)
|
||||
}
|
||||
|
||||
for i := 0; i < maxLen; i++ {
|
||||
num1 := 0
|
||||
num2 := 0
|
||||
if i < len(parts1) {
|
||||
fmt.Sscanf(parts1[i], "%d", &num1)
|
||||
}
|
||||
if i < len(parts2) {
|
||||
fmt.Sscanf(parts2[i], "%d", &num2)
|
||||
}
|
||||
|
||||
if num1 > num2 {
|
||||
return 1
|
||||
}
|
||||
if num1 < num2 {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func FileExists(filePath string) bool {
|
||||
_, err := os.Stat(filePath)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func ReadJSONFile(filePath string) (map[string]interface{}, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func GetMimeType(filePath string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
mimeTypes := map[string]string{
|
||||
".json": "application/json; charset=utf-8",
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".exe": "application/octet-stream",
|
||||
".zip": "application/zip",
|
||||
".pkg": "application/octet-stream",
|
||||
".dmg": "application/x-apple-diskimage",
|
||||
".msi": "application/x-msi",
|
||||
".apk": "application/vnd.android.package-archive",
|
||||
".deb": "application/vnd.debian.binary-package",
|
||||
".rpm": "application/x-rpm",
|
||||
}
|
||||
|
||||
if mime, ok := mimeTypes[ext]; ok {
|
||||
return mime
|
||||
}
|
||||
|
||||
if strings.HasSuffix(strings.ToLower(filePath), ".tar.gz") {
|
||||
return "application/gzip"
|
||||
}
|
||||
|
||||
return "application/octet-stream"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetLatestProductReleaseUsesYmhutBoxAliases(t *testing.T) {
|
||||
products := ProductsInfo{
|
||||
"YmhutBox": {
|
||||
{
|
||||
Version: "2.0.0",
|
||||
FileName: "YmhutBox Setup 2.0.0.exe",
|
||||
DownloadPath: "/downloads/YmhutBox Setup 2.0.0.exe",
|
||||
UpdateTime: "2026-04-28 04:48:25",
|
||||
},
|
||||
},
|
||||
"YMhut Box": {
|
||||
{
|
||||
Version: "2.0.1",
|
||||
FileName: "YMhut_Box_Setup_2.0.1.exe",
|
||||
DownloadPath: "/downloads/YMhut_Box_Setup_2.0.1.exe",
|
||||
UpdateTime: "2026-04-30 17:27:21",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
productName, release := GetLatestProductRelease(products, "YMhut Box")
|
||||
if release == nil {
|
||||
t.Fatal("expected a release")
|
||||
}
|
||||
if productName != "YMhut Box" {
|
||||
t.Fatalf("expected YMhut Box alias to win, got %q", productName)
|
||||
}
|
||||
if release.Version != "2.0.1" {
|
||||
t.Fatalf("expected version 2.0.1, got %q", release.Version)
|
||||
}
|
||||
if release.FileName != "YMhut_Box_Setup_2.0.1.exe" {
|
||||
t.Fatalf("expected new installer, got %q", release.FileName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.title}}</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="icon" href="/img/favicon.png" type="image/png">
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-page">
|
||||
<div class="container">
|
||||
<div class="error-code error-404">404</div>
|
||||
<h2 class="error-title">页面未找到</h2>
|
||||
<p class="error-message">
|
||||
抱歉,您访问的页面不存在或已被移动
|
||||
</p>
|
||||
<div class="error-path">
|
||||
访问路径: {{.path}}
|
||||
</div>
|
||||
<a href="/" class="back-home-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
返回首页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.title}}</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="icon" href="/img/favicon.png" type="image/png">
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-page">
|
||||
<div class="container">
|
||||
<div class="error-code error-500">500</div>
|
||||
<h2 class="error-title">服务器内部错误</h2>
|
||||
<p class="error-message">
|
||||
抱歉,服务器在处理您的请求时发生错误
|
||||
</p>
|
||||
{{if .message}}
|
||||
<div class="error-path">
|
||||
错误信息: {{.message}}
|
||||
</div>
|
||||
{{end}}
|
||||
<a href="/" class="back-home-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
返回首页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,253 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.title}} - 后台管理</title>
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" href="/img/favicon.png" type="image/png">
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
<header class="admin-header">
|
||||
<div class="header-left">
|
||||
<h1>后台管理</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="current-user"></span>
|
||||
<a href="/admin/settings" class="btn btn-icon" title="设置">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 1v6m0 6v6M5.64 5.64l4.24 4.24m4.24 4.24l4.24 4.24M1 12h6m6 0h6M5.64 18.36l4.24-4.24m4.24-4.24l4.24-4.24"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button id="logout-btn" class="btn btn-secondary">登出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-layout">
|
||||
<aside class="admin-sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<a href="#" class="nav-item active" data-page="dashboard">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
</svg>
|
||||
<span>仪表盘</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="routes">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
||||
</svg>
|
||||
<span>路由管理</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="files">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
</svg>
|
||||
<span>文件管理</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="config">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M12 1v6m0 6v6M5.64 5.64l4.24 4.24m4.24 4.24l4.24 4.24M1 12h6m6 0h6M5.64 18.36l4.24-4.24m4.24-4.24l4.24-4.24"></path>
|
||||
</svg>
|
||||
<span>配置管理</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="logs">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 11 12 14 22 4"></polyline>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||
</svg>
|
||||
<span>日志查看</span>
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="admin-content">
|
||||
<!-- 仪表盘 -->
|
||||
<div id="page-dashboard" class="page active">
|
||||
<div class="page-header">
|
||||
<h2>系统概览</h2>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">👥</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="stat-users">-</div>
|
||||
<div class="stat-label">用户总数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🛣️</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="stat-routes">-</div>
|
||||
<div class="stat-label">路由总数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📝</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="stat-logs">-</div>
|
||||
<div class="stat-label">日志条目</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⏰</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="stat-time">-</div>
|
||||
<div class="stat-label">服务器时间</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 路由管理 -->
|
||||
<div id="page-routes" class="page">
|
||||
<div class="page-header">
|
||||
<h2>路由管理</h2>
|
||||
<button class="btn btn-primary" id="add-route-btn">添加路由</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>方法</th>
|
||||
<th>路径</th>
|
||||
<th>类型</th>
|
||||
<th>描述</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="routes-table-body">
|
||||
<tr>
|
||||
<td colspan="7" class="empty-state">加载中...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件管理 -->
|
||||
<div id="page-files" class="page">
|
||||
<div class="page-header">
|
||||
<h2>文件管理</h2>
|
||||
<button class="btn btn-primary" id="refresh-files-btn">刷新</button>
|
||||
</div>
|
||||
<div class="file-browser" id="file-browser">
|
||||
<div class="empty-state">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配置管理 -->
|
||||
<div id="page-config" class="page">
|
||||
<div class="page-header">
|
||||
<h2>配置管理</h2>
|
||||
</div>
|
||||
<div class="config-controls">
|
||||
<select id="config-select">
|
||||
<option value="tool-status.json">tool-status.json</option>
|
||||
<option value="update-info.json">update-info.json</option>
|
||||
<option value="media-types.json">media-types.json</option>
|
||||
<option value="modules.json">modules.json</option>
|
||||
<option value="plugins.json">plugins.json</option>
|
||||
<option value="lang/zh-CN.json">lang/zh-CN.json</option>
|
||||
<option value="lang/en-US.json">lang/en-US.json</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" id="load-config-btn">加载配置</button>
|
||||
</div>
|
||||
<div class="editor-container">
|
||||
<textarea id="config-editor" class="code-editor"></textarea>
|
||||
</div>
|
||||
<div class="button-group" style="margin-top: 1rem;">
|
||||
<button id="save-config-btn" class="btn btn-primary">保存配置</button>
|
||||
<button id="save-reload-config-btn" class="btn btn-success">保存并立即加载</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志查看 -->
|
||||
<div id="page-logs" class="page">
|
||||
<div class="page-header">
|
||||
<h2>日志查看</h2>
|
||||
<div class="button-group">
|
||||
<button class="btn btn-primary" id="refresh-logs-btn">刷新</button>
|
||||
<button class="btn btn-secondary" id="clear-logs-btn">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logs-container">
|
||||
<div id="logs-content" class="logs-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 路由编辑模态框 -->
|
||||
<div id="route-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="route-modal-title">添加路由</h3>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="route-form">
|
||||
<input type="hidden" id="route-id">
|
||||
<div class="form-group">
|
||||
<label>HTTP 方法</label>
|
||||
<select id="route-method" required>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>路径</label>
|
||||
<input type="text" id="route-path" required placeholder="/api/example">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>类型</label>
|
||||
<select id="route-type" required>
|
||||
<option value="view">视图 (view)</option>
|
||||
<option value="json">JSON (json)</option>
|
||||
<option value="file">文件 (file)</option>
|
||||
<option value="static">静态 (static)</option>
|
||||
<option value="custom">自定义 (custom)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>处理器/文件路径</label>
|
||||
<input type="text" id="route-handler" required placeholder="views/index.html 或 handler 函数">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>描述</label>
|
||||
<input type="text" id="route-description" placeholder="路由描述">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="route-active" checked>
|
||||
启用
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>排序</label>
|
||||
<input type="number" id="route-order" value="0">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="route-cancel-btn">取消</button>
|
||||
<button class="btn btn-primary" id="route-save-btn">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,196 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.pageTitle}}</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="icon" href="/img/favicon.png" type="image/png">
|
||||
</head>
|
||||
<body class="shad-app">
|
||||
<header class="site-header">
|
||||
<div class="shad-container header-inner">
|
||||
<div>
|
||||
<span class="eyebrow">YMhut Release Center</span>
|
||||
<h1>YMhut Box 下载与模块分发中心</h1>
|
||||
<p>自动识别 <code>public/downloads</code> 下的安装包,生成主程序更新、历史版本和模块化工具分发清单。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="shad-button shad-button--secondary" href="/api/update-info">更新清单</a>
|
||||
<a class="shad-button shad-button--secondary" href="/api/modules">模块清单</a>
|
||||
<a class="shad-button shad-button--primary" href="/admin">管理后台</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="shad-main">
|
||||
<div class="shad-container">
|
||||
<section class="summary-grid">
|
||||
<article class="summary-card">
|
||||
<span>自动扫描</span>
|
||||
<strong>downloads</strong>
|
||||
<p>无需手工维护下载卡片,刷新页面即可读取安装包并按产品分组。</p>
|
||||
</article>
|
||||
<article class="summary-card">
|
||||
<span>兼容更新</span>
|
||||
<strong>v1 + v2</strong>
|
||||
<p>保留旧版 <code>update-info.json</code>,同时提供模块化清单。</p>
|
||||
</article>
|
||||
<article class="summary-card">
|
||||
<span>校验能力</span>
|
||||
<strong>SHA256</strong>
|
||||
<p>服务端自动为主程序、资源包和未来模块包生成校验字段。</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{{if .errorMessage}}
|
||||
<section class="shad-card shad-card--state shad-card--danger">
|
||||
<h2>读取目录失败</h2>
|
||||
<p>{{.errorMessage}}</p>
|
||||
</section>
|
||||
{{else if not .products}}
|
||||
<section class="shad-card shad-card--state">
|
||||
<h2>暂无可识别安装包</h2>
|
||||
<p>请将 Windows、Android、macOS 或 Linux 安装包放入 <code>public/downloads</code>。</p>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="section-title">
|
||||
<div>
|
||||
<span class="eyebrow">Packages</span>
|
||||
<h2>安装包列表</h2>
|
||||
</div>
|
||||
<p>按产品自动分组,默认展示每个产品的最新版本。</p>
|
||||
</section>
|
||||
|
||||
<section class="shad-grid">
|
||||
{{range $productName, $versions := .products}}
|
||||
{{$latestVersion := index $versions 0}}
|
||||
{{$historyVersions := slice $versions 1}}
|
||||
{{$meta := index $.productMeta $productName}}
|
||||
{{if not $meta}}
|
||||
{{$meta = $.defaultMeta}}
|
||||
{{end}}
|
||||
<article class="shad-card shad-product">
|
||||
<div class="shad-product__head">
|
||||
<div class="shad-product__icon">{{$meta.Icon | safeHTML}}</div>
|
||||
<div class="shad-product__title">
|
||||
<h3>{{$productName}}</h3>
|
||||
<p>{{$meta.Description}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shad-stack shad-stack--row shad-stack--wrap">
|
||||
{{range $meta.Tags}}
|
||||
<span class="shad-badge shad-badge--outline">{{.}}</span>
|
||||
{{end}}
|
||||
<span class="shad-badge shad-badge--muted">{{len $versions}} 个版本</span>
|
||||
</div>
|
||||
|
||||
<div class="shad-panel">
|
||||
<div class="shad-panel__main">
|
||||
<div>
|
||||
<span class="shad-muted">最新版本</span>
|
||||
<strong>{{$latestVersion.Version}}</strong>
|
||||
</div>
|
||||
<span class="shad-badge shad-badge--subtle">{{$latestVersion.Extension}}</span>
|
||||
</div>
|
||||
<div class="shad-meta-grid">
|
||||
<div class="shad-meta">
|
||||
<span class="shad-muted">更新时间</span>
|
||||
<span>{{$latestVersion.UpdateDate}}</span>
|
||||
</div>
|
||||
<div class="shad-meta">
|
||||
<span class="shad-muted">文件大小</span>
|
||||
<span>{{$latestVersion.Size}}</span>
|
||||
</div>
|
||||
<div class="shad-meta">
|
||||
<span class="shad-muted">文件名</span>
|
||||
<span class="shad-truncate">{{$latestVersion.FileName}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shad-actions">
|
||||
<a href="{{$latestVersion.DownloadPath}}" class="shad-button shad-button--primary" download>下载最新版本</a>
|
||||
{{if $historyVersions}}
|
||||
<button class="shad-button shad-button--secondary history-btn"
|
||||
data-product-name="{{$productName}}"
|
||||
data-history='{{marshalJSON $historyVersions}}'>历史版本</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="shad-dialog" id="history-modal" aria-hidden="true">
|
||||
<div class="shad-dialog__overlay"></div>
|
||||
<div class="shad-dialog__content">
|
||||
<div class="shad-dialog__header">
|
||||
<h3 id="modal-title">历史版本</h3>
|
||||
<button class="shad-icon-button" id="close-modal" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="shad-dialog__body">
|
||||
<ul class="shad-history" id="history-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="shad-footer">
|
||||
<div class="shad-container shad-footer__inner">
|
||||
<span>YMhut 下载中心</span>
|
||||
<span>主程序、资源包与模块包统一分发</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const modal = document.getElementById('history-modal');
|
||||
const modalTitle = document.getElementById('modal-title');
|
||||
const historyList = document.getElementById('history-list');
|
||||
const closeModalBtn = document.getElementById('close-modal');
|
||||
|
||||
function closeHistoryModal() {
|
||||
modal.classList.remove('is-open');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
document.querySelectorAll('.history-btn').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const versions = JSON.parse(button.dataset.history || '[]');
|
||||
modalTitle.textContent = `${button.dataset.productName} 历史版本`;
|
||||
historyList.innerHTML = versions.length ? '' : '<li class="shad-history__item"><strong>暂无历史版本</strong></li>';
|
||||
versions.forEach(version => {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'shad-history__item';
|
||||
item.innerHTML = `
|
||||
<div>
|
||||
<strong>版本 ${version.version}</strong>
|
||||
<p>${version.updateDate} · ${version.size} · ${version.extension}</p>
|
||||
</div>
|
||||
<a href="${version.downloadPath}" class="shad-button shad-button--secondary" download>下载</a>
|
||||
`;
|
||||
historyList.appendChild(item);
|
||||
});
|
||||
modal.classList.add('is-open');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
document.body.style.overflow = 'hidden';
|
||||
});
|
||||
});
|
||||
|
||||
closeModalBtn.addEventListener('click', closeHistoryModal);
|
||||
modal.addEventListener('click', event => {
|
||||
if (event.target === modal || event.target.classList.contains('shad-dialog__overlay')) {
|
||||
closeHistoryModal();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') closeHistoryModal();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,184 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>数据库配置 - 系统安装</title>
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" href="/img/favicon.png" type="image/png">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="auth-box" style="max-width: 600px;">
|
||||
<div class="auth-header">
|
||||
<div class="auth-logo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>数据库配置</h1>
|
||||
<p>请配置数据库连接信息</p>
|
||||
</div>
|
||||
|
||||
<div class="install-info" style="background: #e3f2fd; border: 1px solid #2196f3; border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem;">
|
||||
<p style="margin: 0; color: #1976d2; font-size: 0.9rem;">
|
||||
<strong>默认管理员账号:</strong><br>
|
||||
用户名: <code>admin</code><br>
|
||||
密码: <code>admin123456</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form id="install-form" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="db-type">数据库类型</label>
|
||||
<select id="db-type" name="type" required>
|
||||
<option value="mysql">MySQL</option>
|
||||
<option value="sqlite">SQLite</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- MySQL 配置 -->
|
||||
<div id="mysql-config">
|
||||
<div class="form-group">
|
||||
<label for="db-host">数据库主机</label>
|
||||
<input type="text" id="db-host" name="host" placeholder="localhost 或远程IP地址" value="localhost">
|
||||
<small>支持远程数据库,请输入IP地址或域名</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="db-port">端口</label>
|
||||
<input type="text" id="db-port" name="port" placeholder="3306" value="3306">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="db-user">用户名</label>
|
||||
<input type="text" id="db-user" name="user" placeholder="root" value="root" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="db-password">密码</label>
|
||||
<input type="password" id="db-password" name="password" placeholder="数据库密码">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="db-database">数据库名</label>
|
||||
<input type="text" id="db-database" name="database" placeholder="software_download_center" value="software_download_center" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="db-prefix">表前缀(可选)</label>
|
||||
<input type="text" id="db-prefix" name="table_prefix" placeholder="例如: sd_">
|
||||
<small>留空则无前缀</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SQLite 配置 -->
|
||||
<div id="sqlite-config" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label for="db-dsn">数据目录</label>
|
||||
<input type="text" id="db-dsn" name="dsn" placeholder="data" value="data">
|
||||
<small>数据库文件将保存在此目录下</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span>保存并连接</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="install-error" class="error-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 数据库类型切换
|
||||
document.getElementById('db-type').addEventListener('change', (e) => {
|
||||
const type = e.target.value;
|
||||
const mysqlConfig = document.getElementById('mysql-config');
|
||||
const sqliteConfig = document.getElementById('sqlite-config');
|
||||
|
||||
if (type === 'mysql') {
|
||||
mysqlConfig.style.display = 'block';
|
||||
sqliteConfig.style.display = 'none';
|
||||
} else {
|
||||
mysqlConfig.style.display = 'none';
|
||||
sqliteConfig.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// 表单提交
|
||||
document.getElementById('install-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const data = {
|
||||
type: formData.get('type'),
|
||||
host: formData.get('host'),
|
||||
port: formData.get('port'),
|
||||
user: formData.get('user'),
|
||||
password: formData.get('password'),
|
||||
database: formData.get('database'),
|
||||
table_prefix: formData.get('table_prefix'),
|
||||
dsn: formData.get('dsn'),
|
||||
};
|
||||
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span>连接中...</span>';
|
||||
|
||||
const errorEl = document.getElementById('install-error');
|
||||
errorEl.classList.remove('show');
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/install/database', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || '安装失败');
|
||||
}
|
||||
|
||||
alert('数据库配置成功!正在跳转到登录页面...');
|
||||
window.location.href = '/admin/login';
|
||||
} catch (error) {
|
||||
errorEl.textContent = error.message;
|
||||
errorEl.classList.add('show');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// 检查安装状态
|
||||
async function checkInstallStatus() {
|
||||
try {
|
||||
const response = await fetch('/admin/install/status');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.initialized) {
|
||||
// 已安装,跳转到登录页
|
||||
window.location.href = '/admin/login';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Check install status error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时检查
|
||||
checkInstallStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - 后台管理</title>
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" href="/img/favicon.png" type="image/png">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="auth-box">
|
||||
<div class="auth-header">
|
||||
<div class="auth-logo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>欢迎回来</h1>
|
||||
<p>登录您的管理员账户</p>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" name="username" required placeholder="请输入用户名" autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" name="password" required placeholder="请输入密码" autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span>登录</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="auth-footer">
|
||||
<a href="/admin/register" class="auth-link">还没有账户?立即注册</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="auth-error" class="error-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/auth.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>注册 - 后台管理</title>
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" href="/img/favicon.png" type="image/png">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="auth-box">
|
||||
<div class="auth-header">
|
||||
<div class="auth-logo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>创建管理员账户</h1>
|
||||
<p>第一个注册的用户将自动成为管理员</p>
|
||||
</div>
|
||||
|
||||
<form id="register-form" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" name="username" required minlength="3" maxlength="50" placeholder="请输入用户名">
|
||||
<small>3-50个字符</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱</label>
|
||||
<input type="email" id="email" name="email" required placeholder="your@email.com">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" name="password" required minlength="8" placeholder="至少8个字符">
|
||||
<small>至少8个字符,包含大小写字母、数字和特殊字符</small>
|
||||
<div class="password-strength" id="password-strength"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm-password">确认密码</label>
|
||||
<input type="password" id="confirm-password" name="confirm-password" required placeholder="再次输入密码">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span>创建账户</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="auth-footer">
|
||||
<a href="/admin/login" class="auth-link">已有账户?立即登录</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="auth-error" class="error-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/auth.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>系统设置 - 后台管理</title>
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" href="/img/favicon.png" type="image/png">
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
<header class="admin-header">
|
||||
<div class="header-left">
|
||||
<a href="/admin" class="back-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
</a>
|
||||
<h1>系统设置</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="current-user"></span>
|
||||
<button id="logout-btn" class="btn btn-secondary">登出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="admin-content">
|
||||
<div class="settings-container">
|
||||
<!-- 数据库设置 -->
|
||||
<section class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2>数据库设置</h2>
|
||||
<p class="section-description">管理数据库连接和配置</p>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="database-info" id="database-info">
|
||||
<div class="info-item">
|
||||
<span class="info-label">数据库类型</span>
|
||||
<span class="info-value" id="db-type">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">连接状态</span>
|
||||
<span class="info-value status-badge" id="db-status">-</span>
|
||||
</div>
|
||||
<div class="info-item" id="db-file-item" style="display: none;">
|
||||
<span class="info-label">数据库文件</span>
|
||||
<span class="info-value" id="db-file">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据库密码修改(仅 MySQL) -->
|
||||
<div class="database-password" id="database-password-section" style="display: none;">
|
||||
<h3>修改数据库 Root 密码</h3>
|
||||
<p class="password-warning">⚠️ 警告:修改密码后需要更新环境变量 DB_PASSWORD 并重启服务器!</p>
|
||||
<form id="password-form" class="password-form">
|
||||
<div class="form-group">
|
||||
<label>当前密码</label>
|
||||
<input type="password" id="current-password" class="form-input" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新密码</label>
|
||||
<input type="password" id="new-password" class="form-input" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>确认新密码</label>
|
||||
<input type="password" id="confirm-password" class="form-input" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger">更新密码</button>
|
||||
</form>
|
||||
<div id="password-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- 数据库转换 -->
|
||||
<div class="database-convert">
|
||||
<h3>数据库转换</h3>
|
||||
<p class="convert-warning">⚠️ 警告:数据库转换会导出当前数据并导入到新数据库,请确保已备份数据!</p>
|
||||
<div class="convert-form">
|
||||
<select id="target-db-type">
|
||||
<option value="sqlite">SQLite</option>
|
||||
<option value="mysql">MySQL</option>
|
||||
</select>
|
||||
<button class="btn btn-danger" id="convert-db-btn">转换数据库</button>
|
||||
</div>
|
||||
<div id="convert-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 系统信息 -->
|
||||
<section class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2>系统信息</h2>
|
||||
<p class="section-description">查看系统运行状态和统计信息</p>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="system-stats" id="system-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">用户总数</span>
|
||||
<span class="stat-value" id="stat-users">-</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">路由总数</span>
|
||||
<span class="stat-value" id="stat-routes">-</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">日志条目</span>
|
||||
<span class="stat-value" id="stat-logs">-</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">服务器时间</span>
|
||||
<span class="stat-value" id="stat-time">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 操作系统信息 -->
|
||||
<section class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2>运行环境</h2>
|
||||
<p class="section-description">查看服务器运行环境信息</p>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="os-info" id="os-info">
|
||||
<div class="info-item">
|
||||
<span class="info-label">操作系统</span>
|
||||
<span class="info-value" id="os-type">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">系统架构</span>
|
||||
<span class="info-value" id="os-arch">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/admin.js"></script>
|
||||
<script src="/js/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user