feat: ToNav-go v1.0.0 - 内部服务导航系统
功能: - 前台导航: 分类Tab切换、实时搜索、健康状态指示、响应式适配 - 后台管理: 服务/分类CRUD、系统设置、登录认证(bcrypt) - 健康检查: 定时检测(5min)、独立检查URL、三态指示(在线/离线/未检测) - 云端备份: WebDAV上传/下载/恢复/删除、定时自动备份、本地备份管理 技术栈: Go + Gin + GORM + SQLite
This commit is contained in:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 编译产物
|
||||||
|
tonav-go-v1
|
||||||
|
|
||||||
|
# 数据库
|
||||||
|
tonav.db
|
||||||
|
|
||||||
|
# 备份文件
|
||||||
|
backups/
|
||||||
|
tonav_backup_*.db
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
tonav.log
|
||||||
|
|
||||||
|
# PID 文件
|
||||||
|
tonav-go.pid
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
182
README.md
Normal file
182
README.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# ToNav-go
|
||||||
|
|
||||||
|
🧭 内部服务导航系统 — Go 版本
|
||||||
|
|
||||||
|
基于 Go + Gin + GORM + SQLite 构建的轻量级服务导航页,用于管理和展示内部服务链接。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 前台导航
|
||||||
|
- 🔍 **实时搜索** — 输入即过滤,匹配服务名称、描述、标签
|
||||||
|
- 📑 **分类 Tab 切换** — 一键过滤不同分类的服务
|
||||||
|
- 🟢 **健康状态指示** — 绿色在线 / 红色离线 / 灰色未检测
|
||||||
|
- 📱 **响应式设计** — 完美适配手机端
|
||||||
|
- 🎨 **深色 Header + 白色卡片** — 现代化 UI 风格
|
||||||
|
|
||||||
|
### 后台管理
|
||||||
|
- 📡 **服务管理** — 新增/编辑/删除服务(名称、URL、图标、分类、描述、标签、排序、健康检查配置)
|
||||||
|
- 📂 **分类管理** — 新增/编辑/删除分类,支持排序权重
|
||||||
|
- ⚙️ **系统设置** — 站点标题、WebDAV 配置、自动备份开关
|
||||||
|
- 🔐 **登录认证** — bcrypt 密码加密,首次登录强制修改密码
|
||||||
|
- 📊 **Dashboard** — 服务总数、分类数、在线/离线统计
|
||||||
|
|
||||||
|
### 健康检查
|
||||||
|
- ⏱️ **定时检测** — 每 5 分钟自动检测所有启用健康检查的服务
|
||||||
|
- 🎯 **独立检查 URL** — 可为每个服务配置专用健康检查地址
|
||||||
|
- 🔘 **三态指示** — 开启检测 + 成功 = 绿色 / 开启检测 + 失败 = 红色 / 未开启 = 灰色
|
||||||
|
|
||||||
|
### 云端备份(WebDAV)
|
||||||
|
- ☁️ **手动备份** — 一键备份数据库到 WebDAV 云端
|
||||||
|
- 🔄 **恢复备份** — 从云端备份列表选择恢复,恢复前自动备份当前数据
|
||||||
|
- 🗑️ **删除备份** — 清理云端旧备份(需 WebDAV 服务端开放 DELETE 权限)
|
||||||
|
- ⏰ **定时自动备份** — 可配置每天凌晨 3:00 自动执行
|
||||||
|
- 📁 **本地备份管理** — 自动保留最近 5 份本地备份
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 组件 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| 语言 | Go 1.24+ |
|
||||||
|
| Web 框架 | Gin |
|
||||||
|
| ORM | GORM |
|
||||||
|
| 数据库 | SQLite |
|
||||||
|
| 认证 | bcrypt + Cookie Session |
|
||||||
|
| 前端 | 原生 HTML/CSS/JS |
|
||||||
|
| 备份 | WebDAV 协议 |
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ToNav-go/
|
||||||
|
├── main.go # 入口文件、路由注册
|
||||||
|
├── go.mod # Go 模块定义
|
||||||
|
├── go.sum # 依赖校验
|
||||||
|
├── tonav-go-ctl.sh # 管理脚本 (start/stop/restart/status/build/log)
|
||||||
|
├── database/
|
||||||
|
│ ├── db.go # 数据库初始化、自动迁移
|
||||||
|
│ └── seed.go # 初始数据(默认管理员、分类、WebDAV 配置)
|
||||||
|
├── models/
|
||||||
|
│ └── models.go # 数据模型(Category, Service, User, Setting)
|
||||||
|
├── handlers/
|
||||||
|
│ ├── api.go # REST API(服务/分类 CRUD)
|
||||||
|
│ ├── auth.go # 登录/登出/修改密码
|
||||||
|
│ ├── health.go # 健康检查定时任务
|
||||||
|
│ ├── settings.go # 系统设置 & 备份/恢复/删除
|
||||||
|
│ ├── views.go # 页面渲染(首页/Dashboard/管理页面)
|
||||||
|
│ └── utils.go # 工具函数(Session 获取)
|
||||||
|
├── utils/
|
||||||
|
│ ├── config.go # 配置加载(环境变量)
|
||||||
|
│ └── webdav.go # WebDAV 客户端(上传/下载/列表/删除/备份)
|
||||||
|
├── templates/
|
||||||
|
│ ├── index.html # 前台导航页
|
||||||
|
│ └── admin/
|
||||||
|
│ ├── login.html # 登录页
|
||||||
|
│ ├── dashboard.html # 后台首页
|
||||||
|
│ ├── services.html # 服务管理
|
||||||
|
│ ├── categories.html # 分类管理
|
||||||
|
│ └── change_password.html # 修改密码
|
||||||
|
└── backups/ # 本地备份目录(自动创建)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
- Go 1.24+
|
||||||
|
- GCC(CGO 编译 SQLite 需要)
|
||||||
|
|
||||||
|
### 编译运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆项目
|
||||||
|
git clone https://gitea.king.nyc.mn/openclaw/ToNav-go.git
|
||||||
|
cd ToNav-go
|
||||||
|
|
||||||
|
# 编译
|
||||||
|
go build -o tonav-go-v1
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
./tonav-go-v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用管理脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x tonav-go-ctl.sh
|
||||||
|
|
||||||
|
./tonav-go-ctl.sh start # 启动(后台运行)
|
||||||
|
./tonav-go-ctl.sh stop # 停止
|
||||||
|
./tonav-go-ctl.sh restart # 重启
|
||||||
|
./tonav-go-ctl.sh status # 查看状态
|
||||||
|
./tonav-go-ctl.sh build # 编译
|
||||||
|
./tonav-go-ctl.sh log # 查看日志
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境变量配置
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `TONAV_PORT` | `9520` | 监听端口 |
|
||||||
|
| `TONAV_DB` | `tonav.db` | 数据库文件路径 |
|
||||||
|
| `TONAV_SECRET` | 内置密钥 | Cookie 签名密钥 |
|
||||||
|
|
||||||
|
### 默认账号
|
||||||
|
|
||||||
|
- **用户名**: `admin`
|
||||||
|
- **密码**: `admin123`
|
||||||
|
- 首次登录会强制修改密码
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 公开接口
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/` | 前台导航页 |
|
||||||
|
| GET | `/admin/login` | 登录页 |
|
||||||
|
| POST | `/admin/login` | 登录 |
|
||||||
|
|
||||||
|
### 管理接口(需登录)
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/admin/dashboard` | 后台首页 |
|
||||||
|
| GET | `/admin/api/services` | 获取所有服务 |
|
||||||
|
| POST | `/admin/api/services` | 新增服务 |
|
||||||
|
| PUT | `/admin/api/services/:id` | 更新服务 |
|
||||||
|
| DELETE | `/admin/api/services/:id` | 删除服务 |
|
||||||
|
| GET | `/admin/api/categories` | 获取所有分类 |
|
||||||
|
| POST | `/admin/api/categories` | 新增分类 |
|
||||||
|
| PUT | `/admin/api/categories/:id` | 更新分类 |
|
||||||
|
| DELETE | `/admin/api/categories/:id` | 删除分类 |
|
||||||
|
| GET | `/admin/api/settings` | 获取设置 |
|
||||||
|
| POST | `/admin/api/settings` | 保存设置 |
|
||||||
|
| POST | `/admin/api/backup/webdav` | 执行云端备份 |
|
||||||
|
| GET | `/admin/api/backup/list` | 列出云端备份 |
|
||||||
|
| DELETE | `/admin/api/backup/delete?name=xxx` | 删除云端备份 |
|
||||||
|
| POST | `/admin/api/backup/restore?name=xxx` | 恢复云端备份 |
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
### Service 服务
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| name | string | 服务名称 |
|
||||||
|
| url | string | 服务地址 |
|
||||||
|
| icon | string | 图标(emoji) |
|
||||||
|
| description | string | 描述 |
|
||||||
|
| category_id | uint | 所属分类 ID |
|
||||||
|
| tags | string | 标签(逗号分隔) |
|
||||||
|
| status | string | 状态(online/offline/unknown) |
|
||||||
|
| is_enabled | bool | 是否启用 |
|
||||||
|
| sort_order | int | 排序权重(越大越靠前) |
|
||||||
|
| health_check_url | string | 健康检查 URL |
|
||||||
|
| health_check_enabled | bool | 是否启用健康检查 |
|
||||||
|
| click_count | int | 点击次数 |
|
||||||
|
|
||||||
|
### Category 分类
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| name | string | 分类名称 |
|
||||||
|
| sort_order | int | 排序权重(越大越靠前) |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
24
database/db.go
Normal file
24
database/db.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"tonav-go/models"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *gorm.DB
|
||||||
|
|
||||||
|
func InitDB(dbPath string) {
|
||||||
|
var err error
|
||||||
|
DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动迁移
|
||||||
|
err = DB.AutoMigrate(&models.Category{}, &models.Service{}, &models.User{}, &models.Setting{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to migrate database: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
database/seed.go
Normal file
44
database/seed.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"tonav-go/models"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Seed() {
|
||||||
|
// 1. 初始化管理员 (admin / admin123)
|
||||||
|
var count int64
|
||||||
|
DB.Model(&models.User{}).Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||||
|
DB.Create(&models.User{
|
||||||
|
Username: "admin",
|
||||||
|
Password: string(hash),
|
||||||
|
MustChangePassword: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 初始化分类
|
||||||
|
var catCount int64
|
||||||
|
DB.Model(&models.Category{}).Count(&catCount)
|
||||||
|
if catCount == 0 {
|
||||||
|
categories := []models.Category{
|
||||||
|
{Name: "内网服务", SortOrder: 100},
|
||||||
|
{Name: "开发工具", SortOrder: 90},
|
||||||
|
{Name: "测试环境", SortOrder: 80},
|
||||||
|
}
|
||||||
|
DB.Create(&categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 初始设置
|
||||||
|
var setCount int64
|
||||||
|
DB.Model(&models.Setting{}).Count(&setCount)
|
||||||
|
if setCount == 0 {
|
||||||
|
settings := []models.Setting{
|
||||||
|
{Key: "webdav_url", Value: "https://chfs.ouaone.top/webdav/openclaw/upload/tonav-go/"},
|
||||||
|
{Key: "webdav_user", Value: "openclaw"},
|
||||||
|
{Key: "webdav_password", Value: "Khh13579"},
|
||||||
|
}
|
||||||
|
DB.Create(&settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
go.mod
Normal file
48
go.mod
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
module tonav-go
|
||||||
|
|
||||||
|
go 1.24.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-contrib/sessions v1.0.4
|
||||||
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
golang.org/x/crypto v0.48.0
|
||||||
|
gorm.io/driver/sqlite v1.6.0
|
||||||
|
gorm.io/gorm v1.31.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.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.30.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/gorilla/context v1.1.2 // indirect
|
||||||
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
|
github.com/gorilla/sessions v1.4.0 // 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.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
)
|
||||||
107
go.sum
Normal file
107
go.sum
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
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.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
||||||
|
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
|
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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||||
|
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||||
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
|
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||||
|
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||||
|
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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||||
|
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
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/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
157
handlers/api.go
Normal file
157
handlers/api.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"tonav-go/database"
|
||||||
|
"tonav-go/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// API 响应结构
|
||||||
|
type Response struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServices 获取所有服务
|
||||||
|
func GetServices(c *gin.Context) {
|
||||||
|
var services []models.Service
|
||||||
|
database.DB.Order("category_id asc, sort_order desc").Find(&services)
|
||||||
|
c.JSON(http.StatusOK, Response{Success: true, Data: services})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveService 创建或更新服务
|
||||||
|
func SaveService(c *gin.Context) {
|
||||||
|
var service models.Service
|
||||||
|
if err := c.ShouldBindJSON(&service); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "请求数据格式错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证必填字段
|
||||||
|
if service.Name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "服务名称不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if service.URL == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "服务地址不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 URL 路径中有 id,使用路径参数
|
||||||
|
if idStr := c.Param("id"); idStr != "" {
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err == nil {
|
||||||
|
service.ID = uint(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if service.ID > 0 {
|
||||||
|
// 更新时只更新指定字段,避免覆盖 created_at 等
|
||||||
|
result := database.DB.Model(&models.Service{}).Where("id = ?", service.ID).Updates(map[string]interface{}{
|
||||||
|
"name": service.Name,
|
||||||
|
"url": service.URL,
|
||||||
|
"description": service.Description,
|
||||||
|
"icon": service.Icon,
|
||||||
|
"category_id": service.CategoryID,
|
||||||
|
"tags": service.Tags,
|
||||||
|
"is_enabled": service.IsEnabled,
|
||||||
|
"sort_order": service.SortOrder,
|
||||||
|
"health_check_url": service.HealthCheckURL,
|
||||||
|
"health_check_enabled": service.HealthCheckEnabled,
|
||||||
|
})
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "更新失败: " + result.Error.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := database.DB.Create(&service).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "创建失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, Response{Success: true, Message: "保存成功", Data: service})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteService 删除服务
|
||||||
|
func DeleteService(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "缺少服务ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := database.DB.Delete(&models.Service{}, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, Response{Success: true, Message: "删除成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCategories 获取所有分类
|
||||||
|
func GetCategories(c *gin.Context) {
|
||||||
|
var categories []models.Category
|
||||||
|
database.DB.Order("sort_order desc").Find(&categories)
|
||||||
|
c.JSON(http.StatusOK, Response{Success: true, Data: categories})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCategory 保存分类
|
||||||
|
func SaveCategory(c *gin.Context) {
|
||||||
|
var category models.Category
|
||||||
|
if err := c.ShouldBindJSON(&category); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "请求数据格式错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if category.Name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "分类名称不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 URL 路径中有 id
|
||||||
|
if idStr := c.Param("id"); idStr != "" {
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err == nil {
|
||||||
|
category.ID = uint(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if category.ID > 0 {
|
||||||
|
result := database.DB.Model(&models.Category{}).Where("id = ?", category.ID).Updates(map[string]interface{}{
|
||||||
|
"name": category.Name,
|
||||||
|
"sort_order": category.SortOrder,
|
||||||
|
})
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "更新失败: " + result.Error.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := database.DB.Create(&category).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "创建失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, Response{Success: true, Message: "保存成功", Data: category})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCategory 删除分类
|
||||||
|
func DeleteCategory(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "缺少分类ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 检查是否有服务属于该分类
|
||||||
|
var count int64
|
||||||
|
database.DB.Model(&models.Service{}).Where("category_id = ?", id).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "该分类下仍有服务,无法删除"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
database.DB.Delete(&models.Category{}, id)
|
||||||
|
c.JSON(http.StatusOK, Response{Success: true, Message: "删除成功"})
|
||||||
|
}
|
||||||
121
handlers/auth.go
Normal file
121
handlers/auth.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"tonav-go/database"
|
||||||
|
"tonav-go/models"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginHandler 处理登录请求
|
||||||
|
func LoginHandler(c *gin.Context) {
|
||||||
|
var input struct {
|
||||||
|
Username string `form:"username" binding:"required"`
|
||||||
|
Password string `form:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
|
c.HTML(http.StatusOK, "login.html", gin.H{"error": "用户名和密码不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := database.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
|
||||||
|
c.HTML(http.StatusOK, "login.html", gin.H{"error": "用户名或密码错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Password)); err != nil {
|
||||||
|
c.HTML(http.StatusOK, "login.html", gin.H{"error": "用户名或密码错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Set("user_id", int(user.ID))
|
||||||
|
session.Set("username", user.Username)
|
||||||
|
session.Set("must_change", user.MustChangePassword)
|
||||||
|
if err := session.Save(); err != nil {
|
||||||
|
log.Printf("Session save error: %v", err)
|
||||||
|
c.HTML(http.StatusOK, "login.html", gin.H{"error": "登录失败,请重试"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.MustChangePassword {
|
||||||
|
c.Redirect(http.StatusFound, "/admin/change-password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, "/admin/dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogoutHandler 处理退出登录
|
||||||
|
func LogoutHandler(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Clear()
|
||||||
|
session.Save()
|
||||||
|
c.Redirect(http.StatusFound, "/admin/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePasswordHandler 修改密码
|
||||||
|
func ChangePasswordHandler(c *gin.Context) {
|
||||||
|
if c.Request.Method == "GET" {
|
||||||
|
c.HTML(http.StatusOK, "change_password.html", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input struct {
|
||||||
|
OldPassword string `form:"old_password" binding:"required"`
|
||||||
|
NewPassword string `form:"new_password" binding:"required"`
|
||||||
|
ConfirmPassword string `form:"confirm_password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
|
c.HTML(http.StatusOK, "change_password.html", gin.H{"error": "所有字段均为必填"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.NewPassword != input.ConfirmPassword {
|
||||||
|
c.HTML(http.StatusOK, "change_password.html", gin.H{"error": "两次输入的新密码不一致"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(input.NewPassword) < 6 {
|
||||||
|
c.HTML(http.StatusOK, "change_password.html", gin.H{"error": "新密码长度不能少于6位"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := sessions.Default(c)
|
||||||
|
userID, err := getSessionUserID(session)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/admin/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||||
|
c.HTML(http.StatusOK, "change_password.html", gin.H{"error": "用户不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.OldPassword)); err != nil {
|
||||||
|
c.HTML(http.StatusOK, "change_password.html", gin.H{"error": "旧密码错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusOK, "change_password.html", gin.H{"error": "密码加密失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user.Password = string(hashedPassword)
|
||||||
|
user.MustChangePassword = false
|
||||||
|
database.DB.Save(&user)
|
||||||
|
|
||||||
|
session.Set("must_change", false)
|
||||||
|
session.Save()
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/admin/dashboard")
|
||||||
|
}
|
||||||
62
handlers/health.go
Normal file
62
handlers/health.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
"tonav-go/database"
|
||||||
|
"tonav-go/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartHealthCheck 启动异步健康检查(Goroutine)
|
||||||
|
func StartHealthCheck() {
|
||||||
|
go checkAllServices()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
go func() {
|
||||||
|
for range ticker.C {
|
||||||
|
checkAllServices()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAllServices() {
|
||||||
|
var services []models.Service
|
||||||
|
database.DB.Find(&services)
|
||||||
|
|
||||||
|
client := http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
checked := 0
|
||||||
|
for _, s := range services {
|
||||||
|
// 未开启健康检查的,状态设为 unknown
|
||||||
|
if !s.HealthCheckEnabled {
|
||||||
|
if s.Status != "unknown" {
|
||||||
|
database.DB.Model(&s).Update("status", "unknown")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开启了健康检查的,执行检测
|
||||||
|
checkURL := s.URL
|
||||||
|
if s.HealthCheckURL != "" {
|
||||||
|
checkURL = s.HealthCheckURL
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get(checkURL)
|
||||||
|
status := "offline"
|
||||||
|
if err == nil && resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
||||||
|
status = "online"
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
database.DB.Model(&s).Update("status", status)
|
||||||
|
checked++
|
||||||
|
}
|
||||||
|
log.Printf("[%s] 健康检查完成,共 %d 个服务,实际检测 %d 个", time.Now().Format("2006-01-02 15:04:05"), len(services), checked)
|
||||||
|
fmt.Printf("[%s] 健康检查完成\n", time.Now().Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
221
handlers/settings.go
Normal file
221
handlers/settings.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
"tonav-go/database"
|
||||||
|
"tonav-go/models"
|
||||||
|
config "tonav-go/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetSettings 获取设置
|
||||||
|
func GetSettings(c *gin.Context) {
|
||||||
|
var settings []models.Setting
|
||||||
|
database.DB.Find(&settings)
|
||||||
|
|
||||||
|
res := make(map[string]string)
|
||||||
|
for _, s := range settings {
|
||||||
|
res[s.Key] = s.Value
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSettings 保存设置
|
||||||
|
func SaveSettings(c *gin.Context) {
|
||||||
|
var input map[string]string
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, Response{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range input {
|
||||||
|
database.DB.Save(&models.Setting{Key: k, Value: v})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, Response{Success: true, Message: "设置已保存"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWebDAVClient 从数据库配置构建 WebDAV 客户端
|
||||||
|
func getWebDAVClient() (*config.WebDAVClient, error) {
|
||||||
|
var url, user, pass models.Setting
|
||||||
|
database.DB.Where("key = ?", "webdav_url").First(&url)
|
||||||
|
database.DB.Where("key = ?", "webdav_user").First(&user)
|
||||||
|
database.DB.Where("key = ?", "webdav_password").First(&pass)
|
||||||
|
|
||||||
|
if url.Value == "" {
|
||||||
|
return nil, fmt.Errorf("未配置 WebDAV URL")
|
||||||
|
}
|
||||||
|
return config.NewWebDAVClient(url.Value, user.Value, pass.Value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCloudBackup 执行云端备份
|
||||||
|
func RunCloudBackup(c *gin.Context) {
|
||||||
|
client, err := getWebDAVClient()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, Response{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取数据库路径
|
||||||
|
cfg := config.LoadConfig()
|
||||||
|
backupPath, err := config.CreateBackup(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "本地备份失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传时只用文件名,不带路径
|
||||||
|
remoteName := filepath.Base(backupPath)
|
||||||
|
err = client.Upload(backupPath, remoteName)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "云端上传失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理本地旧备份,保留最近5份
|
||||||
|
config.CleanOldBackups(5)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, Response{Success: true, Message: "备份成功: " + remoteName})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCloudBackups 列出云端备份
|
||||||
|
func ListCloudBackups(c *gin.Context) {
|
||||||
|
client, err := getWebDAVClient()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"files": []interface{}{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := client.List()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"files": files})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCloudBackup 删除云端备份
|
||||||
|
func DeleteCloudBackup(c *gin.Context) {
|
||||||
|
name := c.Query("name")
|
||||||
|
if name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "缺少备份文件名"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := getWebDAVClient()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, Response{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.Delete(name); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "删除失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, Response{Success: true, Message: "已删除: " + name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreCloudBackup 从云端备份恢复
|
||||||
|
func RestoreCloudBackup(c *gin.Context) {
|
||||||
|
name := c.Query("name")
|
||||||
|
if name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "缺少备份文件名"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := getWebDAVClient()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, Response{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.LoadConfig()
|
||||||
|
|
||||||
|
// 恢复前先备份当前数据库
|
||||||
|
preBackup, err := config.CreateBackup(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("恢复前备份失败: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("恢复前已备份当前数据库: %s", preBackup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载云端备份到临时文件
|
||||||
|
tmpPath := "backups/restore_tmp.db"
|
||||||
|
if err := client.Download(name, tmpPath); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "下载备份失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭当前数据库连接
|
||||||
|
sqlDB, err := database.DB.DB()
|
||||||
|
if err == nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用下载的备份替换当前数据库
|
||||||
|
if err := config.ReplaceDB(tmpPath, cfg.DBPath); err != nil {
|
||||||
|
// 尝试重新连接原数据库
|
||||||
|
database.InitDB(cfg.DBPath)
|
||||||
|
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "替换数据库失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新初始化数据库连接
|
||||||
|
database.InitDB(cfg.DBPath)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, Response{Success: true, Message: fmt.Sprintf("已从 %s 恢复,恢复前备份: %s", name, filepath.Base(preBackup))})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAutoBackup 启动定时自动备份
|
||||||
|
func StartAutoBackup() {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
// 计算距离下一个凌晨3点的时间
|
||||||
|
now := time.Now()
|
||||||
|
next := time.Date(now.Year(), now.Month(), now.Day(), 3, 0, 0, 0, now.Location())
|
||||||
|
if next.Before(now) {
|
||||||
|
next = next.Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(next.Sub(now))
|
||||||
|
<-timer.C
|
||||||
|
|
||||||
|
// 检查是否启用了自动备份
|
||||||
|
var setting models.Setting
|
||||||
|
database.DB.Where("key = ?", "auto_backup").First(&setting)
|
||||||
|
if setting.Value != "true" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("开始执行自动备份...")
|
||||||
|
doAutoBackup()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func doAutoBackup() {
|
||||||
|
client, err := getWebDAVClient()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("自动备份失败(WebDAV未配置): %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.LoadConfig()
|
||||||
|
backupPath, err := config.CreateBackup(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("自动备份失败(本地备份): %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteName := filepath.Base(backupPath)
|
||||||
|
if err := client.Upload(backupPath, remoteName); err != nil {
|
||||||
|
log.Printf("自动备份失败(上传): %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config.CleanOldBackups(5)
|
||||||
|
log.Printf("自动备份成功: %s", remoteName)
|
||||||
|
}
|
||||||
11
handlers/utils.go
Normal file
11
handlers/utils.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetSession 统一会话获取
|
||||||
|
func GetSession(c *gin.Context) sessions.Session {
|
||||||
|
return sessions.Default(c)
|
||||||
|
}
|
||||||
119
handlers/views.go
Normal file
119
handlers/views.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"tonav-go/database"
|
||||||
|
"tonav-go/models"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthRequired 登录验证中间件
|
||||||
|
func AuthRequired() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
userID := session.Get("user_id")
|
||||||
|
if userID == nil {
|
||||||
|
c.Redirect(http.StatusFound, "/admin/login")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mustChange := session.Get("must_change")
|
||||||
|
if mustChange == true && c.Request.URL.Path != "/admin/change-password" && c.Request.URL.Path != "/admin/logout" {
|
||||||
|
c.Redirect(http.StatusFound, "/admin/change-password")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardHandler 渲染后台首页
|
||||||
|
func DashboardHandler(c *gin.Context) {
|
||||||
|
var serviceCount, categoryCount int64
|
||||||
|
var onlineCount, offlineCount int64
|
||||||
|
database.DB.Model(&models.Service{}).Count(&serviceCount)
|
||||||
|
database.DB.Model(&models.Category{}).Count(&categoryCount)
|
||||||
|
database.DB.Model(&models.Service{}).Where("status = ?", "online").Count(&onlineCount)
|
||||||
|
database.DB.Model(&models.Service{}).Where("status = ?", "offline").Count(&offlineCount)
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "dashboard.html", gin.H{
|
||||||
|
"service_count": serviceCount,
|
||||||
|
"category_count": categoryCount,
|
||||||
|
"online_count": onlineCount,
|
||||||
|
"offline_count": offlineCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexHandler 渲染前台首页
|
||||||
|
func IndexHandler(c *gin.Context) {
|
||||||
|
if database.DB == nil {
|
||||||
|
c.String(http.StatusInternalServerError, "DB NIL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有分类
|
||||||
|
var categories []models.Category
|
||||||
|
database.DB.Order("sort_order desc").Find(&categories)
|
||||||
|
|
||||||
|
// 获取所有启用的服务
|
||||||
|
var services []models.Service
|
||||||
|
database.DB.Where("is_enabled = ?", true).Order("category_id asc, sort_order desc").Find(&services)
|
||||||
|
|
||||||
|
// 获取站点标题设置
|
||||||
|
var titleSetting models.Setting
|
||||||
|
siteTitle := "ToNav"
|
||||||
|
if err := database.DB.Where("key = ?", "site_title").First(&titleSetting).Error; err == nil && titleSetting.Value != "" {
|
||||||
|
siteTitle = titleSetting.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化为 JSON 供前端 JS 使用
|
||||||
|
categoriesJSON, _ := json.Marshal(categories)
|
||||||
|
servicesJSON, _ := json.Marshal(services)
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||||
|
"site_title": siteTitle,
|
||||||
|
"categories": categories,
|
||||||
|
"categories_json": template.JS(categoriesJSON),
|
||||||
|
"services_json": template.JS(servicesJSON),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServicesPageHandler 渲染服务管理页面
|
||||||
|
func ServicesPageHandler(c *gin.Context) {
|
||||||
|
var categories []models.Category
|
||||||
|
database.DB.Order("sort_order desc").Find(&categories)
|
||||||
|
c.HTML(http.StatusOK, "services.html", gin.H{
|
||||||
|
"categories": categories,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoriesPageHandler 渲染分类管理页面
|
||||||
|
func CategoriesPageHandler(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "categories.html", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSessionUserID 安全获取 session 中的 user_id
|
||||||
|
func getSessionUserID(session sessions.Session) (uint, error) {
|
||||||
|
userID := session.Get("user_id")
|
||||||
|
if userID == nil {
|
||||||
|
return 0, fmt.Errorf("user not logged in")
|
||||||
|
}
|
||||||
|
switch v := userID.(type) {
|
||||||
|
case uint:
|
||||||
|
return v, nil
|
||||||
|
case int:
|
||||||
|
return uint(v), nil
|
||||||
|
case int64:
|
||||||
|
return uint(v), nil
|
||||||
|
case float64:
|
||||||
|
return uint(v), nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unexpected user_id type: %T", userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
main.go
Normal file
75
main.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"tonav-go/database"
|
||||||
|
"tonav-go/handlers"
|
||||||
|
config "tonav-go/utils"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-contrib/sessions/cookie"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.LoadConfig()
|
||||||
|
database.InitDB(cfg.DBPath)
|
||||||
|
database.Seed()
|
||||||
|
handlers.StartHealthCheck()
|
||||||
|
handlers.StartAutoBackup()
|
||||||
|
|
||||||
|
r := gin.Default()
|
||||||
|
store := cookie.NewStore([]byte(cfg.SecretKey))
|
||||||
|
r.Use(sessions.Sessions("tonav_session", store))
|
||||||
|
r.Static("/static", "./static")
|
||||||
|
|
||||||
|
// 显式逐个加载,彻底杜绝路径猜测问题
|
||||||
|
r.LoadHTMLFiles(
|
||||||
|
"templates/index.html",
|
||||||
|
"templates/admin/login.html",
|
||||||
|
"templates/admin/dashboard.html",
|
||||||
|
"templates/admin/categories.html",
|
||||||
|
"templates/admin/services.html",
|
||||||
|
"templates/admin/change_password.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
r.GET("/", handlers.IndexHandler)
|
||||||
|
|
||||||
|
r.GET("/admin/login", func(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "login.html", nil)
|
||||||
|
})
|
||||||
|
r.POST("/admin/login", handlers.LoginHandler)
|
||||||
|
r.GET("/admin/logout", handlers.LogoutHandler)
|
||||||
|
|
||||||
|
admin := r.Group("/admin")
|
||||||
|
admin.Use(handlers.AuthRequired())
|
||||||
|
{
|
||||||
|
admin.GET("", func(c *gin.Context) {
|
||||||
|
c.Redirect(http.StatusFound, "/admin/dashboard")
|
||||||
|
})
|
||||||
|
admin.GET("/dashboard", handlers.DashboardHandler)
|
||||||
|
admin.GET("/services", handlers.ServicesPageHandler)
|
||||||
|
admin.GET("/categories", handlers.CategoriesPageHandler)
|
||||||
|
admin.GET("/change-password", handlers.ChangePasswordHandler)
|
||||||
|
admin.POST("/change-password", handlers.ChangePasswordHandler)
|
||||||
|
|
||||||
|
admin.GET("/api/services", handlers.GetServices)
|
||||||
|
admin.POST("/api/services", handlers.SaveService)
|
||||||
|
admin.PUT("/api/services/:id", handlers.SaveService)
|
||||||
|
admin.DELETE("/api/services/:id", handlers.DeleteService)
|
||||||
|
admin.GET("/api/categories", handlers.GetCategories)
|
||||||
|
admin.POST("/api/categories", handlers.SaveCategory)
|
||||||
|
admin.PUT("/api/categories/:id", handlers.SaveCategory)
|
||||||
|
admin.DELETE("/api/categories/:id", handlers.DeleteCategory)
|
||||||
|
admin.GET("/api/settings", handlers.GetSettings)
|
||||||
|
admin.POST("/api/settings", handlers.SaveSettings)
|
||||||
|
admin.POST("/api/backup/webdav", handlers.RunCloudBackup)
|
||||||
|
admin.GET("/api/backup/list", handlers.ListCloudBackups)
|
||||||
|
admin.DELETE("/api/backup/delete", handlers.DeleteCloudBackup)
|
||||||
|
admin.POST("/api/backup/restore", handlers.RestoreCloudBackup)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("ToNav-go 启动在端口 %s...", cfg.Port)
|
||||||
|
r.Run(":" + cfg.Port)
|
||||||
|
}
|
||||||
45
models/models.go
Normal file
45
models/models.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Category struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"unique;not null" json:"name"`
|
||||||
|
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||||
|
Services []Service `gorm:"foreignKey:CategoryID" json:"services"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"not null" json:"name"`
|
||||||
|
URL string `gorm:"not null" json:"url"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
CategoryID uint `gorm:"index" json:"category_id"`
|
||||||
|
Status string `gorm:"default:'online'" json:"status"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
IsEnabled bool `gorm:"default:true" json:"is_enabled"`
|
||||||
|
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||||
|
ClickCount int `gorm:"default:0" json:"click_count"`
|
||||||
|
HealthCheckURL string `json:"health_check_url"`
|
||||||
|
HealthCheckEnabled bool `gorm:"default:false" json:"health_check_enabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Username string `gorm:"unique;not null"`
|
||||||
|
Password string `gorm:"not null"`
|
||||||
|
MustChangePassword bool `gorm:"default:true"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Setting struct {
|
||||||
|
Key string `gorm:"primaryKey"`
|
||||||
|
Value string
|
||||||
|
}
|
||||||
176
templates/admin/categories.html
Normal file
176
templates/admin/categories.html
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>分类管理 - ToNav</title>
|
||||||
|
<style>
|
||||||
|
:root { --main-red: #ff4d4f; --primary: #667eea; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: "PingFang SC", -apple-system, sans-serif; background: #f0f2f5; padding: 20px; }
|
||||||
|
a { text-decoration: none; color: inherit; }
|
||||||
|
.container { max-width: 900px; margin: 0 auto; }
|
||||||
|
.header { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 20px 25px; border-radius: 15px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||||
|
.btn { padding: 10px 20px; border-radius: 8px; border: none; cursor: pointer; font-weight: bold; display: inline-block; font-size: 14px; transition: all .2s; }
|
||||||
|
.btn:hover { transform: translateY(-1px); }
|
||||||
|
.btn-primary { background: var(--main-red); color: #fff; }
|
||||||
|
.btn-back { background: #eee; color: #333; }
|
||||||
|
.header-actions { display: flex; gap: 10px; }
|
||||||
|
.list-card { background: #fff; border-radius: 15px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||||
|
.item { padding: 15px; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; transition: background .15s; }
|
||||||
|
.item:hover { background: #fafafa; }
|
||||||
|
.item:last-child { border-bottom: none; }
|
||||||
|
.item-info { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.item-order { background: #f0f0f0; border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #999; }
|
||||||
|
.item-name { font-weight: 600; font-size: 16px; }
|
||||||
|
.item-actions { display: flex; gap: 8px; }
|
||||||
|
.item-actions button { background: none; border: none; cursor: pointer; padding: 5px 10px; border-radius: 6px; font-size: 13px; transition: background .15s; }
|
||||||
|
.btn-edit { color: var(--primary); }
|
||||||
|
.btn-edit:hover { background: rgba(102,126,234,0.1); }
|
||||||
|
.btn-del { color: var(--main-red); }
|
||||||
|
.btn-del:hover { background: rgba(255,77,79,0.1); }
|
||||||
|
.empty { text-align: center; padding: 40px; color: #999; }
|
||||||
|
|
||||||
|
/* 弹窗 */
|
||||||
|
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); align-items: center; justify-content: center; z-index: 100; }
|
||||||
|
.modal-content { background: #fff; padding: 30px; border-radius: 20px; width: 90%; max-width: 450px; }
|
||||||
|
.input { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 8px; box-sizing: border-box; margin-bottom: 12px; font-size: 14px; }
|
||||||
|
.input:focus { border-color: var(--primary); outline: none; box-shadow: 0 0 0 2px rgba(102,126,234,0.2); }
|
||||||
|
.modal-actions { display: flex; gap: 10px; margin-top: 15px; }
|
||||||
|
.modal-actions .btn { flex: 1; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
body { padding: 12px; }
|
||||||
|
.header { flex-direction: column; gap: 12px; text-align: center; padding: 15px; }
|
||||||
|
.header h1 { font-size: 20px; }
|
||||||
|
.header-actions { width: 100%; }
|
||||||
|
.header-actions .btn { flex: 1; text-align: center; }
|
||||||
|
.item { flex-direction: column; align-items: flex-start; gap: 10px; padding: 12px; }
|
||||||
|
.item-actions { align-self: flex-end; }
|
||||||
|
.modal-content { padding: 20px; border-radius: 16px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>📂 分类管理</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-primary" onclick="openModal()">+ 新增分类</button>
|
||||||
|
<a href="/admin/dashboard" class="btn btn-back">返回</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-card" id="list">
|
||||||
|
<div class="empty">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/编辑弹窗 -->
|
||||||
|
<div id="modal" class="modal-overlay">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2 id="modalTitle" style="margin-bottom:20px">新增分类</h2>
|
||||||
|
<input type="hidden" id="editId">
|
||||||
|
<label style="font-size:13px; color:#666; margin-bottom:4px; display:block">分类名称</label>
|
||||||
|
<input type="text" id="catName" class="input" placeholder="如:内网服务" autofocus>
|
||||||
|
<label style="font-size:13px; color:#666; margin-bottom:4px; display:block">排序(数字越大越靠前)</label>
|
||||||
|
<input type="number" id="catOrder" class="input" placeholder="0" value="0">
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn" style="background:#eee" onclick="closeModal()">取消</button>
|
||||||
|
<button class="btn btn-primary" onclick="save()">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch('/admin/api/categories');
|
||||||
|
const data = await res.json();
|
||||||
|
const list = document.getElementById('list');
|
||||||
|
if (!data.data || data.data.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty">暂无分类,点击上方"+ 新增分类"添加</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = data.data.map(c => `
|
||||||
|
<div class="item">
|
||||||
|
<div class="item-info">
|
||||||
|
<span class="item-order">${c.sort_order}</span>
|
||||||
|
<span class="item-name">${escapeHTML(c.name)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-actions">
|
||||||
|
<button class="btn-edit" onclick='edit(${JSON.stringify(c)})'>编辑</button>
|
||||||
|
<button class="btn-del" onclick="del(${c.id}, '${escapeHTML(c.name)}')">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(id, name, order) {
|
||||||
|
document.getElementById('editId').value = id || '';
|
||||||
|
document.getElementById('catName').value = name || '';
|
||||||
|
document.getElementById('catOrder').value = order || 0;
|
||||||
|
document.getElementById('modalTitle').textContent = id ? '编辑分类' : '新增分类';
|
||||||
|
document.getElementById('modal').style.display = 'flex';
|
||||||
|
document.getElementById('catName').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(c) {
|
||||||
|
openModal(c.id, c.name, c.sort_order);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const id = document.getElementById('editId').value;
|
||||||
|
const name = document.getElementById('catName').value.trim();
|
||||||
|
const order = parseInt(document.getElementById('catOrder').value) || 0;
|
||||||
|
|
||||||
|
if (!name) { alert('分类名称不能为空'); return; }
|
||||||
|
|
||||||
|
const body = { name, sort_order: order };
|
||||||
|
if (id) body.id = parseInt(id);
|
||||||
|
|
||||||
|
const url = id ? `/admin/api/categories/${id}` : '/admin/api/categories';
|
||||||
|
const method = id ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const res = await resp.json();
|
||||||
|
if (res.success) {
|
||||||
|
closeModal();
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
alert(res.message || '保存失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(id, name) {
|
||||||
|
if (!confirm(`确定删除分类「${name}」?`)) return;
|
||||||
|
const resp = await fetch('/admin/api/categories/' + id, { method: 'DELETE' });
|
||||||
|
const res = await resp.json();
|
||||||
|
if (res.success) {
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
alert(res.message || '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHTML(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC 关闭弹窗
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
45
templates/admin/change_password.html
Normal file
45
templates/admin/change_password.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>修改密码 - ToNav</title>
|
||||||
|
<style>
|
||||||
|
:root { --main-red: #ff4d4f; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: "PingFang SC", -apple-system, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||||
|
.card { background: #fff; padding: 40px 30px; border-radius: 20px; width: 100%; max-width: 400px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
|
||||||
|
.card h2 { text-align: center; margin-bottom: 20px; font-size: 22px; }
|
||||||
|
.input { width: 100%; padding: 12px; margin: 8px 0 16px; border: 1px solid #ddd; border-radius: 8px; box-sizing: border-box; font-size: 15px; -webkit-appearance: none; }
|
||||||
|
.input:focus { border-color: #667eea; outline: none; box-shadow: 0 0 0 2px rgba(102,126,234,0.2); }
|
||||||
|
.btn { width: 100%; padding: 13px; background: #ff4d4f; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; margin-top: 10px; font-size: 16px; -webkit-appearance: none; }
|
||||||
|
.btn:active { transform: scale(0.98); }
|
||||||
|
.error-box { background: #fff2f0; border: 1px solid #ffccc7; color: #ff4d4f; padding: 10px; border-radius: 8px; margin-bottom: 16px; text-align: center; font-size: 14px; }
|
||||||
|
.alert { background: #e6f7ff; border: 1px solid #91d5ff; color: #1890ff; padding: 10px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; line-height: 1.5; }
|
||||||
|
label { font-size: 14px; color: #333; font-weight: 500; }
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.card { padding: 30px 20px; border-radius: 16px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h2>🔐 修改密码</h2>
|
||||||
|
<div class="alert">⚠️ 首次登录请先修改初始密码以启用管理功能。</div>
|
||||||
|
|
||||||
|
{{ if .error }}
|
||||||
|
<div class="error-box">{{ .error }}</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<label>当前密码</label>
|
||||||
|
<input type="password" name="old_password" placeholder="输入当前密码" class="input" required autocomplete="current-password">
|
||||||
|
<label>新密码</label>
|
||||||
|
<input type="password" name="new_password" placeholder="至少6位" class="input" required minlength="6" autocomplete="new-password">
|
||||||
|
<label>确认新密码</label>
|
||||||
|
<input type="password" name="confirm_password" placeholder="再次输入新密码" class="input" required autocomplete="new-password">
|
||||||
|
<button type="submit" class="btn">提交修改</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
260
templates/admin/dashboard.html
Normal file
260
templates/admin/dashboard.html
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ToNav 管理后台</title>
|
||||||
|
<style>
|
||||||
|
:root { --main-red: #ff4d4f; --primary: #667eea; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: "PingFang SC", -apple-system, sans-serif; background: #f0f2f5; min-height: 100vh; padding: 20px; }
|
||||||
|
a { text-decoration: none; color: inherit; }
|
||||||
|
.container { max-width: 900px; margin: 0 auto; }
|
||||||
|
.admin-header { display: flex; justify-content: space-between; align-items: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 25px 30px; border-radius: 20px; margin-bottom: 25px; }
|
||||||
|
.header-actions { display: flex; gap: 10px; }
|
||||||
|
.btn { padding: 10px 20px; border-radius: 10px; border: none; cursor: pointer; font-weight: 600; display: inline-block; font-size: 14px; transition: all .2s; }
|
||||||
|
.btn:hover { transform: translateY(-1px); }
|
||||||
|
.btn-light { background: rgba(255,255,255,0.2); color: #fff; }
|
||||||
|
.btn-light:hover { background: rgba(255,255,255,0.3); }
|
||||||
|
.btn-primary { background: var(--main-red); color: #fff; }
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; margin-bottom: 25px; }
|
||||||
|
.stat-card { background: #fff; border-radius: 16px; padding: 25px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.06); transition: transform .2s; }
|
||||||
|
.stat-card:hover { transform: translateY(-3px); }
|
||||||
|
.stat-value { font-size: 36px; font-weight: 700; color: #262626; }
|
||||||
|
.stat-label { font-size: 14px; color: #8c8c8c; margin-top: 5px; }
|
||||||
|
.stat-online .stat-value { color: #52c41a; }
|
||||||
|
.stat-offline .stat-value { color: #ff4d4f; }
|
||||||
|
.quick-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; }
|
||||||
|
.action-btn { background: #fff; border-radius: 16px; padding: 30px 20px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.06); cursor: pointer; transition: all .2s; font-size: 15px; font-weight: 500; border: none; }
|
||||||
|
.action-btn:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0,0,0,0.12); }
|
||||||
|
.input { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 8px; box-sizing: border-box; margin-bottom: 12px; font-size: 14px; }
|
||||||
|
.input:focus { border-color: var(--primary); outline: none; box-shadow: 0 0 0 2px rgba(102,126,234,0.2); }
|
||||||
|
|
||||||
|
/* 弹窗 */
|
||||||
|
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); align-items: center; justify-content: center; z-index: 100; }
|
||||||
|
.modal-content { background: #fff; padding: 30px; border-radius: 20px; width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
body { padding: 12px; }
|
||||||
|
.admin-header { flex-direction: column; gap: 15px; text-align: center; padding: 20px; }
|
||||||
|
.admin-header h1 { font-size: 20px; }
|
||||||
|
.header-actions { flex-wrap: wrap; justify-content: center; }
|
||||||
|
.header-actions .btn { padding: 8px 14px; font-size: 13px; }
|
||||||
|
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||||
|
.stat-card { padding: 18px 10px; }
|
||||||
|
.stat-value { font-size: 28px; }
|
||||||
|
.stat-label { font-size: 12px; }
|
||||||
|
.quick-actions { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||||
|
.action-btn { padding: 20px 10px; font-size: 14px; }
|
||||||
|
.modal-content { padding: 20px; border-radius: 16px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>🧭 ToNav 管理后台</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="/" class="btn btn-light">查看前台</a>
|
||||||
|
<a href="/admin/change-password" class="btn btn-light">修改密码</a>
|
||||||
|
<a href="/admin/logout" class="btn btn-primary">退出登录</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ .service_count }}</div>
|
||||||
|
<div class="stat-label">总服务数</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ .category_count }}</div>
|
||||||
|
<div class="stat-label">分类数</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-online">
|
||||||
|
<div class="stat-value">{{ .online_count }}</div>
|
||||||
|
<div class="stat-label">在线服务</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-offline">
|
||||||
|
<div class="stat-value">{{ .offline_count }}</div>
|
||||||
|
<div class="stat-label">离线服务</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quick-actions">
|
||||||
|
<a href="/admin/services" class="action-btn">
|
||||||
|
<div style="font-size:32px; margin-bottom:10px">📡</div>
|
||||||
|
<div>服务管理</div>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/categories" class="action-btn">
|
||||||
|
<div style="font-size:32px; margin-bottom:10px">📂</div>
|
||||||
|
<div>分类管理</div>
|
||||||
|
</a>
|
||||||
|
<div class="action-btn" onclick="runBackup()">
|
||||||
|
<div style="font-size:32px; margin-bottom:10px">☁️</div>
|
||||||
|
<div>立即云备份</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-btn" onclick="showSettings()">
|
||||||
|
<div style="font-size:32px; margin-bottom:10px">⚙️</div>
|
||||||
|
<div>系统设置</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 系统设置弹窗 -->
|
||||||
|
<div id="settingsModal" class="modal-overlay">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2 style="margin-bottom:20px">⚙️ 系统设置 & 云端备份</h2>
|
||||||
|
|
||||||
|
<div style="margin-bottom:20px; padding:15px; background:#f9f9f9; border-radius:10px">
|
||||||
|
<h3 style="font-size:14px; margin-bottom:10px">站点配置</h3>
|
||||||
|
<label style="font-size:12px; color:#666">站点标题</label>
|
||||||
|
<input type="text" id="cfg_site_title" class="input" placeholder="ToNav">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:20px; padding:15px; background:#f9f9f9; border-radius:10px">
|
||||||
|
<h3 style="font-size:14px; margin-bottom:10px">WebDAV 配置</h3>
|
||||||
|
<label style="font-size:12px; color:#666">WebDAV URL</label>
|
||||||
|
<input type="text" id="cfg_url" class="input">
|
||||||
|
<label style="font-size:12px; color:#666">用户名</label>
|
||||||
|
<input type="text" id="cfg_user" class="input">
|
||||||
|
<label style="font-size:12px; color:#666">密码</label>
|
||||||
|
<input type="password" id="cfg_pass" class="input">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:20px; padding:15px; background:#f9f9f9; border-radius:10px">
|
||||||
|
<h3 style="font-size:14px; margin-bottom:10px">自动备份</h3>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px">
|
||||||
|
<input type="checkbox" id="cfg_auto_backup" style="width:16px; height:16px">
|
||||||
|
<label for="cfg_auto_backup" style="margin:0; font-size:13px">每天凌晨 3:00 自动备份到云端</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" onclick="saveSettings()" style="width:100%; margin-bottom:15px">保存设置</button>
|
||||||
|
|
||||||
|
<div style="padding:15px; border:1px solid #eee; border-radius:10px">
|
||||||
|
<h3 style="font-size:14px; margin-bottom:10px">📜 云端备份历史</h3>
|
||||||
|
<div id="backupList" style="font-size:13px; color:#666">正在获取列表...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn" onclick="document.getElementById('settingsModal').style.display='none'" style="background:#eee; width:100%; margin-top:15px">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function showSettings() {
|
||||||
|
const resp = await fetch('/admin/api/settings');
|
||||||
|
const data = await resp.json();
|
||||||
|
document.getElementById('cfg_site_title').value = data.site_title || '';
|
||||||
|
document.getElementById('cfg_url').value = data.webdav_url || '';
|
||||||
|
document.getElementById('cfg_user').value = data.webdav_user || '';
|
||||||
|
document.getElementById('cfg_pass').value = data.webdav_password || '';
|
||||||
|
document.getElementById('cfg_auto_backup').checked = data.auto_backup === 'true';
|
||||||
|
document.getElementById('settingsModal').style.display = 'flex';
|
||||||
|
loadCloudBackups();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCloudBackups() {
|
||||||
|
const listDiv = document.getElementById('backupList');
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/admin/api/backup/list');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.files && data.files.length > 0) {
|
||||||
|
listDiv.innerHTML = data.files.map(f => `
|
||||||
|
<div style="padding:10px 0; border-bottom:1px solid #f0f0f0">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:500; color:#333">${f.name}</div>
|
||||||
|
<div style="font-size:11px; color:#999; margin-top:2px">
|
||||||
|
${formatTime(f.mod_time)}${f.size ? ' · ' + formatSize(f.size) : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:6px">
|
||||||
|
<button onclick="restoreBackup('${f.name}')" style="background:none; border:1px solid #1890ff; color:#1890ff; padding:4px 10px; border-radius:6px; cursor:pointer; font-size:12px">恢复</button>
|
||||||
|
<button onclick="deleteBackup('${f.name}')" style="background:none; border:1px solid #ff4d4f; color:#ff4d4f; padding:4px 10px; border-radius:6px; cursor:pointer; font-size:12px">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
listDiv.innerHTML = '暂无云端备份';
|
||||||
|
}
|
||||||
|
} catch(e) { listDiv.innerHTML = '获取失败'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (!bytes || bytes === 0) return '';
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
try {
|
||||||
|
const d = new Date(str);
|
||||||
|
if (isNaN(d.getTime())) return str;
|
||||||
|
return d.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' });
|
||||||
|
} catch(e) { return str; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
const data = {
|
||||||
|
site_title: document.getElementById('cfg_site_title').value,
|
||||||
|
webdav_url: document.getElementById('cfg_url').value,
|
||||||
|
webdav_user: document.getElementById('cfg_user').value,
|
||||||
|
webdav_password: document.getElementById('cfg_pass').value,
|
||||||
|
auto_backup: document.getElementById('cfg_auto_backup').checked ? 'true' : 'false'
|
||||||
|
};
|
||||||
|
const resp = await fetch('/admin/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
const res = await resp.json();
|
||||||
|
alert(res.message || '保存成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBackup() {
|
||||||
|
if (!confirm('确定执行云端备份?')) return;
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
const oldContent = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<div style="font-size:32px; margin-bottom:10px">⌛</div><div>正在备份...</div>';
|
||||||
|
btn.style.pointerEvents = 'none';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/admin/api/backup/webdav', {method: 'POST'});
|
||||||
|
const res = await resp.json();
|
||||||
|
alert(res.message);
|
||||||
|
} catch(e) { alert('备份失败'); }
|
||||||
|
btn.innerHTML = oldContent;
|
||||||
|
btn.style.pointerEvents = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreBackup(name) {
|
||||||
|
if (!confirm(`⚠️ 确定从「${name}」恢复?\n\n当前数据库会先自动备份,然后被覆盖为所选版本。`)) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/admin/api/backup/restore?name=${encodeURIComponent(name)}`, {method: 'POST'});
|
||||||
|
const res = await resp.json();
|
||||||
|
if (res.success) {
|
||||||
|
alert('✅ ' + res.message + '\n\n页面将刷新...');
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('❌ ' + res.message);
|
||||||
|
}
|
||||||
|
} catch(e) { alert('恢复失败'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBackup(name) {
|
||||||
|
if (!confirm(`确定删除云端备份「${name}」?此操作不可撤销。`)) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/admin/api/backup/delete?name=${encodeURIComponent(name)}`, {method: 'DELETE'});
|
||||||
|
const res = await resp.json();
|
||||||
|
if (res.success) {
|
||||||
|
loadCloudBackups();
|
||||||
|
} else {
|
||||||
|
alert(res.message);
|
||||||
|
}
|
||||||
|
} catch(e) { alert('删除失败'); }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
templates/admin/login.html
Normal file
40
templates/admin/login.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ToNav 管理登录</title>
|
||||||
|
<style>
|
||||||
|
:root { --main-red: #ff4d4f; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: "PingFang SC", -apple-system, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||||
|
.login-card { background: #fff; border-radius: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); max-width: 400px; width: 100%; padding: 40px 30px; }
|
||||||
|
.login-card h1 { text-align: center; margin-bottom: 30px; font-size: 28px; }
|
||||||
|
label { display: block; margin-bottom: 6px; font-size: 14px; color: #333; font-weight: 500; }
|
||||||
|
.input { width: 100%; padding: 12px; border: 1px solid #d9d9d9; border-radius: 10px; box-sizing: border-box; margin-bottom: 18px; font-size: 15px; -webkit-appearance: none; }
|
||||||
|
.input:focus { border-color: #667eea; outline: none; box-shadow: 0 0 0 2px rgba(102,126,234,0.2); }
|
||||||
|
.btn { width: 100%; padding: 13px; border: none; border-radius: 10px; cursor: pointer; font-weight: 600; background: var(--main-red); color: #fff; font-size: 16px; -webkit-appearance: none; }
|
||||||
|
.btn:active { transform: scale(0.98); }
|
||||||
|
.error-box { background: #fff2f0; border: 1px solid #ffccc7; color: #ff4d4f; padding: 10px; border-radius: 8px; margin-bottom: 20px; text-align: center; font-size: 14px; }
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.login-card { padding: 30px 20px; border-radius: 16px; }
|
||||||
|
.login-card h1 { font-size: 24px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>🧭 ToNav</h1>
|
||||||
|
{{ if .error }}
|
||||||
|
<div class="error-box">{{ .error }}</div>
|
||||||
|
{{ end }}
|
||||||
|
<form method="POST">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input type="text" name="username" class="input" required autofocus autocomplete="username">
|
||||||
|
<label>密码</label>
|
||||||
|
<input type="password" name="password" class="input" required autocomplete="current-password">
|
||||||
|
<button type="submit" class="btn">登 录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
289
templates/admin/services.html
Normal file
289
templates/admin/services.html
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>服务管理 - ToNav</title>
|
||||||
|
<style>
|
||||||
|
:root { --main-red: #ff4d4f; --primary: #667eea; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: "PingFang SC", -apple-system, sans-serif; background: #f0f2f5; padding: 20px; }
|
||||||
|
a { text-decoration: none; color: inherit; }
|
||||||
|
.container { max-width: 1000px; margin: 0 auto; }
|
||||||
|
.header { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 20px 25px; border-radius: 15px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||||
|
.btn { padding: 10px 20px; border-radius: 8px; border: none; cursor: pointer; font-weight: bold; display: inline-block; font-size: 14px; transition: all .2s; }
|
||||||
|
.btn:hover { transform: translateY(-1px); }
|
||||||
|
.btn-primary { background: var(--main-red); color: #fff; }
|
||||||
|
.btn-back { background: #eee; color: #333; }
|
||||||
|
.header-actions { display: flex; gap: 10px; }
|
||||||
|
|
||||||
|
.table-card { background: #fff; border-radius: 15px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { background: #fafafa; font-size: 13px; color: #999; text-align: left; padding: 12px 15px; font-weight: 500; }
|
||||||
|
td { padding: 12px 15px; border-bottom: 1px solid #f5f5f5; font-size: 14px; }
|
||||||
|
tr:hover td { background: #fafafa; }
|
||||||
|
.status-badge { padding: 3px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; }
|
||||||
|
.status-online { background: #f6ffed; color: #52c41a; }
|
||||||
|
.status-offline { background: #fff2f0; color: #ff4d4f; }
|
||||||
|
.btn-edit { color: var(--primary); background: none; border: none; cursor: pointer; padding: 4px 8px; border-radius: 4px; }
|
||||||
|
.btn-edit:hover { background: rgba(102,126,234,0.1); }
|
||||||
|
.btn-del { color: var(--main-red); background: none; border: none; cursor: pointer; padding: 4px 8px; border-radius: 4px; }
|
||||||
|
.btn-del:hover { background: rgba(255,77,79,0.1); }
|
||||||
|
.empty { text-align: center; padding: 40px; color: #999; }
|
||||||
|
.icon-cell { font-size: 24px; }
|
||||||
|
|
||||||
|
/* 弹窗 */
|
||||||
|
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); align-items: center; justify-content: center; z-index: 100; }
|
||||||
|
.modal-content { background: #fff; padding: 30px; border-radius: 20px; width: 90%; max-width: 550px; max-height: 85vh; overflow-y: auto; }
|
||||||
|
.input { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 8px; box-sizing: border-box; margin-bottom: 12px; font-size: 14px; }
|
||||||
|
.input:focus { border-color: var(--primary); outline: none; box-shadow: 0 0 0 2px rgba(102,126,234,0.2); }
|
||||||
|
select.input { appearance: none; -webkit-appearance: none; background: #fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e") no-repeat right 12px center/12px; }
|
||||||
|
.form-row { display: flex; gap: 12px; }
|
||||||
|
.form-row > div { flex: 1; }
|
||||||
|
label { font-size: 13px; color: #666; margin-bottom: 4px; display: block; }
|
||||||
|
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.checkbox-row input[type="checkbox"] { width: 16px; height: 16px; }
|
||||||
|
.modal-actions { display: flex; gap: 10px; margin-top: 15px; }
|
||||||
|
.modal-actions .btn { flex: 1; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
body { padding: 12px; }
|
||||||
|
.header { flex-direction: column; gap: 12px; text-align: center; padding: 15px; }
|
||||||
|
.header h1 { font-size: 20px; }
|
||||||
|
.header-actions { width: 100%; }
|
||||||
|
.header-actions .btn { flex: 1; text-align: center; }
|
||||||
|
.form-row { flex-direction: column; gap: 0; }
|
||||||
|
/* 手机端隐藏状态和排序列 */
|
||||||
|
th:nth-child(3), td:nth-child(3),
|
||||||
|
th:nth-child(4), td:nth-child(4),
|
||||||
|
th:nth-child(5), td:nth-child(5) { display: none; }
|
||||||
|
th, td { padding: 10px 8px; font-size: 13px; }
|
||||||
|
.icon-cell { font-size: 20px; }
|
||||||
|
.modal-content { padding: 20px; border-radius: 16px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>📡 服务管理</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-primary" onclick="openModal()">+ 新增服务</button>
|
||||||
|
<a href="/admin/dashboard" class="btn btn-back">返回</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>图标</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>分类</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>排序</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="list"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="emptyMsg" class="empty" style="display:none">暂无服务,点击上方"+ 新增服务"添加</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/编辑弹窗 -->
|
||||||
|
<div id="modal" class="modal-overlay">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2 id="modalTitle" style="margin-bottom:20px">新增服务</h2>
|
||||||
|
<input type="hidden" id="editId">
|
||||||
|
|
||||||
|
<label>服务名称 *</label>
|
||||||
|
<input type="text" id="svcName" class="input" placeholder="如:Gitea" required>
|
||||||
|
|
||||||
|
<label>服务地址 *</label>
|
||||||
|
<input type="url" id="svcUrl" class="input" placeholder="https://example.com">
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div>
|
||||||
|
<label>图标(emoji)</label>
|
||||||
|
<input type="text" id="svcIcon" class="input" placeholder="🔧" maxlength="4">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>所属分类</label>
|
||||||
|
<select id="svcCategory" class="input">
|
||||||
|
<option value="0">-- 无分类 --</option>
|
||||||
|
{{ range .categories }}
|
||||||
|
<option value="{{ .ID }}">{{ .Name }}</option>
|
||||||
|
{{ end }}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>描述</label>
|
||||||
|
<input type="text" id="svcDesc" class="input" placeholder="简短描述">
|
||||||
|
|
||||||
|
<label>标签(逗号分隔)</label>
|
||||||
|
<input type="text" id="svcTags" class="input" placeholder="开发,工具">
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div>
|
||||||
|
<label>排序</label>
|
||||||
|
<input type="number" id="svcOrder" class="input" value="0">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>健康检查 URL</label>
|
||||||
|
<input type="url" id="svcHealthUrl" class="input" placeholder="可选">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" id="svcEnabled" checked>
|
||||||
|
<label for="svcEnabled" style="margin:0">启用</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" id="svcHealthEnabled">
|
||||||
|
<label for="svcHealthEnabled" style="margin:0">启用健康检查</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn" style="background:#eee" onclick="closeModal()">取消</button>
|
||||||
|
<button class="btn btn-primary" onclick="save()">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 分类映射
|
||||||
|
const categoryMap = {};
|
||||||
|
{{ range .categories }}
|
||||||
|
categoryMap[{{ .ID }}] = "{{ .Name }}";
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch('/admin/api/services');
|
||||||
|
const data = await res.json();
|
||||||
|
const list = document.getElementById('list');
|
||||||
|
const emptyMsg = document.getElementById('emptyMsg');
|
||||||
|
|
||||||
|
if (!data.data || data.data.length === 0) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
emptyMsg.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyMsg.style.display = 'none';
|
||||||
|
|
||||||
|
list.innerHTML = data.data.map(s => `
|
||||||
|
<tr>
|
||||||
|
<td class="icon-cell">${s.icon || '🔗'}</td>
|
||||||
|
<td><strong>${escapeHTML(s.name)}</strong></td>
|
||||||
|
<td>${categoryMap[s.category_id] || '-'}</td>
|
||||||
|
<td><span class="status-badge status-${s.status}">${s.status === 'online' ? '在线' : '离线'}</span></td>
|
||||||
|
<td>${s.sort_order}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-edit" onclick='editSvc(${JSON.stringify(s)})'>编辑</button>
|
||||||
|
<button class="btn-del" onclick="del(${s.id}, '${escapeHTML(s.name)}')">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
document.getElementById('editId').value = '';
|
||||||
|
document.getElementById('svcName').value = '';
|
||||||
|
document.getElementById('svcUrl').value = '';
|
||||||
|
document.getElementById('svcIcon').value = '';
|
||||||
|
document.getElementById('svcCategory').value = '0';
|
||||||
|
document.getElementById('svcDesc').value = '';
|
||||||
|
document.getElementById('svcTags').value = '';
|
||||||
|
document.getElementById('svcOrder').value = '0';
|
||||||
|
document.getElementById('svcHealthUrl').value = '';
|
||||||
|
document.getElementById('svcEnabled').checked = true;
|
||||||
|
document.getElementById('svcHealthEnabled').checked = false;
|
||||||
|
document.getElementById('modalTitle').textContent = '新增服务';
|
||||||
|
document.getElementById('modal').style.display = 'flex';
|
||||||
|
document.getElementById('svcName').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSvc(s) {
|
||||||
|
document.getElementById('editId').value = s.id;
|
||||||
|
document.getElementById('svcName').value = s.name;
|
||||||
|
document.getElementById('svcUrl').value = s.url;
|
||||||
|
document.getElementById('svcIcon').value = s.icon || '';
|
||||||
|
document.getElementById('svcCategory').value = s.category_id || '0';
|
||||||
|
document.getElementById('svcDesc').value = s.description || '';
|
||||||
|
document.getElementById('svcTags').value = s.tags || '';
|
||||||
|
document.getElementById('svcOrder').value = s.sort_order || 0;
|
||||||
|
document.getElementById('svcHealthUrl').value = s.health_check_url || '';
|
||||||
|
document.getElementById('svcEnabled').checked = s.is_enabled;
|
||||||
|
document.getElementById('svcHealthEnabled').checked = s.health_check_enabled;
|
||||||
|
document.getElementById('modalTitle').textContent = '编辑服务';
|
||||||
|
document.getElementById('modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const id = document.getElementById('editId').value;
|
||||||
|
const name = document.getElementById('svcName').value.trim();
|
||||||
|
const url = document.getElementById('svcUrl').value.trim();
|
||||||
|
|
||||||
|
if (!name) { alert('服务名称不能为空'); return; }
|
||||||
|
if (!url) { alert('服务地址不能为空'); return; }
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
icon: document.getElementById('svcIcon').value.trim(),
|
||||||
|
category_id: parseInt(document.getElementById('svcCategory').value) || 0,
|
||||||
|
description: document.getElementById('svcDesc').value.trim(),
|
||||||
|
tags: document.getElementById('svcTags').value.trim(),
|
||||||
|
sort_order: parseInt(document.getElementById('svcOrder').value) || 0,
|
||||||
|
health_check_url: document.getElementById('svcHealthUrl').value.trim(),
|
||||||
|
is_enabled: document.getElementById('svcEnabled').checked,
|
||||||
|
health_check_enabled: document.getElementById('svcHealthEnabled').checked,
|
||||||
|
};
|
||||||
|
if (id) body.id = parseInt(id);
|
||||||
|
|
||||||
|
const apiUrl = id ? `/admin/api/services/${id}` : '/admin/api/services';
|
||||||
|
const method = id ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const resp = await fetch(apiUrl, {
|
||||||
|
method,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const res = await resp.json();
|
||||||
|
if (res.success) {
|
||||||
|
closeModal();
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
alert(res.message || '保存失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(id, name) {
|
||||||
|
if (!confirm(`确定删除服务「${name}」?`)) return;
|
||||||
|
const resp = await fetch('/admin/api/services/' + id, { method: 'DELETE' });
|
||||||
|
const res = await resp.json();
|
||||||
|
if (res.success) {
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
alert(res.message || '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHTML(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str || '';
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
297
templates/index.html
Normal file
297
templates/index.html
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ .site_title }} - 导航服务</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--main-red: #ff4d4f;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
--header-gradient: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: "PingFang SC", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--primary-gradient);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
a { text-decoration: none; color: inherit; }
|
||||||
|
.container { max-width: 800px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* 头部 */
|
||||||
|
.header {
|
||||||
|
background: var(--header-gradient);
|
||||||
|
color: #fff;
|
||||||
|
padding: 25px 20px;
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.header h1 { font-size: 24px; font-weight: 700; margin-bottom: 5px; }
|
||||||
|
.subtitle { font-size: 13px; color: #8c8c8c; margin-bottom: 10px; }
|
||||||
|
.status-bar { font-size: 12px; color: #595959; }
|
||||||
|
|
||||||
|
/* 搜索栏 */
|
||||||
|
.search-bar {
|
||||||
|
background: #262626;
|
||||||
|
padding: 15px 20px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.search-bar input {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 15px 10px 40px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
.search-bar input:focus {
|
||||||
|
outline: none;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
border-color: var(--main-red);
|
||||||
|
}
|
||||||
|
.search-bar input::placeholder { color: #8c8c8c; }
|
||||||
|
.search-icon { position: absolute; left: 35px; color: #8c8c8c; }
|
||||||
|
|
||||||
|
/* 分类 Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
background: #262626;
|
||||||
|
padding: 8px;
|
||||||
|
gap: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.tabs::-webkit-scrollbar { display: none; }
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: fit-content;
|
||||||
|
padding: 12px 10px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tab-btn:hover { color: #bfbfbf; background: rgba(255,255,255,0.05); }
|
||||||
|
.tab-btn.active {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--main-red);
|
||||||
|
box-shadow: 0 4px 15px rgba(255, 77, 79, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 服务卡片网格 */
|
||||||
|
.services-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
padding: 20px 0;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
.service-card {
|
||||||
|
background: #f7f7f8;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
animation: fadeInUp 0.5s ease backwards;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.service-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
|
||||||
|
border-color: rgba(102,126,234,0.3);
|
||||||
|
}
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.card-icon { font-size: 30px; margin-right: 14px; flex-shrink: 0; }
|
||||||
|
.card-info { min-width: 0; flex: 1; }
|
||||||
|
.card-name {
|
||||||
|
font-size: 16px; font-weight: 600; color: #262626;
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.card-desc {
|
||||||
|
font-size: 13px; color: #8c8c8c; margin-top: 4px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
display: inline-block; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.status-dot.online {
|
||||||
|
background: #52c41a;
|
||||||
|
box-shadow: 0 0 6px rgba(82,196,26,0.4);
|
||||||
|
}
|
||||||
|
.status-dot.offline {
|
||||||
|
background: #f5222d;
|
||||||
|
box-shadow: 0 0 6px rgba(245,34,45,0.4);
|
||||||
|
}
|
||||||
|
.status-dot.unknown {
|
||||||
|
background: #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部 */
|
||||||
|
.footer {
|
||||||
|
background: var(--header-gradient);
|
||||||
|
color: #8c8c8c;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 0 0 20px 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.footer a { color: var(--main-red); transition: opacity 0.3s; }
|
||||||
|
.footer a:hover { opacity: 0.8; }
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body { padding: 10px; }
|
||||||
|
.services-grid { grid-template-columns: 1fr; }
|
||||||
|
.tab-btn { font-size: 13px; padding: 10px 8px; }
|
||||||
|
.header { padding: 20px 15px; border-radius: 16px 16px 0 0; }
|
||||||
|
.footer { border-radius: 0 0 16px 16px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🧭 {{ .site_title }}</h1>
|
||||||
|
<div class="subtitle">个人导航站</div>
|
||||||
|
<div class="status-bar"><span id="lastCheckTime">加载中...</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" id="searchInput" placeholder="搜索服务或描述..." oninput="handleSearch()">
|
||||||
|
<span class="search-icon">🔍</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs" id="categoryTabs"></div>
|
||||||
|
|
||||||
|
<div class="services-grid" id="servicesGrid">
|
||||||
|
<div class="empty-state">加载中...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<div>© 2026 ToNav - <a href="/admin">管理后台</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 服务端渲染的数据
|
||||||
|
const categoriesData = {{ .categories_json }};
|
||||||
|
const servicesData = {{ .services_json }};
|
||||||
|
|
||||||
|
// 构建分类ID → 名称映射
|
||||||
|
const categoryMap = {};
|
||||||
|
categoriesData.forEach(c => { categoryMap[c.id] = c.name; });
|
||||||
|
|
||||||
|
let currentTab = 'all';
|
||||||
|
let currentKeyword = '';
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
renderTabs();
|
||||||
|
renderServices('all');
|
||||||
|
updateLastCheckTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderTabs() {
|
||||||
|
const container = document.getElementById('categoryTabs');
|
||||||
|
let html = '<button class="tab-btn active" data-category="all">全部</button>';
|
||||||
|
categoriesData.forEach(c => {
|
||||||
|
html += `<button class="tab-btn" data-category="${c.name}">${c.name}</button>`;
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
container.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
currentTab = this.dataset.category;
|
||||||
|
container.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
this.classList.add('active');
|
||||||
|
renderServices(currentTab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
currentKeyword = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
renderServices(currentTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServices(category) {
|
||||||
|
const container = document.getElementById('servicesGrid');
|
||||||
|
|
||||||
|
let filtered = category === 'all'
|
||||||
|
? servicesData
|
||||||
|
: servicesData.filter(s => categoryMap[s.category_id] === category);
|
||||||
|
|
||||||
|
if (currentKeyword) {
|
||||||
|
filtered = filtered.filter(s =>
|
||||||
|
s.name.toLowerCase().includes(currentKeyword) ||
|
||||||
|
(s.description && s.description.toLowerCase().includes(currentKeyword)) ||
|
||||||
|
(s.tags && s.tags.toLowerCase().includes(currentKeyword))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state">暂无服务</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = filtered.map((s, i) => {
|
||||||
|
const statusClass = s.status === 'online' ? 'online' : (s.status === 'offline' ? 'offline' : 'unknown');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<a href="${escapeAttr(s.url)}" target="_blank" class="service-card" style="animation-delay: ${i * 0.05}s">
|
||||||
|
<div class="card-icon">${s.icon || '🔗'}</div>
|
||||||
|
<div class="card-info">
|
||||||
|
<div class="card-name">${escapeHTML(s.name)} <span class="status-dot ${statusClass}"></span></div>
|
||||||
|
${s.description ? `<div class="card-desc">${escapeHTML(s.description)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHTML(str) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = str || '';
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
function escapeAttr(str) {
|
||||||
|
return (str||'').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
function updateLastCheckTime() {
|
||||||
|
const now = new Date();
|
||||||
|
document.getElementById('lastCheckTime').textContent = '最后更新: ' +
|
||||||
|
now.toLocaleDateString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => { location.reload(); }, 30000);
|
||||||
|
document.addEventListener('visibilitychange', function() {
|
||||||
|
if (!document.hidden) location.reload();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
82
tonav-go-ctl.sh
Executable file
82
tonav-go-ctl.sh
Executable file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
APP_NAME="tonav-go"
|
||||||
|
APP_DIR="/root/.openclaw/workspace/ToNav-go"
|
||||||
|
BINARY_NAME="tonav-go-v1"
|
||||||
|
PID_FILE="$APP_DIR/tonav-go.pid"
|
||||||
|
LOG_FILE="$APP_DIR/tonav.log"
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if [ -f $PID_FILE ]; then
|
||||||
|
PID=$(cat $PID_FILE)
|
||||||
|
if ps -p $PID > /dev/null; then
|
||||||
|
echo "$APP_NAME is already running (PID: $PID)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting $APP_NAME..."
|
||||||
|
cd $APP_DIR
|
||||||
|
nohup ./$BINARY_NAME >> $LOG_FILE 2>&1 &
|
||||||
|
echo $! > $PID_FILE
|
||||||
|
echo "$APP_NAME started with PID: $(cat $PID_FILE)"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if [ -f $PID_FILE ]; then
|
||||||
|
PID=$(cat $PID_FILE)
|
||||||
|
echo "Stopping $APP_NAME (PID: $PID)..."
|
||||||
|
kill $PID
|
||||||
|
rm $PID_FILE
|
||||||
|
echo "$APP_NAME stopped."
|
||||||
|
else
|
||||||
|
echo "$APP_NAME is not running."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
if [ -f $PID_FILE ]; then
|
||||||
|
PID=$(cat $PID_FILE)
|
||||||
|
if ps -p $PID > /dev/null; then
|
||||||
|
echo "$APP_NAME is running (PID: $PID)"
|
||||||
|
else
|
||||||
|
echo "$APP_NAME is not running (stale PID file)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "$APP_NAME is not running."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
echo "Building $APP_NAME..."
|
||||||
|
cd $APP_DIR
|
||||||
|
go build -o $BINARY_NAME
|
||||||
|
echo "Build complete."
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
start)
|
||||||
|
start
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
stop
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
stop
|
||||||
|
sleep 2
|
||||||
|
start
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
status
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
build
|
||||||
|
;;
|
||||||
|
log)
|
||||||
|
tail -f $LOG_FILE
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {start|stop|restart|status|build|log}"
|
||||||
|
exit 1
|
||||||
|
esac
|
||||||
46
utils/config.go
Normal file
46
utils/config.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
DBPath string
|
||||||
|
SecretKey string
|
||||||
|
LogPath string
|
||||||
|
WebDAVURL string
|
||||||
|
WebDAVUser string
|
||||||
|
WebDAVPassword string
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Port: getEnv("TONAV_PORT", "9520"),
|
||||||
|
DBPath: getEnv("TONAV_DB", "tonav.db"),
|
||||||
|
SecretKey: getEnv("TONAV_SECRET", "tonav-secret-key-7306783874"),
|
||||||
|
LogPath: "tonav.log",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if value, ok := os.LookupEnv(key); ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceDB 用备份文件替换当前数据库
|
||||||
|
func ReplaceDB(srcPath, dstPath string) error {
|
||||||
|
input, err := os.ReadFile(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取备份文件失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dstPath, input, 0644); err != nil {
|
||||||
|
return fmt.Errorf("替换数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
// 清理临时文件
|
||||||
|
os.Remove(srcPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
259
utils/webdav.go
Normal file
259
utils/webdav.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebDAVClient struct {
|
||||||
|
URL string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupInfo 备份文件信息
|
||||||
|
type BackupInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ModTime string `json:"mod_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebDAVClient(url, user, pass string) *WebDAVClient {
|
||||||
|
if !strings.HasSuffix(url, "/") {
|
||||||
|
url += "/"
|
||||||
|
}
|
||||||
|
return &WebDAVClient{URL: url, Username: user, Password: pass}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WebDAVClient) Upload(localPath, remoteName string) error {
|
||||||
|
file, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("PUT", w.URL+remoteName, file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(w.Username, w.Password)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("upload failed: %s", resp.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download 从 WebDAV 下载文件
|
||||||
|
func (w *WebDAVClient) Download(remoteName, localPath string) error {
|
||||||
|
req, err := http.NewRequest("GET", w.URL+remoteName, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(w.Username, w.Password)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("download failed: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除 WebDAV 上的文件
|
||||||
|
func (w *WebDAVClient) Delete(remoteName string) error {
|
||||||
|
req, err := http.NewRequest("DELETE", w.URL+remoteName, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(w.Username, w.Password)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("delete failed: %s", resp.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebDAV PROPFIND XML 结构
|
||||||
|
type multiStatus struct {
|
||||||
|
Responses []davResponse `xml:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type davResponse struct {
|
||||||
|
Href string `xml:"href"`
|
||||||
|
PropStat []propStat `xml:"propstat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type propStat struct {
|
||||||
|
Prop davProp `xml:"prop"`
|
||||||
|
Status string `xml:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type davProp struct {
|
||||||
|
ContentLength int64 `xml:"getcontentlength"`
|
||||||
|
LastModified string `xml:"getlastmodified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 列出云端备份,返回详细信息
|
||||||
|
func (w *WebDAVClient) List() ([]BackupInfo, error) {
|
||||||
|
req, err := http.NewRequest("PROPFIND", w.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Depth", "1")
|
||||||
|
req.SetBasicAuth(w.Username, w.Password)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
// 尝试 XML 解析获取详细信息
|
||||||
|
var ms multiStatus
|
||||||
|
re := regexp.MustCompile(`tonav_backup_[0-9_]+\.db`)
|
||||||
|
|
||||||
|
if xml.Unmarshal(body, &ms) == nil && len(ms.Responses) > 0 {
|
||||||
|
var list []BackupInfo
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, r := range ms.Responses {
|
||||||
|
matches := re.FindAllString(r.Href, -1)
|
||||||
|
for _, name := range matches {
|
||||||
|
if seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
info := BackupInfo{Name: name}
|
||||||
|
if len(r.PropStat) > 0 {
|
||||||
|
info.Size = r.PropStat[0].Prop.ContentLength
|
||||||
|
info.ModTime = r.PropStat[0].Prop.LastModified
|
||||||
|
}
|
||||||
|
// 从文件名解析时间
|
||||||
|
if info.ModTime == "" {
|
||||||
|
info.ModTime = parseTimeFromName(name)
|
||||||
|
}
|
||||||
|
list = append(list, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 按名称倒序(最新的在前)
|
||||||
|
sort.Slice(list, func(i, j int) bool {
|
||||||
|
return list[i].Name > list[j].Name
|
||||||
|
})
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:正则匹配
|
||||||
|
matches := re.FindAllString(string(body), -1)
|
||||||
|
unique := make(map[string]bool)
|
||||||
|
var list []BackupInfo
|
||||||
|
for _, m := range matches {
|
||||||
|
if !unique[m] {
|
||||||
|
unique[m] = true
|
||||||
|
list = append(list, BackupInfo{
|
||||||
|
Name: m,
|
||||||
|
ModTime: parseTimeFromName(m),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(list, func(i, j int) bool {
|
||||||
|
return list[i].Name > list[j].Name
|
||||||
|
})
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTimeFromName 从备份文件名解析时间
|
||||||
|
func parseTimeFromName(name string) string {
|
||||||
|
re := regexp.MustCompile(`(\d{8})_(\d{6})`)
|
||||||
|
m := re.FindStringSubmatch(name)
|
||||||
|
if len(m) == 3 {
|
||||||
|
t, err := time.Parse("20060102_150405", m[1]+"_"+m[2])
|
||||||
|
if err == nil {
|
||||||
|
return t.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBackup 创建本地备份,存放到 backups/ 目录
|
||||||
|
func CreateBackup(dbPath string) (string, error) {
|
||||||
|
// 确保 backups 目录存在
|
||||||
|
backupDir := "backups"
|
||||||
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("创建备份目录失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
backupName := fmt.Sprintf("tonav_backup_%s.db", timestamp)
|
||||||
|
backupPath := filepath.Join(backupDir, backupName)
|
||||||
|
|
||||||
|
input, err := os.ReadFile(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("读取数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
err = os.WriteFile(backupPath, input, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("写入备份文件失败: %v", err)
|
||||||
|
}
|
||||||
|
return backupPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanOldBackups 清理本地旧备份,保留最近 keep 份
|
||||||
|
func CleanOldBackups(keep int) {
|
||||||
|
backupDir := "backups"
|
||||||
|
entries, err := os.ReadDir(backupDir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
re := regexp.MustCompile(`^tonav_backup_\d{8}_\d{6}\.db$`)
|
||||||
|
var backups []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && re.MatchString(e.Name()) {
|
||||||
|
backups = append(backups, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按名称排序(时间戳命名,字母序即时间序)
|
||||||
|
sort.Strings(backups)
|
||||||
|
|
||||||
|
// 删除多余的旧备份
|
||||||
|
if len(backups) > keep {
|
||||||
|
for _, name := range backups[:len(backups)-keep] {
|
||||||
|
os.Remove(filepath.Join(backupDir, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user