docs: add comprehensive README.md and fix bugs
- add README.md with usage and deployment guide - fix category sync logic in backend - fix URL overflow in admin services list - fix data caching issues in front-end and back-end - add 'View Front-end' button in admin dashboard
This commit is contained in:
239
README.md
239
README.md
@@ -1,206 +1,69 @@
|
|||||||
# ToNav - 个人导航页系统
|
# ToNav - 个人导航页系统
|
||||||
|
|
||||||
> 一个简洁实用的个人服务导航与健康管理平台
|
ToNav 是一个轻量级、简洁美观的个人内网服务/常用链接导航系统。它采用 Flask + SQLite 架构,支持响应式布局、分类管理、服务健康状态检测以及完善的后台管理功能。
|
||||||
|
|
||||||
## 📋 项目概述
|
## 🎨 界面风格
|
||||||
|
继承自 `contraband_manager` 的设计语言:
|
||||||
|
- **紫色渐变背景**: 现代感十足的视觉体验。
|
||||||
|
- **卡片式布局**: 简洁直观的服务展示。
|
||||||
|
- **响应式设计**: 完美适配电脑、平板及移动端。
|
||||||
|
- **状态感知**: 实时显示服务的在线/离线状态。
|
||||||
|
|
||||||
ToNav 是一个基于 Flask 的轻量级个人导航页系统,用于管理和展示内部服务,并提供服务健康状态监控功能。
|
## 🚀 核心功能
|
||||||
|
- **服务管理**: 支持添加、修改、删除服务,支持自定义图标 (Emoji)、描述和排序权重。
|
||||||
|
- **分类管理**: 灵活的分类系统,支持分类重命名及同步更新所属服务。
|
||||||
|
- **健康检测**: 自动检测服务 URL 的可用性,前台实时反馈(在线 🟢 / 离线 🔴)。
|
||||||
|
- **后台管理**: 完善的 Dashboard 统计,支持修改管理员密码。
|
||||||
|
- **防缓存机制**: API 请求自带时间戳,确保数据修改后即刻生效。
|
||||||
|
|
||||||
## ✨ 核心功能
|
## 🛠️ 技术栈
|
||||||
|
- **后端**: Python 3 + Flask
|
||||||
|
- **数据库**: SQLite 3
|
||||||
|
- **前端**: HTML5 + CSS3 (Grid/Flexbox) + Vanilla JavaScript
|
||||||
|
- **部署**: Systemd + Bash Control Script
|
||||||
|
|
||||||
### 前台展示
|
## 📦 安装与部署
|
||||||
- 🎨 美观的服务导航页
|
|
||||||
- 📱 响应式设计,支持移动端
|
|
||||||
- 🔖 服务分类展示
|
|
||||||
- ✅ 实时显示服务健康状态
|
|
||||||
|
|
||||||
### 管理后台
|
### 依赖安装
|
||||||
- 🔐 安全登录系统(bcrypt 密码哈希)
|
|
||||||
- 🛠️ 服务管理(增删改查)
|
|
||||||
- 📁 分类管理
|
|
||||||
- 🔄 服务启用/禁用切换
|
|
||||||
- 🔍 手动健康检查触发
|
|
||||||
- 🔑 管理员密码修改
|
|
||||||
|
|
||||||
### 健康监控
|
|
||||||
- ⏱️ 后台定时健康检查(默认 60 秒)
|
|
||||||
- 🚨 支持自定义健康检查 URL
|
|
||||||
- ⏱️ 超时控制(默认 5 秒)
|
|
||||||
- 📊 状态记录(在线/离线/超时/连接错误)
|
|
||||||
|
|
||||||
## 🏗️ 技术栈
|
|
||||||
|
|
||||||
| 技术 | 版本 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| Flask | 3.0.0 | Web 框架 |
|
|
||||||
| requests | 2.31.0 | HTTP 请求(健康检查) |
|
|
||||||
| SQLite | 内置 | 数据存储 |
|
|
||||||
|
|
||||||
## 📂 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
ToNav/
|
|
||||||
├── app.py # 主应用入口
|
|
||||||
├── config.py # 配置文件
|
|
||||||
├── tonav.db # SQLite 数据库
|
|
||||||
├── requirements.txt # Python 依赖
|
|
||||||
├── templates/ # HTML 模板
|
|
||||||
│ ├── base.html # 基础模板
|
|
||||||
│ ├── index.html # 前台导航页
|
|
||||||
│ └── admin/ # 管理后台
|
|
||||||
│ ├── login.html # 登录页
|
|
||||||
│ ├── dashboard.html # 仪表盘
|
|
||||||
│ ├── services.html # 服务管理
|
|
||||||
│ └── categories.html # 分类管理
|
|
||||||
├── static/ # 静态资源
|
|
||||||
└── utils/ # 工具模块
|
|
||||||
├── auth.py # 认证模块
|
|
||||||
├── database.py # 数据库操作
|
|
||||||
└── health_check.py # 健康检查
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🗄️ 数据库结构
|
|
||||||
|
|
||||||
### services 表
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| id | INTEGER | 自增主键 |
|
|
||||||
| name | VARCHAR(100) | 服务名称 |
|
|
||||||
| url | VARCHAR(500) | 服务地址 |
|
|
||||||
| description | TEXT | 服务描述 |
|
|
||||||
| icon | VARCHAR(50) | 图标 |
|
|
||||||
| category | VARCHAR(50) | 所属分类 |
|
|
||||||
| is_enabled | INTEGER | 是否启用 (1/0) |
|
|
||||||
| sort_order | INTEGER | 排序 |
|
|
||||||
| health_check_url | VARCHAR(500) | 健康检查地址 |
|
|
||||||
| health_check_enabled | INTEGER | 是否启用健康检查 |
|
|
||||||
| created_at | TIMESTAMP | 创建时间 |
|
|
||||||
| updated_at | TIMESTAMP | 更新时间 |
|
|
||||||
|
|
||||||
### categories 表
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| id | INTEGER | 自增主键 |
|
|
||||||
| name | VARCHAR(50) | 分类名称 |
|
|
||||||
| sort_order | INTEGER | 排序 |
|
|
||||||
|
|
||||||
### users 表
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| id | INTEGER | 自增主键 |
|
|
||||||
| username | VARCHAR(50) | 用户名 |
|
|
||||||
| password_hash | VARCHAR(255) | 密码哈希 |
|
|
||||||
| created_at | TIMESTAMP | 创建时间 |
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
|
||||||
|
|
||||||
### 1. 安装依赖
|
|
||||||
```bash
|
```bash
|
||||||
cd ToNav
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 启动服务
|
### 初始化数据库
|
||||||
```bash
|
```bash
|
||||||
python3 app.py
|
python3 utils/database.py
|
||||||
|
```
|
||||||
|
*默认账号: `admin` / 密码: `admin123`*
|
||||||
|
|
||||||
|
### 启动服务
|
||||||
|
你可以直接使用控制脚本进行管理:
|
||||||
|
```bash
|
||||||
|
chmod +x tonav-ctl.sh
|
||||||
|
./tonav-ctl.sh start
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 访问应用
|
## ⚙️ 服务管理命令 (tonav-ctl.sh)
|
||||||
- 前台导航页: http://127.0.0.1:9519
|
- `start`: 启动服务
|
||||||
- 管理后台: http://127.0.0.1:9519/admin
|
- `stop`: 停止服务
|
||||||
- 默认账号: `admin`
|
- `restart`: 重启服务
|
||||||
- 默认密码: `tonav123`
|
- `status`: 查看运行状态
|
||||||
|
- `log`: 查看最后50行日志
|
||||||
|
- `logtail`: 实时查看日志
|
||||||
|
- `enable`: 设置开机自启
|
||||||
|
|
||||||
## ⚙️ 配置说明
|
## 📁 目录结构
|
||||||
|
```text
|
||||||
编辑 `config.py` 可调整以下配置:
|
ToNav/
|
||||||
|
├── app.py # Flask 主应用
|
||||||
```python
|
├── config.py # 系统配置文件
|
||||||
# 服务监听地址和端口
|
├── tonav.db # SQLite 数据库
|
||||||
HOST = '127.0.0.1'
|
├── tonav-ctl.sh # 服务管理脚本
|
||||||
PORT = 9519
|
├── templates/ # HTML 模板
|
||||||
DEBUG = False
|
│ ├── index.html # 前台展示页
|
||||||
|
│ └── admin/ # 后台管理页面
|
||||||
# 健康检查配置
|
├── static/ # 静态资源 (CSS/JS)
|
||||||
HEALTH_CHECK_INTERVAL = 60 # 检测间隔(秒)
|
└── utils/ # 数据库及认证工具类
|
||||||
HEALTH_CHECK_TIMEOUT = 5 # 检测超时(秒)
|
|
||||||
|
|
||||||
# Flask 密钥
|
|
||||||
SECRET_KEY = 'tonav-secret-key-change-in-production-2026'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📡 API 接口
|
|
||||||
|
|
||||||
### 前台 API
|
|
||||||
- `GET /api/services` - 获取所有启用的服务
|
|
||||||
- `GET /api/categories` - 获取所有分类
|
|
||||||
|
|
||||||
### 后台 API
|
|
||||||
- `POST /api/admin/login` - 登录
|
|
||||||
- `GET /api/admin/login/status` - 检查登录状态
|
|
||||||
- `GET /api/admin/services` - 获取所有服务
|
|
||||||
- `POST /api/admin/services` - 创建服务
|
|
||||||
- `PUT /api/admin/services/<id>` - 更新服务
|
|
||||||
- `DELETE /api/admin/services/<id>` - 删除服务
|
|
||||||
- `POST /api/admin/services/<id>/toggle` - 切换服务状态
|
|
||||||
- `GET /api/admin/categories` - 获取所有分类
|
|
||||||
- `POST /api/admin/categories` - 创建分类
|
|
||||||
- `PUT /api/admin/categories/<id>` - 更新分类
|
|
||||||
- `DELETE /api/admin/categories/<id>` - 删除分类
|
|
||||||
- `POST /api/admin/health-check` - 手动触发健康检查
|
|
||||||
- `POST /api/admin/change-password` - 修改密码
|
|
||||||
|
|
||||||
## 📊 当前数据
|
|
||||||
|
|
||||||
### 已配置服务 (3 个)
|
|
||||||
1. **违禁品查获排行榜** - http://127.0.0.1:9517
|
|
||||||
- 描述: 实时数据统计 · 自动刷新
|
|
||||||
- 图标: 📊
|
|
||||||
- 健康检查: ✅ 启用
|
|
||||||
|
|
||||||
2. **短信接收端-Python** - http://127.0.0.1:9518
|
|
||||||
- 描述: HTTP接口 + Web管理
|
|
||||||
- 图标: 📱
|
|
||||||
|
|
||||||
3. **短信接收端-Go** - http://127.0.0.1:28001
|
|
||||||
- 描述: 高性能版本 · 端口28001
|
|
||||||
- 图标: 🔧
|
|
||||||
|
|
||||||
### 分类配置 (3 个)
|
|
||||||
- 内网服务
|
|
||||||
- 开发工具
|
|
||||||
- 测试环境
|
|
||||||
|
|
||||||
## 🎯 使用场景
|
|
||||||
|
|
||||||
- 个人实验室/内网环境服务导航
|
|
||||||
- 服务状态监控面板
|
|
||||||
- 团队内部服务门户
|
|
||||||
- 自建服务启动页
|
|
||||||
|
|
||||||
## 📝 注意事项
|
|
||||||
|
|
||||||
1. **生产环境部署**
|
|
||||||
- 修改 `SECRET_KEY` 为随机字符串
|
|
||||||
- 修改默认管理员密码
|
|
||||||
- 使用反向代理(如 Nginx)
|
|
||||||
- 启用 HTTPS
|
|
||||||
|
|
||||||
2. **健康检查**
|
|
||||||
- 默认仅检查 HTTP 状态码 < 500
|
|
||||||
- 超时服务会被标记为离线
|
|
||||||
- 检查间隔建议不要小于 30 秒
|
|
||||||
|
|
||||||
3. **安全建议**
|
|
||||||
- 限制 ADMIN 接口访问
|
|
||||||
- 定期备份数据库
|
|
||||||
- 使用强密码
|
|
||||||
|
|
||||||
## 📄 许可证
|
|
||||||
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
Developed for personal use. Powered by OpenClaw.
|
||||||
**版本**: 1.0.0
|
|
||||||
**更新时间**: 2026-02-12
|
|
||||||
|
|||||||
Binary file not shown.
45
app.py
45
app.py
@@ -43,12 +43,15 @@ def api_services():
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 查询时动态获取分类名
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT id, name, url, description, icon, category, sort_order,
|
SELECT s.id, s.name, s.url, s.description, s.icon,
|
||||||
health_check_enabled
|
COALESCE(c.name, s.category) as category,
|
||||||
FROM services
|
s.sort_order, s.health_check_enabled
|
||||||
WHERE is_enabled = 1
|
FROM services s
|
||||||
ORDER BY sort_order DESC, id ASC
|
LEFT JOIN categories c ON s.category = c.name
|
||||||
|
WHERE s.is_enabled = 1
|
||||||
|
ORDER BY s.sort_order DESC, s.id ASC
|
||||||
''')
|
''')
|
||||||
|
|
||||||
services = [dict(row) for row in cursor.fetchall()]
|
services = [dict(row) for row in cursor.fetchall()]
|
||||||
@@ -140,11 +143,14 @@ def api_admin_services():
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 查询时动态获取分类名(如果分类不存在则显示原始值)
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT id, name, url, description, icon, category, is_enabled,
|
SELECT s.id, s.name, s.url, s.description, s.icon,
|
||||||
sort_order, health_check_url, health_check_enabled
|
COALESCE(c.name, s.category) as category,
|
||||||
FROM services
|
s.is_enabled, s.sort_order, s.health_check_url, s.health_check_enabled
|
||||||
ORDER BY sort_order DESC, id ASC
|
FROM services s
|
||||||
|
LEFT JOIN categories c ON s.category = c.name
|
||||||
|
ORDER BY s.sort_order DESC, s.id ASC
|
||||||
''')
|
''')
|
||||||
|
|
||||||
services = [dict(row) for row in cursor.fetchall()]
|
services = [dict(row) for row in cursor.fetchall()]
|
||||||
@@ -327,16 +333,35 @@ def api_admin_update_category(category_id):
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 先获取旧的分类名
|
||||||
|
cursor.execute('SELECT name FROM categories WHERE id = ?', (category_id,))
|
||||||
|
old_row = cursor.fetchone()
|
||||||
|
if not old_row:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': '分类不存在'}), 404
|
||||||
|
|
||||||
|
old_name = old_row[0]
|
||||||
|
new_name = data.get('name', '')
|
||||||
|
|
||||||
|
# 更新分类表
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
UPDATE categories
|
UPDATE categories
|
||||||
SET name = ?, sort_order = ?
|
SET name = ?, sort_order = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
''', (
|
''', (
|
||||||
data.get('name', ''),
|
new_name,
|
||||||
data.get('sort_order', 0),
|
data.get('sort_order', 0),
|
||||||
category_id
|
category_id
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# 同步更新 services 表中该分类的服务
|
||||||
|
if old_name != new_name:
|
||||||
|
cursor.execute('''
|
||||||
|
UPDATE services
|
||||||
|
SET category = ?
|
||||||
|
WHERE category = ?
|
||||||
|
''', (new_name, old_name))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class Config:
|
|||||||
|
|
||||||
# 健康检查配置
|
# 健康检查配置
|
||||||
HEALTH_CHECK_INTERVAL = 60 # 检测间隔(秒)
|
HEALTH_CHECK_INTERVAL = 60 # 检测间隔(秒)
|
||||||
HEALTH_CHECK_TIMEOUT = 5 # 检测超时(秒)
|
HEALTH_CHECK_TIMEOUT = 15 # 检测超时(秒)
|
||||||
|
|
||||||
# 分页配置
|
# 分页配置
|
||||||
ITEMS_PER_PAGE = 20
|
ITEMS_PER_PAGE = 20
|
||||||
|
|||||||
@@ -10,8 +10,11 @@
|
|||||||
<h1>📂 分类管理</h1>
|
<h1>📂 分类管理</h1>
|
||||||
<a href="/admin" class="back-link">← 返回首页</a>
|
<a href="/admin" class="back-link">← 返回首页</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<button class="btn btn-outline" onclick="window.open('/', '_blank')" style="margin-right: 10px;">查看前台 ↗</button>
|
||||||
<button class="btn btn-primary" onclick="showCreateModal()">+ 新建分类</button>
|
<button class="btn btn-primary" onclick="showCreateModal()">+ 新建分类</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 分类列表 -->
|
<!-- 分类列表 -->
|
||||||
<div class="categories-list">
|
<div class="categories-list">
|
||||||
@@ -272,6 +275,17 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255,255,255,0.3);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -10,8 +10,11 @@
|
|||||||
<h1>🧭 ToNav 管理后台</h1>
|
<h1>🧭 ToNav 管理后台</h1>
|
||||||
<span class="username" id="username">加载中...</span>
|
<span class="username" id="username">加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-outline" onclick="window.open('/', '_blank')">查看前台 ↗</button>
|
||||||
<button class="btn btn-primary" onclick="location.href='/admin/logout'">退出登录</button>
|
<button class="btn btn-primary" onclick="location.href='/admin/logout'">退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
<!-- 主内容区 -->
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
@@ -230,6 +233,23 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255,255,255,0.3);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -10,8 +10,11 @@
|
|||||||
<h1>📡 服务管理</h1>
|
<h1>📡 服务管理</h1>
|
||||||
<a href="/admin" class="back-link">← 返回首页</a>
|
<a href="/admin" class="back-link">← 返回首页</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<button class="btn btn-outline" onclick="window.open('/', '_blank')" style="margin-right: 10px;">查看前台 ↗</button>
|
||||||
<button class="btn btn-primary" onclick="showCreateModal()">+ 新建服务</button>
|
<button class="btn btn-primary" onclick="showCreateModal()">+ 新建服务</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 服务列表 -->
|
<!-- 服务列表 -->
|
||||||
<div class="services-list">
|
<div class="services-list">
|
||||||
@@ -193,6 +196,10 @@
|
|||||||
.service-url {
|
.service-url {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #8c8c8c;
|
color: #8c8c8c;
|
||||||
|
word-break: break-all;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-meta {
|
.service-meta {
|
||||||
@@ -386,6 +393,17 @@
|
|||||||
border-top: 1px solid #f0f0f0;
|
border-top: 1px solid #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255,255,255,0.3);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -450,7 +468,8 @@
|
|||||||
// 加载服务列表
|
// 加载服务列表
|
||||||
async function loadServices() {
|
async function loadServices() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/services');
|
// 添加时间戳防止缓存
|
||||||
|
const response = await fetch(`/api/admin/services?t=${new Date().getTime()}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
window.location.href = '/admin/login';
|
window.location.href = '/admin/login';
|
||||||
return;
|
return;
|
||||||
@@ -520,22 +539,55 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载分类列表到下拉框
|
||||||
|
async function loadCategoriesToSelect() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/categories');
|
||||||
|
const categories = await response.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('serviceCategory');
|
||||||
|
const currentValue = select.value;
|
||||||
|
select.innerHTML = '';
|
||||||
|
|
||||||
|
categories.forEach(cat => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = cat.name;
|
||||||
|
option.textContent = cat.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 尝试恢复之前选中的值
|
||||||
|
if (currentValue) {
|
||||||
|
const exists = categories.find(c => c.name === currentValue);
|
||||||
|
if (exists) {
|
||||||
|
select.value = currentValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载分类失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 显示创建弹窗
|
// 显示创建弹窗
|
||||||
function showCreateModal() {
|
async function showCreateModal() {
|
||||||
document.getElementById('modalTitle').textContent = '新建服务';
|
document.getElementById('modalTitle').textContent = '新建服务';
|
||||||
document.getElementById('serviceId').value = '';
|
document.getElementById('serviceId').value = '';
|
||||||
document.getElementById('serviceForm').reset();
|
document.getElementById('serviceForm').reset();
|
||||||
document.getElementById('serviceEnabled').checked = true;
|
document.getElementById('serviceEnabled').checked = true;
|
||||||
document.getElementById('serviceSort').value = '0';
|
document.getElementById('serviceSort').value = '0';
|
||||||
document.getElementById('healthUrlGroup').style.display = 'none';
|
document.getElementById('healthUrlGroup').style.display = 'none';
|
||||||
|
|
||||||
|
await loadCategoriesToSelect();
|
||||||
document.getElementById('serviceModal').classList.add('active');
|
document.getElementById('serviceModal').classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑服务
|
// 编辑服务
|
||||||
function editService(id) {
|
async function editService(id) {
|
||||||
const service = allServices.find(s => s.id === id);
|
const service = allServices.find(s => s.id === id);
|
||||||
if (!service) return;
|
if (!service) return;
|
||||||
|
|
||||||
|
await loadCategoriesToSelect();
|
||||||
|
|
||||||
document.getElementById('modalTitle').textContent = '编辑服务';
|
document.getElementById('modalTitle').textContent = '编辑服务';
|
||||||
document.getElementById('serviceId').value = service.id;
|
document.getElementById('serviceId').value = service.id;
|
||||||
document.getElementById('serviceName').value = service.name;
|
document.getElementById('serviceName').value = service.name;
|
||||||
|
|||||||
@@ -15,10 +15,7 @@
|
|||||||
|
|
||||||
<!-- 分类 Tabs -->
|
<!-- 分类 Tabs -->
|
||||||
<div class="tabs" id="categoryTabs">
|
<div class="tabs" id="categoryTabs">
|
||||||
<button class="tab-btn active" data-category="all">全部</button>
|
<div class="loading" style="color: #8c8c8c; font-size: 12px; padding: 10px;">加载分类...</div>
|
||||||
<button class="tab-btn" data-category="内网服务">内网服务</button>
|
|
||||||
<button class="tab-btn" data-category="开发工具">开发工具</button>
|
|
||||||
<button class="tab-btn" data-category="测试环境">测试环境</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 服务卡片网格 -->
|
<!-- 服务卡片网格 -->
|
||||||
@@ -137,20 +134,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-status {
|
.card-status {
|
||||||
width: 12px;
|
width: 14px;
|
||||||
height: 12px;
|
height: 14px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #d9d9d9;
|
background: #d9d9d9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-status.online {
|
.card-status.online {
|
||||||
background: #52c41a;
|
background: #52c41a;
|
||||||
box-shadow: 0 0 8px rgba(82, 196, 26, 0.5);
|
color: #fff;
|
||||||
|
box-shadow: 0 0 10px rgba(82, 196, 26, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-status.offline {
|
.card-status.offline {
|
||||||
background: #ff4d4f;
|
background: #ff4d4f;
|
||||||
box-shadow: 0 0 8px rgba(255, 77, 79, 0.5);
|
color: #fff;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 77, 79, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-name {
|
.card-name {
|
||||||
@@ -231,7 +235,7 @@
|
|||||||
// 加载分类
|
// 加载分类
|
||||||
async function loadCategories() {
|
async function loadCategories() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/categories');
|
const response = await fetch(`/api/categories?t=${new Date().getTime()}`);
|
||||||
allCategories = await response.json();
|
allCategories = await response.json();
|
||||||
renderTabs();
|
renderTabs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -242,10 +246,11 @@
|
|||||||
// 加载服务
|
// 加载服务
|
||||||
async function loadServices() {
|
async function loadServices() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/services');
|
const response = await fetch(`/api/services?t=${new Date().getTime()}`);
|
||||||
allServices = await response.json();
|
allServices = await response.json();
|
||||||
renderServices(window.currentTab || 'all');
|
renderServices(window.currentTab || 'all');
|
||||||
updateLastCheckTime();
|
updateLastCheckTime();
|
||||||
|
loadHealthStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('加载服务失败:', err);
|
console.error('加载服务失败:', err);
|
||||||
document.getElementById('servicesGrid').innerHTML =
|
document.getElementById('servicesGrid').innerHTML =
|
||||||
@@ -253,6 +258,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载健康状态
|
||||||
|
async function loadHealthStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/health-check', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.results) {
|
||||||
|
data.results.forEach(result => {
|
||||||
|
healthStatus[result.id] = result.status;
|
||||||
|
});
|
||||||
|
// 重新渲染以显示状态
|
||||||
|
renderServices(window.currentTab || 'all');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('健康检测失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 渲染分类 Tabs
|
// 渲染分类 Tabs
|
||||||
function renderTabs() {
|
function renderTabs() {
|
||||||
const tabsContainer = document.getElementById('categoryTabs');
|
const tabsContainer = document.getElementById('categoryTabs');
|
||||||
@@ -297,13 +322,22 @@
|
|||||||
let html = '';
|
let html = '';
|
||||||
filteredServices.forEach((service, index) => {
|
filteredServices.forEach((service, index) => {
|
||||||
const status = healthStatus[service.id] || 'unknown';
|
const status = healthStatus[service.id] || 'unknown';
|
||||||
const statusClass = status === 'online' ? 'online' : (status === 'offline' ? 'offline' : '');
|
let statusClass = '';
|
||||||
|
let statusIcon = '';
|
||||||
|
|
||||||
|
if (status === 'online') {
|
||||||
|
statusClass = 'online';
|
||||||
|
statusIcon = '✓';
|
||||||
|
} else if (status === 'offline') {
|
||||||
|
statusClass = 'offline';
|
||||||
|
statusIcon = '✗';
|
||||||
|
}
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<a href="${service.url}" target="_blank" class="service-card" style="animation-delay: ${index * 0.05}s">
|
<a href="${service.url}" target="_blank" class="service-card" style="animation-delay: ${index * 0.05}s">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-icon">${service.icon || '📡'}</span>
|
<span class="card-icon">${service.icon || '📡'}</span>
|
||||||
<span class="card-status ${statusClass}"></span>
|
<span class="card-status ${statusClass}">${statusIcon}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-name">${service.name}</div>
|
<div class="card-name">${service.name}</div>
|
||||||
<div class="card-desc">${service.description || ''}</div>
|
<div class="card-desc">${service.description || ''}</div>
|
||||||
@@ -331,5 +365,12 @@
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
loadServices();
|
loadServices();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
|
// 页面显示时刷新
|
||||||
|
document.addEventListener('visibilitychange', function() {
|
||||||
|
if (!document.hidden) {
|
||||||
|
loadServices();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user