- 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
462 lines
12 KiB
Python
462 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""ToNav - 个人导航页系统"""
|
|
|
|
from flask import Flask, render_template, request, jsonify, session, redirect, url_for
|
|
import sqlite3
|
|
import json
|
|
import os
|
|
from config import Config
|
|
from utils.auth import authenticate, is_logged_in, hash_password
|
|
from utils.health_check import health_worker, check_all_services
|
|
from utils.database import init_database, insert_initial_data
|
|
|
|
# 创建 Flask 应用
|
|
app = Flask(__name__)
|
|
app.config.from_object(Config)
|
|
|
|
# 初始化数据库
|
|
if not os.path.exists(Config.DATABASE_PATH):
|
|
init_database()
|
|
insert_initial_data()
|
|
|
|
# 启动健康检查线程
|
|
health_worker.start()
|
|
|
|
# ==================== 数据库辅助函数 ====================
|
|
|
|
def get_db():
|
|
"""获取数据库连接"""
|
|
conn = sqlite3.connect(Config.DATABASE_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
# ==================== 前台导航页 ====================
|
|
|
|
@app.route('/')
|
|
def index():
|
|
"""前台导航页"""
|
|
return render_template('index.html')
|
|
|
|
@app.route('/api/services')
|
|
def api_services():
|
|
"""获取所有启用的服务"""
|
|
conn = get_db()
|
|
cursor = conn.cursor()
|
|
|
|
# 查询时动态获取分类名
|
|
cursor.execute('''
|
|
SELECT s.id, s.name, s.url, s.description, s.icon,
|
|
COALESCE(c.name, s.category) as category,
|
|
s.sort_order, s.health_check_enabled
|
|
FROM services s
|
|
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()]
|
|
conn.close()
|
|
|
|
return jsonify(services)
|
|
|
|
@app.route('/api/categories')
|
|
def api_categories():
|
|
"""获取所有分类"""
|
|
conn = get_db()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute('''
|
|
SELECT name, sort_order
|
|
FROM categories
|
|
ORDER BY sort_order DESC, id ASC
|
|
''')
|
|
|
|
categories = [dict(row) for row in cursor.fetchall()]
|
|
conn.close()
|
|
|
|
return jsonify(categories)
|
|
|
|
# ==================== 管理后台 ====================
|
|
|
|
@app.route('/admin')
|
|
def admin_dashboard():
|
|
"""管理后台首页"""
|
|
if not is_logged_in(session):
|
|
return redirect(url_for('admin_login'))
|
|
|
|
return render_template('admin/dashboard.html')
|
|
|
|
@app.route('/admin/login', methods=['GET', 'POST'])
|
|
def admin_login():
|
|
"""登录页"""
|
|
if request.method == 'GET':
|
|
return render_template('admin/login.html')
|
|
|
|
username = request.form.get('username', '')
|
|
password = request.form.get('password', '')
|
|
|
|
user = authenticate(username, password)
|
|
if user:
|
|
session['user_id'] = user['id']
|
|
session['username'] = user['username']
|
|
return redirect(url_for('admin_dashboard'))
|
|
|
|
return render_template('admin/login.html', error='用户名或密码错误')
|
|
|
|
@app.route('/admin/logout')
|
|
def admin_logout():
|
|
"""退出登录"""
|
|
session.clear()
|
|
return redirect(url_for('admin_login'))
|
|
|
|
@app.route('/admin/services')
|
|
def admin_services():
|
|
"""服务管理页"""
|
|
if not is_logged_in(session):
|
|
return redirect(url_for('admin_login'))
|
|
|
|
return render_template('admin/services.html')
|
|
|
|
@app.route('/admin/categories')
|
|
def admin_categories():
|
|
"""分类管理页"""
|
|
if not is_logged_in(session):
|
|
return redirect(url_for('admin_login'))
|
|
|
|
return render_template('admin/categories.html')
|
|
|
|
# ==================== 后台 API ====================
|
|
|
|
@app.route('/api/admin/login/status')
|
|
def api_login_status():
|
|
"""检查登录状态"""
|
|
if is_logged_in(session):
|
|
return jsonify({'logged_in': True, 'username': session.get('username')})
|
|
return jsonify({'logged_in': False})
|
|
|
|
@app.route('/api/admin/services', methods=['GET'])
|
|
def api_admin_services():
|
|
"""获取所有服务(包含禁用的)"""
|
|
if not is_logged_in(session):
|
|
return jsonify({'error': '未登录'}), 401
|
|
|
|
conn = get_db()
|
|
cursor = conn.cursor()
|
|
|
|
# 查询时动态获取分类名(如果分类不存在则显示原始值)
|
|
cursor.execute('''
|
|
SELECT s.id, s.name, s.url, s.description, s.icon,
|
|
COALESCE(c.name, s.category) as category,
|
|
s.is_enabled, s.sort_order, s.health_check_url, s.health_check_enabled
|
|
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()]
|
|
conn.close()
|
|
|
|
return jsonify(services)
|
|
|
|
@app.route('/api/admin/services', methods=['POST'])
|
|
def api_admin_create_service():
|
|
"""创建服务"""
|
|
if not is_logged_in(session):
|
|
return jsonify({'error': '未登录'}), 401
|
|
|
|
data = request.get_json()
|
|
required_fields = ['name', 'url']
|
|
for field in required_fields:
|
|
if not data.get(field):
|
|
return jsonify({'error': f'缺少字段: {field}'}), 400
|
|
|
|
conn = get_db()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute('''
|
|
INSERT INTO services (name, url, description, icon, category,
|
|
is_enabled, sort_order, health_check_url,
|
|
health_check_enabled)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
''', (
|
|
data['name'],
|
|
data['url'],
|
|
data.get('description', ''),
|
|
data.get('icon', ''),
|
|
data.get('category', '默认'),
|
|
1 if data.get('is_enabled', True) else 0,
|
|
data.get('sort_order', 0),
|
|
data.get('health_check_url', ''),
|
|
1 if data.get('health_check_enabled', False) else 0
|
|
))
|
|
|
|
conn.commit()
|
|
service_id = cursor.lastrowid
|
|
conn.close()
|
|
|
|
return jsonify({'id': service_id, 'message': '创建成功'})
|
|
|
|
@app.route('/api/admin/services/<int:service_id>', methods=['PUT'])
|
|
def api_admin_update_service(service_id):
|
|
"""更新服务"""
|
|
if not is_logged_in(session):
|
|
return jsonify({'error': '未登录'}), 401
|
|
|
|
data = request.get_json()
|
|
|
|
conn = get_db()
|
|
cursor = conn.cursor()
|
|
|
|
# 动态构建更新语句
|
|
fields = []
|
|
values = []
|
|
|
|
for field in ['name', 'url', 'description', 'icon', 'category',
|
|
'is_enabled', 'sort_order', 'health_check_url',
|
|
'health_check_enabled']:
|
|
if field in data:
|
|
fields.append(f"{field} = ?")
|
|
values.append(data[field])
|
|
|
|
if not fields:
|
|
return jsonify({'error': '没有要更新的字段'}), 400
|
|
|
|
values.append(service_id)
|
|
|
|
cursor.execute(f'''
|
|
UPDATE services
|
|
SET {', '.join(fields)}, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
''', values)
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return jsonify({'message': '更新成功'})
|
|
|
|
@app.route('/api/admin/services/<int:service_id>', methods=['DELETE'])
|
|
def api_admin_delete_service(service_id):
|
|
"""删除服务"""
|
|
if not is_logged_in(session):
|
|
return jsonify({'error': '未登录'}), 401
|
|
|
|
conn = get_db()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute('DELETE FROM services WHERE id = ?', (service_id,))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return jsonify({'message': '删除成功'})
|
|
|
|
@app.route('/api/admin/services/<int:service_id>/toggle', methods=['POST'])
|
|
def api_admin_toggle_service(service_id):
|
|
"""切换服务启用状态"""
|
|
if not is_logged_in(session):
|
|
return jsonify({'error': '未登录'}), 401
|
|
|
|
conn = get_db()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute('''
|
|
UPDATE services
|
|
SET is_enabled = 1 - is_enabled
|
|
WHERE id = ?
|
|
''', (service_id,))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return jsonify({'message': '状态切换成功'})
|
|
|
|
# ==================== 分类管理 API ====================
|
|
|
|
@app.route('/api/admin/categories', methods=['GET'])
|
|
def api_admin_categories():
|
|
"""获取所有分类"""
|
|
if not is_logged_in(session):
|
|
return jsonify({'error': '未登录'}), 401
|
|
|
|
conn = get_db()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute('''
|
|
SELECT id, name, sort_order
|
|
FROM categories
|
|
ORDER BY sort_order DESC, id ASC
|
|
''')
|
|
|
|
categories = [dict(row) for row in cursor.fetchall()]
|
|
conn.close()
|
|
|
|
return jsonify(categories)
|
|
|
|
@app.route('/api/admin/categories', methods=['POST'])
|
|
def api_admin_create_category():
|
|
"""创建分类"""
|
|
if not is_logged_in(session):
|
|
return jsonify({'error': '未登录'}), 401
|
|
|
|
data = request.get_json()
|
|
name = data.get('name', '').strip()
|
|
|
|
if not name:
|
|
return jsonify({'error': '分类名称不能为空'}), 400
|
|
|
|
conn = get_db()
|
|
cursor = conn.cursor()
|
|
|
|
try:
|
|
cursor.execute('''
|
|
INSERT INTO categories (name, sort_order)
|
|
VALUES (?, ?)
|
|
''', (name, data.get('sort_order', 0)))
|
|
|
|
conn.commit()
|
|
category_id = cursor.lastrowid
|
|
conn.close()
|
|
|
|
return jsonify({'id': category_id, 'message': '创建成功'})
|
|
except sqlite3.IntegrityError:
|
|
conn.close()
|
|
return jsonify({'error': '分类名称已存在'}), 400
|
|
|
|
@app.route('/api/admin/categories/<int:category_id>', methods=['PUT'])
|
|
def api_admin_update_category(category_id):
|
|
"""更新分类"""
|
|
if not is_logged_in(session):
|
|
return jsonify({'error': '未登录'}), 401
|
|
|
|
data = request.get_json()
|
|
|
|
conn = get_db()
|
|
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('''
|
|
UPDATE categories
|
|
SET name = ?, sort_order = ?
|
|
WHERE id = ?
|
|
''', (
|
|
new_name,
|
|
data.get('sort_order', 0),
|
|
category_id
|
|
))
|
|
|
|
# 同步更新 services 表中该分类的服务
|
|
if old_name != new_name:
|
|
cursor.execute('''
|
|
UPDATE services
|
|
SET category = ?
|
|
WHERE category = ?
|
|
''', (new_name, old_name))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return jsonify({'message': '更新成功'})
|
|
|
|
@app.route('/api/admin/categories/<int:category_id>', methods=['DELETE'])
|
|
def api_admin_delete_category(category_id):
|
|
"""删除分类"""
|
|
if not is_logged_in(session):
|
|
return jsonify({'error': '未登录'}), 401
|
|
|
|
# 检查是否有服务使用该分类
|
|
conn = get_db()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM services
|
|
WHERE category = (SELECT name FROM categories WHERE id = ?)
|
|
''', (category_id,))
|
|
|
|
count = cursor.fetchone()[0]
|
|
if count > 0:
|
|
conn.close()
|
|
return jsonify({'error': f'该分类下有 {count} 个服务,无法删除'}), 400
|
|
|
|
cursor.execute('DELETE FROM categories WHERE id = ?', (category_id,))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return jsonify({'message': '删除成功'})
|
|
|
|
# ==================== 健康检查 API ====================
|
|
|
|
@app.route('/api/admin/health-check', methods=['POST'])
|
|
def api_admin_health_check():
|
|
"""手动触发全量健康检查"""
|
|
if not is_logged_in(session):
|
|
return jsonify({'error': '未登录'}), 401
|
|
|
|
results = check_all_services()
|
|
return jsonify({'results': results})
|
|
|
|
# ==================== 系统设置 API ====================
|
|
|
|
@app.route('/api/admin/change-password', methods=['POST'])
|
|
def api_admin_change_password():
|
|
"""修改密码"""
|
|
if not is_logged_in(session):
|
|
return jsonify({'error': '未登录'}), 401
|
|
|
|
data = request.get_json()
|
|
old_password = data.get('old_password', '')
|
|
new_password = data.get('new_password', '')
|
|
|
|
if not old_password or not new_password:
|
|
return jsonify({'error': '密码不能为空'}), 400
|
|
|
|
if len(new_password) < 6:
|
|
return jsonify({'error': '新密码长度至少6位'}), 400
|
|
|
|
conn = get_db()
|
|
cursor = conn.cursor()
|
|
|
|
user_id = session['user_id']
|
|
|
|
cursor.execute('''
|
|
SELECT password_hash FROM users WHERE id = ?
|
|
''', (user_id,))
|
|
|
|
row = cursor.fetchone()
|
|
if not row or not is_logged_in(session):
|
|
conn.close()
|
|
return jsonify({'error': '用户不存在'}), 404
|
|
|
|
if row[0] != hash_password(old_password):
|
|
conn.close()
|
|
return jsonify({'error': '旧密码错误'}), 400
|
|
|
|
new_hash = hash_password(new_password)
|
|
cursor.execute('''
|
|
UPDATE users SET password_hash = ? WHERE id = ?
|
|
''', (new_hash, user_id))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return jsonify({'message': '密码修改成功,请重新登录'})
|
|
|
|
# ==================== 主入口 ====================
|
|
|
|
if __name__ == '__main__':
|
|
app.run(
|
|
host=Config.HOST,
|
|
port=Config.PORT,
|
|
debug=Config.DEBUG
|
|
)
|