同步更新:重构路由、服务模块,更新前端构建

This commit is contained in:
2025-12-14 21:47:08 +08:00
parent e01a7b5235
commit a346509a5f
87 changed files with 9186 additions and 7826 deletions

21
routes/__init__.py Normal file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
def register_blueprints(app) -> None:
from routes.admin_api import admin_api_bp
from routes.api_accounts import api_accounts_bp
from routes.api_auth import api_auth_bp
from routes.api_schedules import api_schedules_bp
from routes.api_screenshots import api_screenshots_bp
from routes.api_user import api_user_bp
from routes.pages import pages_bp
app.register_blueprint(pages_bp)
app.register_blueprint(api_auth_bp)
app.register_blueprint(api_user_bp)
app.register_blueprint(api_accounts_bp)
app.register_blueprint(api_screenshots_bp)
app.register_blueprint(api_schedules_bp)
app.register_blueprint(admin_api_bp)

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from flask import Blueprint
admin_api_bp = Blueprint("admin_api", __name__, url_prefix="/yuyx/api")
# Import side effects: register routes on blueprint
from routes.admin_api import core as _core # noqa: F401

1142
routes/admin_api/core.py Normal file

File diff suppressed because it is too large Load Diff

410
routes/api_accounts.py Normal file
View File

@@ -0,0 +1,410 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import threading
import database
import db_pool
from app_logger import get_logger
from crypto_utils import encrypt_password as encrypt_account_password
from flask import Blueprint, jsonify, request
from flask_login import current_user, login_required
from services.accounts_service import load_user_accounts
from services.browser_manager import init_browser_manager
from services.browse_types import BROWSE_TYPE_SHOULD_READ, normalize_browse_type, validate_browse_type
from services.client_log import log_to_client
from services.models import Account
from services.runtime import get_socketio
from services.screenshots import take_screenshot_for_account
from services.state import (
safe_get_account,
safe_get_user_accounts_snapshot,
safe_remove_account,
safe_remove_task,
safe_remove_task_status,
safe_set_account,
safe_set_user_accounts,
)
from services.tasks import get_task_scheduler, submit_account_task
logger = get_logger("app")
api_accounts_bp = Blueprint("api_accounts", __name__)
def _emit(event: str, data: object, *, room: str | None = None) -> None:
try:
socketio = get_socketio()
socketio.emit(event, data, room=room)
except Exception:
pass
@api_accounts_bp.route("/api/accounts", methods=["GET"])
@login_required
def get_accounts():
"""获取当前用户的所有账号"""
user_id = current_user.id
refresh = request.args.get("refresh", "false").lower() == "true"
accounts = safe_get_user_accounts_snapshot(user_id)
if refresh or not accounts:
load_user_accounts(user_id)
accounts = safe_get_user_accounts_snapshot(user_id)
return jsonify([acc.to_dict() for acc in accounts.values()])
@api_accounts_bp.route("/api/accounts", methods=["POST"])
@login_required
def add_account():
"""添加账号"""
user_id = current_user.id
current_count = len(database.get_user_accounts(user_id))
is_vip = database.is_user_vip(user_id)
if not is_vip and current_count >= 3:
return jsonify({"error": "普通用户最多添加3个账号升级VIP可无限添加"}), 403
data = request.json
username = data.get("username", "").strip()
password = data.get("password", "").strip()
remark = data.get("remark", "").strip()[:200]
if not username or not password:
return jsonify({"error": "用户名和密码不能为空"}), 400
accounts = safe_get_user_accounts_snapshot(user_id)
if not accounts:
load_user_accounts(user_id)
accounts = safe_get_user_accounts_snapshot(user_id)
for acc in accounts.values():
if acc.username == username:
return jsonify({"error": f"账号 '{username}' 已存在"}), 400
import uuid
account_id = str(uuid.uuid4())[:8]
remember = data.get("remember", True)
database.create_account(user_id, account_id, username, password, remember, remark)
account = Account(account_id, user_id, username, password, remember, remark)
safe_set_account(user_id, account_id, account)
log_to_client(f"添加账号: {username}", user_id)
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
return jsonify(account.to_dict())
@api_accounts_bp.route("/api/accounts/<account_id>", methods=["PUT"])
@login_required
def update_account(account_id):
"""更新账号信息(密码等)"""
user_id = current_user.id
account = safe_get_account(user_id, account_id)
if not account:
return jsonify({"error": "账号不存在"}), 404
if account.is_running:
return jsonify({"error": "账号正在运行中,请先停止"}), 400
data = request.json
new_password = data.get("password", "").strip()
new_remember = data.get("remember", account.remember)
if not new_password:
return jsonify({"error": "密码不能为空"}), 400
encrypted_password = encrypt_account_password(new_password)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
UPDATE accounts
SET password = ?, remember = ?
WHERE id = ?
""",
(encrypted_password, new_remember, account_id),
)
conn.commit()
database.reset_account_login_status(account_id)
logger.info(f"[账号更新] 用户 {user_id} 修改了账号 {account.username} 的密码,已重置登录状态")
account._password = new_password
account.remember = new_remember
log_to_client(f"账号 {account.username} 信息已更新,登录状态已重置", user_id)
return jsonify({"message": "账号更新成功", "account": account.to_dict()})
@api_accounts_bp.route("/api/accounts/<account_id>", methods=["DELETE"])
@login_required
def delete_account(account_id):
"""删除账号"""
user_id = current_user.id
account = safe_get_account(user_id, account_id)
if not account:
return jsonify({"error": "账号不存在"}), 404
if account.is_running:
account.should_stop = True
if account.automation:
account.automation.close()
username = account.username
database.delete_account(account_id)
safe_remove_account(user_id, account_id)
log_to_client(f"删除账号: {username}", user_id)
return jsonify({"success": True})
@api_accounts_bp.route("/api/accounts/clear", methods=["POST"])
@login_required
def clear_accounts():
"""清空当前用户的所有账号"""
user_id = current_user.id
accounts = safe_get_user_accounts_snapshot(user_id)
if any(acc.is_running for acc in accounts.values()):
return jsonify({"error": "有任务正在运行,请先停止后再清空"}), 400
account_ids = list(accounts.keys())
deleted = database.delete_user_accounts(user_id)
safe_set_user_accounts(user_id, {})
for account_id in account_ids:
safe_remove_task_status(account_id)
safe_remove_task(account_id)
log_to_client(f"清空账号: {deleted}", user_id)
return jsonify({"success": True, "deleted": deleted})
@api_accounts_bp.route("/api/accounts/<account_id>/remark", methods=["PUT"])
@login_required
def update_remark(account_id):
"""更新备注"""
user_id = current_user.id
account = safe_get_account(user_id, account_id)
if not account:
return jsonify({"error": "账号不存在"}), 404
data = request.json
remark = data.get("remark", "").strip()[:200]
database.update_account_remark(account_id, remark)
account.remark = remark
log_to_client(f"更新备注: {account.username} -> {remark}", user_id)
return jsonify({"success": True})
@api_accounts_bp.route("/api/accounts/<account_id>/start", methods=["POST"])
@login_required
def start_account(account_id):
"""启动账号任务"""
user_id = current_user.id
account = safe_get_account(user_id, account_id)
if not account:
return jsonify({"error": "账号不存在"}), 404
if account.is_running:
return jsonify({"error": "任务已在运行中"}), 400
data = request.json or {}
browse_type = validate_browse_type(data.get("browse_type"), default=BROWSE_TYPE_SHOULD_READ)
if not browse_type:
return jsonify({"error": "浏览类型无效"}), 400
enable_screenshot = data.get("enable_screenshot", True)
if not init_browser_manager():
return jsonify({"error": "浏览器初始化失败"}), 500
ok, message = submit_account_task(
user_id=user_id,
account_id=account_id,
browse_type=browse_type,
enable_screenshot=enable_screenshot,
source="manual",
)
if not ok:
return jsonify({"error": message}), 400
log_to_client(f"启动任务: {account.username} - {browse_type}", user_id)
return jsonify({"success": True})
@api_accounts_bp.route("/api/accounts/<account_id>/stop", methods=["POST"])
@login_required
def stop_account(account_id):
"""停止账号任务"""
user_id = current_user.id
account = safe_get_account(user_id, account_id)
if not account:
return jsonify({"error": "账号不存在"}), 404
if not account.is_running:
return jsonify({"error": "任务未在运行"}), 400
account.should_stop = True
account.status = "正在停止"
try:
scheduler = get_task_scheduler()
if scheduler.cancel_pending_task(user_id=user_id, account_id=account_id):
account.status = "已停止"
account.is_running = False
safe_remove_task_status(account_id)
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
log_to_client(f"任务已取消: {account.username}", user_id)
return jsonify({"success": True, "canceled": True})
except Exception:
pass
log_to_client(f"停止任务: {account.username}", user_id)
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
return jsonify({"success": True})
@api_accounts_bp.route("/api/accounts/<account_id>/screenshot", methods=["POST"])
@login_required
def manual_screenshot(account_id):
"""手动为指定账号截图"""
user_id = current_user.id
account = safe_get_account(user_id, account_id)
if not account:
load_user_accounts(user_id)
account = safe_get_account(user_id, account_id)
if not account:
return jsonify({"error": "账号不存在"}), 404
if account.is_running:
return jsonify({"error": "任务运行中,无法截图"}), 400
data = request.json or {}
requested_browse_type = data.get("browse_type", None)
if requested_browse_type is None:
browse_type = normalize_browse_type(account.last_browse_type)
else:
browse_type = validate_browse_type(requested_browse_type, default=BROWSE_TYPE_SHOULD_READ)
if not browse_type:
return jsonify({"error": "浏览类型无效"}), 400
account.last_browse_type = browse_type
threading.Thread(
target=take_screenshot_for_account,
args=(user_id, account_id, browse_type, "manual_screenshot"),
daemon=True,
).start()
log_to_client(f"手动截图: {account.username} - {browse_type}", user_id)
return jsonify({"success": True})
@api_accounts_bp.route("/api/accounts/batch/start", methods=["POST"])
@login_required
def batch_start_accounts():
"""批量启动账号"""
user_id = current_user.id
data = request.json or {}
account_ids = data.get("account_ids", [])
browse_type = validate_browse_type(data.get("browse_type", BROWSE_TYPE_SHOULD_READ), default=BROWSE_TYPE_SHOULD_READ)
if not browse_type:
return jsonify({"error": "浏览类型无效"}), 400
enable_screenshot = data.get("enable_screenshot", True)
if not account_ids:
return jsonify({"error": "请选择要启动的账号"}), 400
started = []
failed = []
if not safe_get_user_accounts_snapshot(user_id):
load_user_accounts(user_id)
for account_id in account_ids:
account = safe_get_account(user_id, account_id)
if not account:
failed.append({"id": account_id, "reason": "账号不存在"})
continue
if account.is_running:
failed.append({"id": account_id, "reason": "已在运行中"})
continue
ok, msg = submit_account_task(
user_id=user_id,
account_id=account_id,
browse_type=browse_type,
enable_screenshot=enable_screenshot,
source="batch",
)
if ok:
started.append(account_id)
else:
failed.append({"id": account_id, "reason": msg})
return jsonify(
{"success": True, "started_count": len(started), "failed_count": len(failed), "started": started, "failed": failed}
)
@api_accounts_bp.route("/api/accounts/batch/stop", methods=["POST"])
@login_required
def batch_stop_accounts():
"""批量停止账号"""
user_id = current_user.id
data = request.json
account_ids = data.get("account_ids", [])
if not account_ids:
return jsonify({"error": "请选择要停止的账号"}), 400
stopped = []
if not safe_get_user_accounts_snapshot(user_id):
load_user_accounts(user_id)
for account_id in account_ids:
account = safe_get_account(user_id, account_id)
if not account:
continue
if not account.is_running:
continue
account.should_stop = True
account.status = "正在停止"
stopped.append(account_id)
try:
scheduler = get_task_scheduler()
if scheduler.cancel_pending_task(user_id=user_id, account_id=account_id):
account.status = "已停止"
account.is_running = False
safe_remove_task_status(account_id)
except Exception:
pass
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
return jsonify({"success": True, "stopped_count": len(stopped), "stopped": stopped})

420
routes/api_auth.py Normal file
View File

@@ -0,0 +1,420 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import random
import secrets
import time
import database
import email_service
from app_logger import get_logger
from app_security import get_client_ip, require_ip_not_locked, validate_password
from flask import Blueprint, jsonify, redirect, render_template, request, url_for
from flask_login import login_required, login_user, logout_user
from routes.pages import render_app_spa_or_legacy
from services.accounts_service import load_user_accounts
from services.models import User
from services.state import (
check_ip_rate_limit,
record_failed_captcha,
safe_cleanup_expired_captcha,
safe_delete_captcha,
safe_set_captcha,
safe_verify_and_consume_captcha,
)
logger = get_logger("app")
api_auth_bp = Blueprint("api_auth", __name__)
@api_auth_bp.route("/api/register", methods=["POST"])
@require_ip_not_locked
def register():
"""用户注册"""
data = request.json
username = data.get("username", "").strip()
password = data.get("password", "").strip()
email = data.get("email", "").strip()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
if not username or not password:
return jsonify({"error": "用户名和密码不能为空"}), 400
client_ip = get_client_ip()
allowed, error_msg = check_ip_rate_limit(client_ip)
if not allowed:
return jsonify({"error": error_msg}), 429
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success:
is_locked = record_failed_captcha(client_ip)
if is_locked:
return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
return jsonify({"error": message}), 400
email_settings = email_service.get_email_settings()
email_verify_enabled = email_settings.get("register_verify_enabled", False) and email_settings.get("enabled", False)
if email_verify_enabled and not email:
return jsonify({"error": "启用邮箱验证后,邮箱为必填项"}), 400
if email and "@" not in email:
return jsonify({"error": "邮箱格式不正确"}), 400
system_config = database.get_system_config()
auto_approve_enabled = system_config.get("auto_approve_enabled", 0) == 1
auto_approve_hourly_limit = system_config.get("auto_approve_hourly_limit", 10)
auto_approve_vip_days = system_config.get("auto_approve_vip_days", 7)
if auto_approve_enabled or email_verify_enabled:
hourly_count = database.get_hourly_registration_count()
if hourly_count >= auto_approve_hourly_limit:
return jsonify({"error": f"注册人数过多,请稍后再试(每小时限制{auto_approve_hourly_limit}人)"}), 429
user_id = database.create_user(username, password, email)
if user_id:
if email_verify_enabled and email:
result = email_service.send_register_verification_email(email=email, username=username, user_id=user_id)
if result["success"]:
return jsonify(
{
"success": True,
"message": "注册成功!验证邮件已发送,请查收邮箱并点击链接完成验证",
"need_verify": True,
}
)
logger.error(f"注册验证邮件发送失败: {result['error']}")
return jsonify(
{
"success": True,
"message": f"注册成功,但验证邮件发送失败({result['error']})。请稍后在登录页面重新发送验证邮件",
"need_verify": True,
}
)
if auto_approve_enabled:
database.approve_user(user_id)
if auto_approve_vip_days > 0:
database.set_user_vip(user_id, auto_approve_vip_days)
return jsonify({"success": True, "message": f"注册成功!已自动审核通过,赠送{auto_approve_vip_days}天VIP"})
return jsonify({"success": True, "message": "注册成功!已自动审核通过"})
return jsonify({"success": True, "message": "注册成功,请等待管理员审核"})
return jsonify({"error": "用户名已存在"}), 400
@api_auth_bp.route("/api/verify-email/<token>")
def verify_email(token):
"""验证邮箱 - 用户点击邮件中的链接"""
result = email_service.verify_email_token(token, email_service.EMAIL_TYPE_REGISTER)
if result:
user_id = result["user_id"]
email = result["email"]
database.approve_user(user_id)
system_config = database.get_system_config()
auto_approve_vip_days = system_config.get("auto_approve_vip_days", 7)
if auto_approve_vip_days > 0:
database.set_user_vip(user_id, auto_approve_vip_days)
logger.info(f"用户邮箱验证成功: user_id={user_id}, email={email}")
spa_initial_state = {
"page": "verify_result",
"success": True,
"title": "验证成功",
"message": "您的邮箱已验证成功!账号已激活,现在可以登录使用了。",
"primary_label": "立即登录",
"primary_url": "/login",
"redirect_url": "/login",
"redirect_seconds": 5,
}
return render_app_spa_or_legacy("verify_success.html", spa_initial_state=spa_initial_state)
logger.warning(f"邮箱验证失败: token={token[:20]}...")
error_message = "验证链接无效或已过期,请重新注册或申请重发验证邮件"
spa_initial_state = {
"page": "verify_result",
"success": False,
"title": "验证失败",
"error_message": error_message,
"primary_label": "重新注册",
"primary_url": "/register",
"secondary_label": "返回登录",
"secondary_url": "/login",
}
return render_app_spa_or_legacy(
"verify_failed.html",
legacy_context={"error_message": error_message},
spa_initial_state=spa_initial_state,
)
@api_auth_bp.route("/api/resend-verify-email", methods=["POST"])
@require_ip_not_locked
def resend_verify_email():
"""重发验证邮件"""
data = request.json
email = data.get("email", "").strip()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
if not email:
return jsonify({"error": "请输入邮箱"}), 400
client_ip = get_client_ip()
allowed, error_msg = check_ip_rate_limit(client_ip)
if not allowed:
return jsonify({"error": error_msg}), 429
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success:
is_locked = record_failed_captcha(client_ip)
if is_locked:
return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
return jsonify({"error": message}), 400
user = database.get_user_by_email(email)
if not user:
return jsonify({"error": "该邮箱未注册"}), 404
if user["status"] == "approved":
return jsonify({"error": "该账号已验证通过,请直接登录"}), 400
result = email_service.resend_register_verification_email(user_id=user["id"], email=email, username=user["username"])
if result["success"]:
return jsonify({"success": True, "message": "验证邮件已重新发送,请查收"})
return jsonify({"error": result["error"]}), 500
@api_auth_bp.route("/api/email/verify-status")
def get_email_verify_status():
"""获取邮箱验证功能状态公开API"""
try:
settings = email_service.get_email_settings()
return jsonify(
{
"email_enabled": settings.get("enabled", False),
"register_verify_enabled": settings.get("register_verify_enabled", False) and settings.get("enabled", False),
}
)
except Exception:
return jsonify({"email_enabled": False, "register_verify_enabled": False})
@api_auth_bp.route("/api/forgot-password", methods=["POST"])
@require_ip_not_locked
def forgot_password():
"""发送密码重置邮件"""
data = request.json
email = data.get("email", "").strip()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
if not email:
return jsonify({"error": "请输入邮箱"}), 400
client_ip = get_client_ip()
allowed, error_msg = check_ip_rate_limit(client_ip)
if not allowed:
return jsonify({"error": error_msg}), 429
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success:
is_locked = record_failed_captcha(client_ip)
if is_locked:
return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
return jsonify({"error": message}), 400
email_settings = email_service.get_email_settings()
if not email_settings.get("enabled", False):
return jsonify({"error": "邮件功能未启用,请联系管理员"}), 400
user = database.get_user_by_email(email)
if user and user.get("status") == "approved":
result = email_service.send_password_reset_email(email=email, username=user["username"], user_id=user["id"])
if not result["success"]:
logger.error(f"密码重置邮件发送失败: {result['error']}")
return jsonify({"success": True, "message": "如果该邮箱已注册,您将收到密码重置邮件"})
@api_auth_bp.route("/reset-password/<token>")
def reset_password_page(token):
"""密码重置页面"""
result = email_service.verify_password_reset_token(token)
valid = bool(result)
error_message = "" if valid else "重置链接无效或已过期,请重新申请密码重置"
legacy_context = {"token": token, "valid": valid, "error_message": error_message}
spa_initial_state = {"page": "reset_password", "token": token, "valid": valid, "error_message": error_message}
return render_app_spa_or_legacy(
"reset_password.html",
legacy_context=legacy_context,
spa_initial_state=spa_initial_state,
)
@api_auth_bp.route("/api/reset-password-confirm", methods=["POST"])
def reset_password_confirm():
"""确认密码重置"""
data = request.json
token = data.get("token", "").strip()
new_password = data.get("new_password", "").strip()
if not token or not new_password:
return jsonify({"error": "参数不完整"}), 400
is_valid, error_msg = validate_password(new_password)
if not is_valid:
return jsonify({"error": error_msg}), 400
result = email_service.confirm_password_reset(token)
if not result:
return jsonify({"error": "重置链接无效或已过期"}), 400
user_id = result["user_id"]
if database.admin_reset_user_password(user_id, new_password):
logger.info(f"用户密码重置成功: user_id={user_id}")
return jsonify({"success": True, "message": "密码重置成功"})
return jsonify({"error": "密码重置失败"}), 500
@api_auth_bp.route("/api/reset_password_request", methods=["POST"])
def request_password_reset():
"""用户申请重置密码(需要审核)"""
data = request.json
username = data.get("username", "").strip()
email = data.get("email", "").strip()
new_password = data.get("new_password", "").strip()
if not username or not new_password:
return jsonify({"error": "用户名和新密码不能为空"}), 400
is_valid, error_msg = validate_password(new_password)
if not is_valid:
return jsonify({"error": error_msg}), 400
user = database.get_user_by_username(username)
if user:
if email and user.get("email") != email:
pass
else:
database.create_password_reset_request(user["id"], new_password)
return jsonify({"message": "如果账号存在,密码重置申请已提交,请等待管理员审核"})
@api_auth_bp.route("/api/generate_captcha", methods=["POST"])
def generate_captcha():
"""生成4位数字验证码图片"""
import base64
import uuid
from io import BytesIO
session_id = str(uuid.uuid4())
code = "".join([str(secrets.randbelow(10)) for _ in range(4)])
safe_set_captcha(session_id, {"code": code, "expire_time": time.time() + 300, "failed_attempts": 0})
safe_cleanup_expired_captcha()
try:
from PIL import Image, ImageDraw, ImageFont
import io
width, height = 160, 60
image = Image.new("RGB", (width, height), color=(255, 255, 255))
draw = ImageDraw.Draw(image)
for _ in range(6):
x1 = random.randint(0, width)
y1 = random.randint(0, height)
x2 = random.randint(0, width)
y2 = random.randint(0, height)
draw.line(
[(x1, y1), (x2, y2)],
fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)),
width=1,
)
for _ in range(80):
x = random.randint(0, width)
y = random.randint(0, height)
draw.point((x, y), fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)))
font = None
font_paths = [
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
]
for font_path in font_paths:
try:
font = ImageFont.truetype(font_path, 42)
break
except Exception:
continue
if font is None:
font = ImageFont.load_default()
for i, char in enumerate(code):
x = 12 + i * 35 + random.randint(-3, 3)
y = random.randint(5, 12)
color = (random.randint(0, 150), random.randint(0, 150), random.randint(0, 150))
draw.text((x, y), char, font=font, fill=color)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
img_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
return jsonify({"session_id": session_id, "captcha_image": f"data:image/png;base64,{img_base64}"})
except ImportError as e:
logger.error(f"PIL库未安装验证码功能不可用: {e}")
safe_delete_captcha(session_id)
return jsonify({"error": "验证码服务暂不可用请联系管理员安装PIL库"}), 503
@api_auth_bp.route("/api/login", methods=["POST"])
@require_ip_not_locked
def login():
"""用户登录"""
data = request.json
username = data.get("username", "").strip()
password = data.get("password", "").strip()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
need_captcha = data.get("need_captcha", False)
if need_captcha:
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success:
return jsonify({"error": message}), 400
user = database.verify_user(username, password)
if not user:
return jsonify({"error": "用户名或密码错误", "need_captcha": True}), 401
if user["status"] != "approved":
return jsonify({"error": "账号未审核,请等待管理员审核", "need_captcha": False}), 401
user_obj = User(user["id"])
login_user(user_obj)
load_user_accounts(user["id"])
return jsonify({"success": True})
@api_auth_bp.route("/api/logout", methods=["POST"])
@login_required
def logout():
"""用户登出"""
logout_user()
return jsonify({"success": True})

248
routes/api_schedules.py Normal file
View File

@@ -0,0 +1,248 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import re
import database
from flask import Blueprint, jsonify, request
from flask_login import current_user, login_required
from services.accounts_service import load_user_accounts
from services.browse_types import BROWSE_TYPE_SHOULD_READ, normalize_browse_type, validate_browse_type
from services.state import safe_get_account, safe_get_user_accounts_snapshot
from services.tasks import submit_account_task
api_schedules_bp = Blueprint("api_schedules", __name__)
@api_schedules_bp.route("/api/schedules", methods=["GET"])
@login_required
def get_user_schedules_api():
"""获取当前用户的所有定时任务"""
schedules = database.get_user_schedules(current_user.id)
import json
for s in schedules:
try:
s["account_ids"] = json.loads(s.get("account_ids", "[]") or "[]")
except (json.JSONDecodeError, TypeError):
s["account_ids"] = []
return jsonify(schedules)
@api_schedules_bp.route("/api/schedules", methods=["POST"])
@login_required
def create_user_schedule_api():
"""创建用户定时任务"""
data = request.json
name = data.get("name", "我的定时任务")
schedule_time = data.get("schedule_time", "08:00")
weekdays = data.get("weekdays", "1,2,3,4,5")
browse_type = validate_browse_type(data.get("browse_type", BROWSE_TYPE_SHOULD_READ), default=BROWSE_TYPE_SHOULD_READ)
if not browse_type:
return jsonify({"error": "浏览类型无效"}), 400
enable_screenshot = data.get("enable_screenshot", 1)
random_delay = int(data.get("random_delay", 0) or 0)
account_ids = data.get("account_ids", [])
if not re.match(r"^\\d{2}:\\d{2}$", schedule_time):
return jsonify({"error": "时间格式不正确,应为 HH:MM"}), 400
if random_delay not in (0, 1):
return jsonify({"error": "random_delay必须是0或1"}), 400
schedule_id = database.create_user_schedule(
user_id=current_user.id,
name=name,
schedule_time=schedule_time,
weekdays=weekdays,
browse_type=browse_type,
enable_screenshot=enable_screenshot,
random_delay=random_delay,
account_ids=account_ids,
)
if schedule_id:
return jsonify({"success": True, "id": schedule_id})
return jsonify({"error": "创建失败"}), 500
@api_schedules_bp.route("/api/schedules/<int:schedule_id>", methods=["GET"])
@login_required
def get_schedule_detail_api(schedule_id):
"""获取定时任务详情"""
schedule = database.get_schedule_by_id(schedule_id)
if not schedule:
return jsonify({"error": "定时任务不存在"}), 404
if schedule["user_id"] != current_user.id:
return jsonify({"error": "无权访问"}), 403
import json
try:
schedule["account_ids"] = json.loads(schedule.get("account_ids", "[]") or "[]")
except (json.JSONDecodeError, TypeError):
schedule["account_ids"] = []
return jsonify(schedule)
@api_schedules_bp.route("/api/schedules/<int:schedule_id>", methods=["PUT"])
@login_required
def update_schedule_api(schedule_id):
"""更新定时任务"""
schedule = database.get_schedule_by_id(schedule_id)
if not schedule:
return jsonify({"error": "定时任务不存在"}), 404
if schedule["user_id"] != current_user.id:
return jsonify({"error": "无权访问"}), 403
data = request.json
allowed_fields = [
"name",
"schedule_time",
"weekdays",
"browse_type",
"enable_screenshot",
"random_delay",
"account_ids",
"enabled",
]
update_data = {k: v for k, v in data.items() if k in allowed_fields}
if "schedule_time" in update_data:
if not re.match(r"^\\d{2}:\\d{2}$", update_data["schedule_time"]):
return jsonify({"error": "时间格式不正确"}), 400
if "random_delay" in update_data:
try:
update_data["random_delay"] = int(update_data.get("random_delay") or 0)
except Exception:
return jsonify({"error": "random_delay必须是0或1"}), 400
if update_data["random_delay"] not in (0, 1):
return jsonify({"error": "random_delay必须是0或1"}), 400
if "browse_type" in update_data:
normalized = validate_browse_type(update_data.get("browse_type"), default=BROWSE_TYPE_SHOULD_READ)
if not normalized:
return jsonify({"error": "浏览类型无效"}), 400
update_data["browse_type"] = normalized
success = database.update_user_schedule(schedule_id, **update_data)
if success:
return jsonify({"success": True})
return jsonify({"error": "更新失败"}), 500
@api_schedules_bp.route("/api/schedules/<int:schedule_id>", methods=["DELETE"])
@login_required
def delete_schedule_api(schedule_id):
"""删除定时任务"""
schedule = database.get_schedule_by_id(schedule_id)
if not schedule:
return jsonify({"error": "定时任务不存在"}), 404
if schedule["user_id"] != current_user.id:
return jsonify({"error": "无权访问"}), 403
success = database.delete_user_schedule(schedule_id)
if success:
return jsonify({"success": True})
return jsonify({"error": "删除失败"}), 500
@api_schedules_bp.route("/api/schedules/<int:schedule_id>/toggle", methods=["POST"])
@login_required
def toggle_schedule_api(schedule_id):
"""启用/禁用定时任务"""
schedule = database.get_schedule_by_id(schedule_id)
if not schedule:
return jsonify({"error": "定时任务不存在"}), 404
if schedule["user_id"] != current_user.id:
return jsonify({"error": "无权访问"}), 403
data = request.json
enabled = data.get("enabled", not schedule["enabled"])
success = database.toggle_user_schedule(schedule_id, enabled)
if success:
return jsonify({"success": True, "enabled": enabled})
return jsonify({"error": "操作失败"}), 500
@api_schedules_bp.route("/api/schedules/<int:schedule_id>/run", methods=["POST"])
@login_required
def run_schedule_now_api(schedule_id):
"""立即执行定时任务"""
import json
schedule = database.get_schedule_by_id(schedule_id)
if not schedule:
return jsonify({"error": "定时任务不存在"}), 404
if schedule["user_id"] != current_user.id:
return jsonify({"error": "无权访问"}), 403
try:
account_ids = json.loads(schedule.get("account_ids", "[]") or "[]")
except (json.JSONDecodeError, TypeError):
account_ids = []
if not account_ids:
return jsonify({"error": "没有配置账号"}), 400
user_id = current_user.id
browse_type = normalize_browse_type(schedule.get("browse_type", BROWSE_TYPE_SHOULD_READ))
enable_screenshot = schedule["enable_screenshot"]
if not safe_get_user_accounts_snapshot(user_id):
load_user_accounts(user_id)
started = []
for account_id in account_ids:
account = safe_get_account(user_id, account_id)
if not account:
continue
if account.is_running:
continue
ok, msg = submit_account_task(
user_id=user_id,
account_id=account_id,
browse_type=browse_type,
enable_screenshot=enable_screenshot,
source="user_scheduled",
)
if ok:
started.append(account_id)
database.update_schedule_last_run(schedule_id)
return jsonify({"success": True, "started_count": len(started), "message": f"已启动 {len(started)} 个账号"})
@api_schedules_bp.route("/api/schedules/<int:schedule_id>/logs", methods=["GET"])
@login_required
def get_schedule_logs_api(schedule_id):
"""获取定时任务执行日志"""
try:
schedule = database.get_schedule_by_id(schedule_id)
if not schedule or schedule["user_id"] != current_user.id:
return jsonify([])
limit = request.args.get("limit", 20, type=int)
logs = database.get_schedule_execution_logs(schedule_id, limit)
return jsonify(logs if logs else [])
except Exception:
return jsonify([])
@api_schedules_bp.route("/api/schedules/<int:schedule_id>/logs", methods=["DELETE"])
@login_required
def delete_schedule_logs_api(schedule_id):
"""清空定时任务执行日志"""
try:
schedule = database.get_schedule_by_id(schedule_id)
if not schedule or schedule["user_id"] != current_user.id:
return jsonify({"error": "无权限"}), 403
deleted = database.delete_schedule_logs(schedule_id, current_user.id)
return jsonify({"success": True, "deleted": deleted})
except Exception as e:
return jsonify({"error": str(e)}), 500

117
routes/api_screenshots.py Normal file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
from datetime import datetime
import database
from app_config import get_config
from app_security import is_safe_path
from flask import Blueprint, jsonify, send_from_directory
from flask_login import current_user, login_required
from services.client_log import log_to_client
from services.time_utils import BEIJING_TZ
config = get_config()
SCREENSHOTS_DIR = config.SCREENSHOTS_DIR
api_screenshots_bp = Blueprint("api_screenshots", __name__)
@api_screenshots_bp.route("/api/screenshots", methods=["GET"])
@login_required
def get_screenshots():
"""获取当前用户的截图列表"""
user_id = current_user.id
user_info = database.get_user_by_id(user_id)
username_prefix = user_info["username"] if user_info else f"user{user_id}"
try:
screenshots = []
if os.path.exists(SCREENSHOTS_DIR):
for filename in os.listdir(SCREENSHOTS_DIR):
if filename.lower().endswith((".png", ".jpg", ".jpeg")) and filename.startswith(username_prefix + "_"):
filepath = os.path.join(SCREENSHOTS_DIR, filename)
stat = os.stat(filepath)
created_time = datetime.fromtimestamp(stat.st_mtime, tz=BEIJING_TZ)
parts = filename.rsplit(".", 1)[0].split("_", 1)
if len(parts) > 1:
display_name = parts[1] + "." + filename.rsplit(".", 1)[1]
else:
display_name = filename
screenshots.append(
{
"filename": filename,
"display_name": display_name,
"size": stat.st_size,
"created": created_time.strftime("%Y-%m-%d %H:%M:%S"),
}
)
screenshots.sort(key=lambda x: x["created"], reverse=True)
return jsonify(screenshots)
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_screenshots_bp.route("/screenshots/<filename>")
@login_required
def serve_screenshot(filename):
"""提供截图文件访问"""
user_id = current_user.id
user_info = database.get_user_by_id(user_id)
username_prefix = user_info["username"] if user_info else f"user{user_id}"
if not filename.startswith(username_prefix + "_"):
return jsonify({"error": "无权访问"}), 403
if not is_safe_path(SCREENSHOTS_DIR, filename):
return jsonify({"error": "非法路径"}), 403
return send_from_directory(SCREENSHOTS_DIR, filename)
@api_screenshots_bp.route("/api/screenshots/<filename>", methods=["DELETE"])
@login_required
def delete_screenshot(filename):
"""删除指定截图"""
user_id = current_user.id
user_info = database.get_user_by_id(user_id)
username_prefix = user_info["username"] if user_info else f"user{user_id}"
if not filename.startswith(username_prefix + "_"):
return jsonify({"error": "无权删除"}), 403
try:
filepath = os.path.join(SCREENSHOTS_DIR, filename)
if os.path.exists(filepath):
os.remove(filepath)
log_to_client(f"删除截图: {filename}", user_id)
return jsonify({"success": True})
return jsonify({"error": "文件不存在"}), 404
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_screenshots_bp.route("/api/screenshots/clear", methods=["POST"])
@login_required
def clear_all_screenshots():
"""清空当前用户的所有截图"""
user_id = current_user.id
user_info = database.get_user_by_id(user_id)
username_prefix = user_info["username"] if user_info else f"user{user_id}"
try:
deleted_count = 0
if os.path.exists(SCREENSHOTS_DIR):
for filename in os.listdir(SCREENSHOTS_DIR):
if filename.lower().endswith((".png", ".jpg", ".jpeg")) and filename.startswith(username_prefix + "_"):
filepath = os.path.join(SCREENSHOTS_DIR, filename)
os.remove(filepath)
deleted_count += 1
log_to_client(f"清理了 {deleted_count} 个截图文件", user_id)
return jsonify({"success": True, "deleted": deleted_count})
except Exception as e:
return jsonify({"error": str(e)}), 500

293
routes/api_user.py Normal file
View File

@@ -0,0 +1,293 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import database
import email_service
from app_logger import get_logger
from app_security import require_ip_not_locked, validate_email
from flask import Blueprint, jsonify, request
from flask_login import current_user, login_required
from routes.pages import render_app_spa_or_legacy
from services.state import safe_iter_task_status_items
logger = get_logger("app")
api_user_bp = Blueprint("api_user", __name__)
@api_user_bp.route("/api/announcements/active", methods=["GET"])
@login_required
def get_active_announcement():
"""获取当前用户应展示的公告若无则返回announcement=null"""
try:
user_id = int(current_user.id)
except Exception:
return jsonify({"announcement": None})
announcement = database.get_active_announcement_for_user(user_id)
if not announcement:
return jsonify({"announcement": None})
return jsonify(
{
"announcement": {
"id": announcement.get("id"),
"title": announcement.get("title", ""),
"content": announcement.get("content", ""),
"created_at": announcement.get("created_at"),
}
}
)
@api_user_bp.route("/api/announcements/<int:announcement_id>/dismiss", methods=["POST"])
@login_required
def dismiss_announcement(announcement_id):
"""用户永久关闭某条公告(本次公告不再弹窗)"""
try:
user_id = int(current_user.id)
except Exception:
return jsonify({"error": "请先登录"}), 401
announcement = database.get_announcement_by_id(announcement_id)
if not announcement:
return jsonify({"error": "公告不存在"}), 404
database.dismiss_announcement_for_user(user_id, announcement_id)
return jsonify({"success": True})
@api_user_bp.route("/api/feedback", methods=["POST"])
@login_required
def submit_feedback():
"""用户提交Bug反馈"""
data = request.get_json()
title = data.get("title", "").strip()
description = data.get("description", "").strip()
contact = data.get("contact", "").strip()
if not title or not description:
return jsonify({"error": "标题和描述不能为空"}), 400
if len(title) > 100:
return jsonify({"error": "标题不能超过100个字符"}), 400
if len(description) > 2000:
return jsonify({"error": "描述不能超过2000个字符"}), 400
user_info = database.get_user_by_id(current_user.id)
username = user_info["username"] if user_info else f"用户{current_user.id}"
feedback_id = database.create_bug_feedback(
user_id=current_user.id,
username=username,
title=title,
description=description,
contact=contact,
)
return jsonify({"message": "反馈提交成功", "id": feedback_id})
@api_user_bp.route("/api/feedback", methods=["GET"])
@login_required
def get_my_feedbacks():
"""获取当前用户的反馈列表"""
feedbacks = database.get_user_feedbacks(current_user.id)
return jsonify(feedbacks)
@api_user_bp.route("/api/user/vip", methods=["GET"])
@login_required
def get_current_user_vip():
"""获取当前用户VIP信息"""
vip_info = database.get_user_vip_info(current_user.id)
user_info = database.get_user_by_id(current_user.id)
vip_info["username"] = user_info["username"] if user_info else "Unknown"
return jsonify(vip_info)
@api_user_bp.route("/api/user/password", methods=["POST"])
@login_required
def change_user_password():
"""用户修改自己的密码"""
data = request.get_json()
current_password = data.get("current_password")
new_password = data.get("new_password")
if not current_password or not new_password:
return jsonify({"error": "请填写完整信息"}), 400
if len(new_password) < 6:
return jsonify({"error": "新密码至少6位"}), 400
user = database.get_user_by_id(current_user.id)
if not user:
return jsonify({"error": "用户不存在"}), 404
username = user.get("username", "")
if not username or not database.verify_user(username, current_password):
return jsonify({"error": "当前密码错误"}), 400
if database.admin_reset_user_password(current_user.id, new_password):
return jsonify({"success": True})
return jsonify({"error": "密码更新失败"}), 500
@api_user_bp.route("/api/user/email", methods=["GET"])
@login_required
def get_user_email():
"""获取当前用户的邮箱信息"""
user = database.get_user_by_id(current_user.id)
if not user:
return jsonify({"error": "用户不存在"}), 404
return jsonify({"email": user.get("email", ""), "email_verified": user.get("email_verified", False)})
@api_user_bp.route("/api/user/bind-email", methods=["POST"])
@login_required
@require_ip_not_locked
def bind_user_email():
"""发送邮箱绑定验证邮件"""
data = request.get_json()
email = data.get("email", "").strip().lower()
if not email or not validate_email(email):
return jsonify({"error": "请输入有效的邮箱地址"}), 400
settings = email_service.get_email_settings()
if not settings.get("enabled", False):
return jsonify({"error": "邮件功能未启用,请联系管理员"}), 400
existing_user = database.get_user_by_email(email)
if existing_user and existing_user["id"] != current_user.id:
return jsonify({"error": "该邮箱已被其他用户绑定"}), 400
user = database.get_user_by_id(current_user.id)
if not user:
return jsonify({"error": "用户不存在"}), 404
if user.get("email") == email and user.get("email_verified"):
return jsonify({"error": "该邮箱已绑定并验证"}), 400
result = email_service.send_bind_email_verification(user_id=current_user.id, email=email, username=user["username"])
if result["success"]:
return jsonify({"success": True, "message": "验证邮件已发送,请查收"})
return jsonify({"error": result["error"]}), 500
@api_user_bp.route("/api/verify-bind-email/<token>")
def verify_bind_email(token):
"""验证邮箱绑定Token"""
result = email_service.verify_bind_email_token(token)
if result:
user_id = result["user_id"]
email = result["email"]
if database.update_user_email(user_id, email, verified=True):
spa_initial_state = {
"page": "verify_result",
"success": True,
"title": "邮箱绑定成功",
"message": f"邮箱 {email} 已成功绑定到您的账号!",
"primary_label": "返回登录",
"primary_url": "/login",
"redirect_url": "/login",
"redirect_seconds": 5,
}
return render_app_spa_or_legacy("verify_success.html", spa_initial_state=spa_initial_state)
error_message = "邮箱绑定失败,请重试"
spa_initial_state = {
"page": "verify_result",
"success": False,
"title": "绑定失败",
"error_message": error_message,
"primary_label": "返回登录",
"primary_url": "/login",
}
return render_app_spa_or_legacy(
"verify_failed.html",
legacy_context={"error_message": error_message},
spa_initial_state=spa_initial_state,
)
error_message = "验证链接已过期或无效,请重新发送验证邮件"
spa_initial_state = {
"page": "verify_result",
"success": False,
"title": "链接无效",
"error_message": error_message,
"primary_label": "返回登录",
"primary_url": "/login",
}
return render_app_spa_or_legacy(
"verify_failed.html",
legacy_context={"error_message": error_message},
spa_initial_state=spa_initial_state,
)
@api_user_bp.route("/api/user/unbind-email", methods=["POST"])
@login_required
def unbind_user_email():
"""解绑用户邮箱"""
user = database.get_user_by_id(current_user.id)
if not user:
return jsonify({"error": "用户不存在"}), 404
if not user.get("email"):
return jsonify({"error": "当前未绑定邮箱"}), 400
if database.update_user_email(current_user.id, None, verified=False):
return jsonify({"success": True, "message": "邮箱已解绑"})
return jsonify({"error": "解绑失败"}), 500
@api_user_bp.route("/api/user/email-notify", methods=["GET"])
@login_required
def get_user_email_notify():
"""获取用户邮件通知偏好"""
enabled = database.get_user_email_notify(current_user.id)
return jsonify({"enabled": enabled})
@api_user_bp.route("/api/user/email-notify", methods=["POST"])
@login_required
def update_user_email_notify():
"""更新用户邮件通知偏好"""
data = request.get_json()
enabled = data.get("enabled", True)
if database.update_user_email_notify(current_user.id, enabled):
return jsonify({"success": True})
return jsonify({"error": "更新失败"}), 500
@api_user_bp.route("/api/run_stats", methods=["GET"])
@login_required
def get_run_stats():
"""获取当前用户的运行统计"""
user_id = current_user.id
stats = database.get_user_run_stats(user_id)
current_running = 0
for _, info in safe_iter_task_status_items():
if info.get("user_id") == user_id and info.get("status") == "运行中":
current_running += 1
return jsonify(
{
"today_completed": stats.get("completed", 0),
"current_running": current_running,
"today_failed": stats.get("failed", 0),
"today_items": stats.get("total_items", 0),
"today_attachments": stats.get("total_attachments", 0),
}
)

26
routes/decorators.py Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from functools import wraps
from flask import jsonify, request, session
from services.runtime import get_logger
def admin_required(f):
"""管理员权限装饰器(不改变原有接口/行为)。"""
@wraps(f)
def decorated_function(*args, **kwargs):
logger = get_logger()
logger.debug(f"[admin_required] 检查会话admin_id存在: {'admin_id' in session}")
if "admin_id" not in session:
logger.warning(f"[admin_required] 拒绝访问 {request.path} - session中无admin_id")
return jsonify({"error": "需要管理员权限"}), 403
logger.info(f"[admin_required] 管理员 {session.get('admin_username')} 访问 {request.path}")
return f(*args, **kwargs)
return decorated_function

123
routes/pages.py Normal file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import os
from typing import Optional
from flask import Blueprint, current_app, redirect, render_template, session, url_for
from flask_login import current_user, login_required
from routes.decorators import admin_required
from services.runtime import get_logger
pages_bp = Blueprint("pages", __name__)
def render_app_spa_or_legacy(
legacy_template_name: str,
legacy_context: Optional[dict] = None,
spa_initial_state: Optional[dict] = None,
):
"""渲染前台 Vue SPA构建产物位于 static/app失败则回退旧模板。"""
logger = get_logger()
legacy_context = legacy_context or {}
manifest_path = os.path.join(current_app.root_path, "static", "app", ".vite", "manifest.json")
try:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
entry = manifest.get("index.html") or {}
js_file = entry.get("file")
css_files = entry.get("css") or []
if not js_file:
logger.warning(f"[app_spa] manifest缺少入口文件: {manifest_path}")
return render_template(legacy_template_name, **legacy_context)
return render_template(
"app.html",
app_spa_js_file=f"app/{js_file}",
app_spa_css_files=[f"app/{p}" for p in css_files],
app_spa_initial_state=spa_initial_state,
)
except FileNotFoundError:
logger.info(f"[app_spa] 未找到manifest: {manifest_path},回退旧模板: {legacy_template_name}")
return render_template(legacy_template_name, **legacy_context)
except Exception as e:
logger.error(f"[app_spa] 加载manifest失败: {e}")
return render_template(legacy_template_name, **legacy_context)
@pages_bp.route("/")
def index():
"""主页 - 重定向到登录或应用"""
if current_user.is_authenticated:
return redirect(url_for("pages.app_page"))
return redirect(url_for("pages.login_page"))
@pages_bp.route("/login")
def login_page():
"""登录页面"""
return render_app_spa_or_legacy("login.html")
@pages_bp.route("/register")
def register_page():
"""注册页面"""
return render_app_spa_or_legacy("register.html")
@pages_bp.route("/app")
@login_required
def app_page():
"""主应用页面"""
return render_app_spa_or_legacy("index.html")
@pages_bp.route("/app/<path:subpath>")
@login_required
def app_page_subpath(subpath):
"""SPA 子路由刷新支持History 模式)"""
return render_app_spa_or_legacy("index.html")
@pages_bp.route("/yuyx")
def admin_login_page():
"""后台登录页面"""
if "admin_id" in session:
return redirect(url_for("pages.admin_page"))
return render_template("admin_login.html")
@pages_bp.route("/yuyx/admin")
@admin_required
def admin_page():
"""后台管理页面"""
logger = get_logger()
manifest_path = os.path.join(current_app.root_path, "static", "admin", ".vite", "manifest.json")
try:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
entry = manifest.get("index.html") or {}
js_file = entry.get("file")
css_files = entry.get("css") or []
if not js_file:
logger.warning(f"[admin_spa] manifest缺少入口文件: {manifest_path}")
return render_template("admin_legacy.html")
return render_template(
"admin.html",
admin_spa_js_file=f"admin/{js_file}",
admin_spa_css_files=[f"admin/{p}" for p in css_files],
)
except FileNotFoundError:
logger.warning(f"[admin_spa] 未找到manifest: {manifest_path},回退旧版后台模板")
return render_template("admin_legacy.html")
except Exception as e:
logger.error(f"[admin_spa] 加载manifest失败: {e}")
return render_template("admin_legacy.html")