Add server components
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 13:28:09 +08:00
parent 7ecc6a8923
commit 079ee4eaeb
168 changed files with 37475 additions and 0 deletions
+169
View File
@@ -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)` 添加日志到缓冲区。
## 技术支持
如有问题,请查看服务器日志或联系开发团队。
+237
View File
@@ -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"
```
+173
View File
@@ -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
+362
View File
@@ -0,0 +1,362 @@
# 快速开始指南
这是一个详细的、一步一步的安装和运行指南。
## 第一步:检查 Go 环境
打开终端(Windows: PowerShell 或 CMDLinux/macOS: Terminal),运行:
```bash
go version
```
**预期输出:**
```
go version go1.21.0 windows/amd64
```
**如果显示错误:**
- 请先安装 Gohttps://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` 重新整理依赖
+583
View File
@@ -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 或 CMDLinux/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 相关)
+12
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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": {}
}
+471
View File
@@ -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 />);
+91
View File
@@ -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;
}
}
+21
View File
@@ -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": []
}
+12
View File
@@ -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
}
});
+56
View File
@@ -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
+69
View File
@@ -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)
}
}
}
+537
View File
@@ -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))
}
+115
View File
@@ -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
}
+430
View File
@@ -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 USERMySQL 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
}
+42
View File
@@ -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
)
+103
View File
@@ -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=
+447
View File
@@ -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,
})
}
+263
View 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,
},
})
}
+219
View File
@@ -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,
})
}
+107
View File
@@ -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
}
+69
View File
@@ -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)
}
}
+74
View File
@@ -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()
}
}
+31
View File
@@ -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"
}
+36
View File
@@ -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"
}
+851
View File
@@ -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;
}
}
+523
View File
@@ -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.

After

Width:  |  Height:  |  Size: 5.5 MiB

+532
View File
@@ -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秒更新一次
});
+193
View File
@@ -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();
});
+128
View File
@@ -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秒更新一次
});
+71
View File
@@ -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"
}
+71
View File
@@ -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": "已安装的开发环境"
}
+175
View File
@@ -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
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"manifest_version": 1,
"modules": []
}
+56
View File
@@ -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": ""
}
}
+95
View File
@@ -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 风格,移除渐变主视觉,蓝色作为主强调色,橙红仅用于更新、警告和风险行为。",
"天气胶囊": "补齐阴天、未知、离线等状态的基础图标和轻量动效,遵守关闭动画与高对比度设置。",
"本地化与日志": "修复工具箱与安全、风险确认、默认工具范围、设置弹窗和高频日志的中文模式英文漏出;反馈码和原始错误信息仍保留必要英文。"
}
}
+115
View File
@@ -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)
}
}
}
+52
View File
@@ -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")
}
+68
View File
@@ -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)
}
+109
View File
@@ -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"
}
+104
View File
@@ -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
}
+351
View File
@@ -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"
}
+38
View File
@@ -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)
}
}
+30
View File
@@ -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>
+32
View File
@@ -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>
+253
View File
@@ -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">&times;</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>
+196
View File
@@ -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="关闭">&times;</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>
+184
View File
@@ -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>
+55
View File
@@ -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>
+68
View File
@@ -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>
+143
View File
@@ -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>