Harden auth, CSRF, and email log UX

This commit is contained in:
2025-12-26 19:05:20 +08:00
parent 3214cbbd91
commit f90b0a4f11
47 changed files with 583 additions and 198 deletions

View File

@@ -1206,23 +1206,24 @@
}
},
"node_modules/element-plus": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.12.0.tgz",
"integrity": "sha512-M9YLSn2np9OnqrSKWsiXvGe3qnF8pd94+TScsHj1aTMCD+nSEvucXermf807qNt6hOP040le0e5Aft7E9ZfHmA==",
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.11.3.tgz",
"integrity": "sha512-769xsjLR4B9Vf9cl5PDXnwTEdmFJvMgAkYtthdJKPhjVjU3hdAwTJ+gXKiO+PUyo2KWFwOYKZd4Ywh6PHfkbJg==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.4.1",
"@element-plus/icons-vue": "^2.3.2",
"@element-plus/icons-vue": "^2.3.1",
"@floating-ui/dom": "^1.0.1",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
"@types/lodash": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6",
"@vueuse/core": "^9.1.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.19",
"dayjs": "^1.11.13",
"escape-html": "^1.0.3",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"lodash-unified": "^1.0.3",
"lodash-unified": "^1.0.2",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0"
},
@@ -1329,6 +1330,12 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",

View File

@@ -12,12 +12,30 @@ function toastErrorOnce(key, message, minIntervalMs = 1500) {
ElMessage.error(message)
}
function getCookie(name) {
const escaped = String(name || '').replace(/([.*+?^${}()|[\]\\])/g, '\\$1')
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`))
return match ? decodeURIComponent(match[1]) : ''
}
export const api = axios.create({
baseURL: '/yuyx/api',
timeout: 30_000,
withCredentials: true,
})
api.interceptors.request.use((config) => {
const method = String(config?.method || 'GET').toUpperCase()
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const token = getCookie('csrf_token')
if (token) {
config.headers = config.headers || {}
config.headers['X-CSRF-Token'] = token
}
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {

View File

@@ -477,6 +477,12 @@ function emailTypeLabel(type) {
return map[type] || type
}
function emailLogUserLabel(row) {
if (row?.username && row?.user_id) return `${row.username} (#${row.user_id})`
if (row?.user_id) return `用户#${row.user_id}`
return '系统'
}
async function loadEmailStats() {
emailStatsLoading.value = true
try {
@@ -709,6 +715,11 @@ onMounted(refreshAll)
<el-table :data="emailLogs" v-loading="emailLogsLoading" style="width: 100%">
<el-table-column prop="created_at" label="时间" width="180" />
<el-table-column prop="email_to" label="收件人" min-width="180" />
<el-table-column label="来源用户" min-width="160">
<template #default="{ row }">
<span class="ellipsis" :title="emailLogUserLabel(row)">{{ emailLogUserLabel(row) }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="120">
<template #default="{ row }">{{ emailTypeLabel(row.email_type) }}</template>
</el-table-column>

View File

@@ -12,12 +12,30 @@ function toastErrorOnce(key, message, minIntervalMs = 1500) {
ElMessage.error(message)
}
function getCookie(name) {
const escaped = String(name || '').replace(/([.*+?^${}()|[\]\\])/g, '\\$1')
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`))
return match ? decodeURIComponent(match[1]) : ''
}
export const publicApi = axios.create({
baseURL: '/api',
timeout: 30_000,
withCredentials: true,
})
publicApi.interceptors.request.use((config) => {
const method = String(config?.method || 'GET').toUpperCase()
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const token = getCookie('csrf_token')
if (token) {
config.headers = config.headers || {}
config.headers['X-CSRF-Token'] = token
}
}
return config
})
publicApi.interceptors.response.use(
(response) => response,
(error) => {

36
app.py
View File

@@ -18,8 +18,8 @@ import signal
import sys
import threading
from flask import Flask, jsonify, redirect, request, send_from_directory, url_for
from flask_login import LoginManager
from flask import Flask, jsonify, redirect, request, send_from_directory, session, url_for
from flask_login import LoginManager, current_user
from flask_socketio import SocketIO
import database
@@ -27,7 +27,7 @@ import db_pool
import email_service
from app_config import get_config
from app_logger import get_logger, init_logging
from app_security import is_safe_path
from app_security import generate_csrf_token, is_safe_path, validate_csrf_token
from browser_pool_worker import init_browser_worker_pool, shutdown_browser_worker_pool
from realtime.socketio_handlers import register_socketio_handlers
from realtime.status_push import status_push_worker
@@ -128,6 +128,36 @@ def unauthorized():
return redirect(url_for("pages.login_page", next=request.url))
@app.before_request
def enforce_csrf_protection():
if request.method in {"GET", "HEAD", "OPTIONS"}:
return
if request.path.startswith("/static/"):
return
if not (current_user.is_authenticated or "admin_id" in session):
return
token = request.headers.get("X-CSRF-Token") or request.form.get("csrf_token")
if not token or not validate_csrf_token(token):
return jsonify({"error": "CSRF token missing or invalid"}), 403
@app.after_request
def ensure_csrf_cookie(response):
if request.path.startswith("/static/"):
return response
token = session.get("csrf_token")
if not token:
token = generate_csrf_token()
response.set_cookie(
"csrf_token",
token,
httponly=False,
secure=bool(config.SESSION_COOKIE_SECURE),
samesite=config.SESSION_COOKIE_SAMESITE,
)
return response
# ==================== 静态文件(保持 endpoint 名称不变) ====================

View File

@@ -143,6 +143,12 @@ class Config:
# ==================== IP限流配置 ====================
MAX_IP_ATTEMPTS_PER_HOUR = int(os.environ.get('MAX_IP_ATTEMPTS_PER_HOUR', '10'))
IP_LOCK_DURATION = int(os.environ.get('IP_LOCK_DURATION', '3600')) # 秒
IP_RATE_LIMIT_LOGIN_MAX = int(os.environ.get('IP_RATE_LIMIT_LOGIN_MAX', '20'))
IP_RATE_LIMIT_LOGIN_WINDOW_SECONDS = int(os.environ.get('IP_RATE_LIMIT_LOGIN_WINDOW_SECONDS', '60'))
IP_RATE_LIMIT_REGISTER_MAX = int(os.environ.get('IP_RATE_LIMIT_REGISTER_MAX', '10'))
IP_RATE_LIMIT_REGISTER_WINDOW_SECONDS = int(os.environ.get('IP_RATE_LIMIT_REGISTER_WINDOW_SECONDS', '3600'))
IP_RATE_LIMIT_EMAIL_MAX = int(os.environ.get('IP_RATE_LIMIT_EMAIL_MAX', '20'))
IP_RATE_LIMIT_EMAIL_WINDOW_SECONDS = int(os.environ.get('IP_RATE_LIMIT_EMAIL_WINDOW_SECONDS', '3600'))
# ==================== 超时配置 ====================
PAGE_LOAD_TIMEOUT = int(os.environ.get('PAGE_LOAD_TIMEOUT', '60000')) # 毫秒
@@ -181,6 +187,8 @@ class Config:
DEBUG = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
ALLOWED_SCREENSHOT_EXTENSIONS = {'.png', '.jpg', '.jpeg'}
MAX_SCREENSHOT_SIZE = int(os.environ.get('MAX_SCREENSHOT_SIZE', '10485760')) # 10MB
LOGIN_CAPTCHA_AFTER_FAILURES = int(os.environ.get('LOGIN_CAPTCHA_AFTER_FAILURES', '3'))
LOGIN_CAPTCHA_WINDOW_SECONDS = int(os.environ.get('LOGIN_CAPTCHA_WINDOW_SECONDS', '900'))
@classmethod
def validate(cls):

View File

@@ -10,9 +10,12 @@ import re
import time
import hashlib
import secrets
import ipaddress
import socket
from pathlib import Path
from typing import Optional
from functools import wraps
from urllib.parse import urlparse
from flask import request, jsonify, session
from collections import defaultdict
import threading
@@ -199,7 +202,7 @@ def require_ip_not_locked(f):
"""装饰器检查IP是否被锁定"""
@wraps(f)
def decorated_function(*args, **kwargs):
ip_address = request.remote_addr
ip_address = get_rate_limit_ip()
# P0 / O-01统一使用 services.state 的线程安全限流状态
try:
@@ -450,6 +453,65 @@ def get_client_ip(trust_proxy=False):
return request.remote_addr
def get_rate_limit_ip() -> str:
"""在可信代理场景下取真实IP用于限流/风控。"""
remote_addr = request.remote_addr or ""
try:
remote_ip = ipaddress.ip_address(remote_addr)
except ValueError:
remote_ip = None
if remote_ip and (remote_ip.is_private or remote_ip.is_loopback or remote_ip.is_link_local):
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
candidate = forwarded.split(",")[0].strip()
try:
ipaddress.ip_address(candidate)
return candidate
except ValueError:
pass
real_ip = request.headers.get("X-Real-IP", "").strip()
if real_ip:
try:
ipaddress.ip_address(real_ip)
return real_ip
except ValueError:
pass
return remote_addr
def is_safe_outbound_url(url: str) -> bool:
"""限制向内网/保留地址发起请求降低SSRF风险。"""
try:
parsed = urlparse(str(url or "").strip())
except Exception:
return False
if parsed.scheme not in ("http", "https"):
return False
host = parsed.hostname
if not host:
return False
ips = []
try:
ips = [ipaddress.ip_address(host)]
except ValueError:
try:
infos = socket.getaddrinfo(host, None)
ips = [ipaddress.ip_address(info[4][0]) for info in infos]
except Exception:
return False
for ip in ips:
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved:
return False
return True
if __name__ == '__main__':
# 测试文件路径安全
print("文件路径安全测试:")

View File

@@ -11,7 +11,13 @@ import database
import email_service
import requests
from app_logger import get_logger
from app_security import require_ip_not_locked, validate_password
from app_security import (
get_rate_limit_ip,
is_safe_outbound_url,
require_ip_not_locked,
validate_email,
validate_password,
)
from flask import current_app, jsonify, redirect, request, session, url_for
from routes.admin_api import admin_api_bp
from routes.decorators import admin_required
@@ -26,6 +32,10 @@ from services.state import (
safe_iter_task_status_items,
safe_remove_user_accounts,
safe_verify_and_consume_captcha,
check_ip_request_rate,
check_login_captcha_required,
clear_login_failures,
record_login_failure,
)
from services.tasks import get_task_scheduler, submit_account_task
from services.time_utils import BEIJING_TZ, get_beijing_now
@@ -62,7 +72,7 @@ def debug_config():
def admin_login():
"""管理员登录支持JSON和form-data两种格式"""
if request.is_json:
data = request.json
data = request.json or {}
else:
data = request.form
@@ -72,15 +82,29 @@ def admin_login():
captcha_code = data.get("captcha", "").strip()
need_captcha = data.get("need_captcha", False)
if need_captcha:
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "login")
if not allowed:
if request.is_json:
return jsonify({"error": error_msg, "need_captcha": True}), 429
return redirect(url_for("pages.admin_login_page"))
captcha_required = check_login_captcha_required(client_ip) or bool(need_captcha)
if captcha_required:
if not captcha_session or not captcha_code:
if request.is_json:
return jsonify({"error": "请填写验证码", "need_captcha": True}), 400
return redirect(url_for("pages.admin_login_page"))
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success:
record_login_failure(client_ip)
if request.is_json:
return jsonify({"error": message}), 400
return jsonify({"error": message, "need_captcha": True}), 400
return redirect(url_for("pages.admin_login_page"))
admin = database.verify_admin(username, password)
if admin:
clear_login_failures(client_ip)
session.pop("admin_id", None)
session.pop("admin_username", None)
session["admin_id"] = admin["id"]
@@ -94,9 +118,10 @@ def admin_login():
return jsonify({"success": True, "redirect": "/yuyx/admin"})
return redirect(url_for("pages.admin_page"))
record_login_failure(client_ip)
logger.warning(f"[admin_login] 管理员 {username} 登录失败 - 用户名或密码错误")
if request.is_json:
return jsonify({"error": "管理员用户名或密码错误", "need_captcha": True}), 401
return jsonify({"error": "管理员用户名或密码错误", "need_captcha": check_login_captcha_required(client_ip)}), 401
return redirect(url_for("pages.admin_login_page"))
@@ -565,6 +590,9 @@ def test_proxy_api():
if not api_url:
return jsonify({"error": "请提供API地址"}), 400
if not is_safe_outbound_url(api_url):
return jsonify({"error": "API地址不可用或不安全"}), 400
try:
response = requests.get(api_url, timeout=10)
if response.status_code == 200:
@@ -1071,10 +1099,9 @@ def test_smtp_config_api(config_id):
if not test_email:
return jsonify({"error": "请提供测试邮箱"}), 400
import re
if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", test_email):
return jsonify({"error": "邮箱格式不正确"}), 400
is_valid, error_msg = validate_email(test_email)
if not is_valid:
return jsonify({"error": error_msg}), 400
result = email_service.test_smtp_config(config_id, test_email)
return jsonify(result)

View File

@@ -9,15 +9,18 @@ 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 app_security import get_rate_limit_ip, require_ip_not_locked, validate_email, validate_password, validate_username
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,
check_ip_request_rate,
check_login_captcha_required,
clear_login_failures,
record_failed_captcha,
record_login_failure,
safe_cleanup_expired_captcha,
safe_delete_captcha,
safe_set_captcha,
@@ -33,23 +36,26 @@ api_auth_bp = Blueprint("api_auth", __name__)
@require_ip_not_locked
def register():
"""用户注册"""
data = request.json
data = request.json or {}
username = data.get("username", "").strip()
password = data.get("password", "").strip()
email = data.get("email", "").strip()
email = data.get("email", "").strip().lower()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
if not username or not password:
return jsonify({"error": "用户名和密码不能为空"}), 400
is_valid, error_msg = validate_username(username)
if not is_valid:
return jsonify({"error": error_msg}), 400
is_valid, error_msg = validate_password(password)
if not is_valid:
return jsonify({"error": error_msg}), 400
client_ip = get_client_ip()
allowed, error_msg = check_ip_rate_limit(client_ip)
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "register")
if not allowed:
return jsonify({"error": error_msg}), 429
@@ -66,8 +72,10 @@ def register():
if email_verify_enabled and not email:
return jsonify({"error": "启用邮箱验证后,邮箱为必填项"}), 400
if email and "@" not in email:
return jsonify({"error": "邮箱格式不正确"}), 400
if email:
is_valid, error_msg = validate_email(email)
if not is_valid:
return jsonify({"error": error_msg}), 400
system_config = database.get_system_config()
auto_approve_enabled = system_config.get("auto_approve_enabled", 0) == 1
@@ -159,17 +167,20 @@ def verify_email(token):
@require_ip_not_locked
def resend_verify_email():
"""重发验证邮件"""
data = request.json
email = data.get("email", "").strip()
data = request.json or {}
email = data.get("email", "").strip().lower()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
if not email:
return jsonify({"error": "请输入邮箱"}), 400
client_ip = get_client_ip()
is_valid, error_msg = validate_email(email)
if not is_valid:
return jsonify({"error": error_msg}), 400
allowed, error_msg = check_ip_rate_limit(client_ip)
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "email")
if not allowed:
return jsonify({"error": error_msg}), 429
@@ -213,17 +224,20 @@ def get_email_verify_status():
@require_ip_not_locked
def forgot_password():
"""发送密码重置邮件"""
data = request.json
email = data.get("email", "").strip()
data = request.json or {}
email = data.get("email", "").strip().lower()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
if not email:
return jsonify({"error": "请输入邮箱"}), 400
client_ip = get_client_ip()
is_valid, error_msg = validate_email(email)
if not is_valid:
return jsonify({"error": error_msg}), 400
allowed, error_msg = check_ip_rate_limit(client_ip)
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "email")
if not allowed:
return jsonify({"error": error_msg}), 429
@@ -267,7 +281,7 @@ def reset_password_page(token):
@api_auth_bp.route("/api/reset-password-confirm", methods=["POST"])
def reset_password_confirm():
"""确认密码重置"""
data = request.json
data = request.json or {}
token = data.get("token", "").strip()
new_password = data.get("new_password", "").strip()
@@ -292,9 +306,9 @@ def reset_password_confirm():
@api_auth_bp.route("/api/reset_password_request", methods=["POST"])
def request_password_reset():
"""用户申请重置密码(需要审核)"""
data = request.json
data = request.json or {}
username = data.get("username", "").strip()
email = data.get("email", "").strip()
email = data.get("email", "").strip().lower()
new_password = data.get("new_password", "").strip()
if not username or not new_password:
@@ -304,6 +318,11 @@ def request_password_reset():
if not is_valid:
return jsonify({"error": error_msg}), 400
if email:
is_valid, error_msg = validate_email(email)
if not is_valid:
return jsonify({"error": error_msg}), 400
user = database.get_user_by_username(username)
if user:
@@ -389,25 +408,36 @@ def generate_captcha():
@require_ip_not_locked
def login():
"""用户登录"""
data = request.json
data = request.json or {}
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:
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "login")
if not allowed:
return jsonify({"error": error_msg, "need_captcha": True}), 429
captcha_required = check_login_captcha_required(client_ip) or bool(need_captcha)
if captcha_required:
if not captcha_session or not captcha_code:
return jsonify({"error": "请填写验证码", "need_captcha": True}), 400
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success:
return jsonify({"error": message}), 400
record_login_failure(client_ip)
return jsonify({"error": message, "need_captcha": True}), 400
user = database.verify_user(username, password)
if not user:
return jsonify({"error": "用户名或密码错误", "need_captcha": True}), 401
record_login_failure(client_ip)
return jsonify({"error": "用户名或密码错误", "need_captcha": check_login_captcha_required(client_ip)}), 401
if user["status"] != "approved":
return jsonify({"error": "账号未审核,请等待管理员审核", "need_captcha": False}), 401
clear_login_failures(client_ip)
user_obj = User(user["id"])
login_user(user_obj)
load_user_accounts(user["id"])

View File

@@ -5,11 +5,11 @@ 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, validate_password
from app_security import get_rate_limit_ip, require_ip_not_locked, validate_email, validate_password
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
from services.state import check_ip_request_rate, safe_iter_task_status_items
logger = get_logger("app")
@@ -152,12 +152,21 @@ def get_user_email():
@require_ip_not_locked
def bind_user_email():
"""发送邮箱绑定验证邮件"""
data = request.get_json()
data = request.get_json() or {}
email = data.get("email", "").strip().lower()
if not email or not validate_email(email):
if not email:
return jsonify({"error": "请输入有效的邮箱地址"}), 400
is_valid, error_msg = validate_email(email)
if not is_valid:
return jsonify({"error": error_msg}), 400
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "email")
if not allowed:
return jsonify({"error": error_msg}), 429
settings = email_service.get_email_settings()
if not settings.get("enabled", False):
return jsonify({"error": "邮件功能未启用,请联系管理员"}), 400

View File

@@ -9,6 +9,7 @@ from typing import Optional
import requests
from app_logger import get_logger
from app_security import is_safe_outbound_url
logger = get_logger("proxy")
@@ -34,6 +35,10 @@ def get_proxy_from_api(api_url: str, max_retries: int = 3) -> Optional[str]:
ip_port_pattern = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{1,5}$")
max_retries = max(1, int(max_retries or 1))
if not is_safe_outbound_url(api_url):
logger.warning("代理API地址不可用或不安全已拒绝请求")
return None
for attempt in range(max_retries):
try:
response = requests.get(api_url, timeout=10)
@@ -74,4 +79,3 @@ def get_proxy_from_api(api_url: str, max_retries: int = 3) -> Optional[str]:
logger.warning(f"获取代理失败,已重试 {max_retries} 次,将不使用代理继续")
return None

View File

@@ -362,6 +362,111 @@ def safe_get_ip_lock_until(ip_address: str) -> float:
return 0.0
# ==================== IP request rate limit接口频率限制 ====================
_ip_request_rate: Dict[str, Dict[str, Any]] = {}
_ip_request_rate_lock = threading.RLock()
def _get_action_rate_limit(action: str) -> Tuple[int, int]:
action = str(action or "").lower()
if action == "register":
return int(config.IP_RATE_LIMIT_REGISTER_MAX), int(config.IP_RATE_LIMIT_REGISTER_WINDOW_SECONDS)
if action == "email":
return int(config.IP_RATE_LIMIT_EMAIL_MAX), int(config.IP_RATE_LIMIT_EMAIL_WINDOW_SECONDS)
return int(config.IP_RATE_LIMIT_LOGIN_MAX), int(config.IP_RATE_LIMIT_LOGIN_WINDOW_SECONDS)
def check_ip_request_rate(
ip_address: str,
action: str,
*,
max_requests: Optional[int] = None,
window_seconds: Optional[int] = None,
) -> Tuple[bool, Optional[str]]:
now_ts = time.time()
default_max, default_window = _get_action_rate_limit(action)
max_requests = int(max_requests or default_max)
window_seconds = int(window_seconds or default_window)
key = f"{action}:{ip_address}"
with _ip_request_rate_lock:
data = _ip_request_rate.get(key)
if not data or (now_ts - float(data.get("window_start", 0) or 0)) >= window_seconds:
data = {"window_start": now_ts, "count": 0}
_ip_request_rate[key] = data
if int(data.get("count", 0) or 0) >= max_requests:
remaining = max(1, int(window_seconds - (now_ts - float(data.get("window_start", 0) or 0))))
if remaining >= 60:
wait_hint = f"{remaining // 60 + 1}分钟"
else:
wait_hint = f"{remaining}"
return False, f"请求过于频繁,请{wait_hint}后再试"
data["count"] = int(data.get("count", 0) or 0) + 1
return True, None
def cleanup_expired_ip_request_rates(now_ts: Optional[float] = None) -> int:
now_ts = float(now_ts if now_ts is not None else time.time())
removed = 0
with _ip_request_rate_lock:
for key in list(_ip_request_rate.keys()):
data = _ip_request_rate.get(key) or {}
action = key.split(":", 1)[0]
_, window_seconds = _get_action_rate_limit(action)
window_start = float(data.get("window_start", 0) or 0)
if now_ts - window_start >= window_seconds:
_ip_request_rate.pop(key, None)
removed += 1
return removed
# ==================== 登录失败追踪(触发验证码) ====================
_login_failures: Dict[str, Dict[str, Any]] = {}
_login_failures_lock = threading.RLock()
def _get_login_captcha_config() -> Tuple[int, int]:
return int(config.LOGIN_CAPTCHA_AFTER_FAILURES), int(config.LOGIN_CAPTCHA_WINDOW_SECONDS)
def record_login_failure(ip_address: str) -> None:
now_ts = time.time()
max_failures, window_seconds = _get_login_captcha_config()
ip_key = str(ip_address or "")
with _login_failures_lock:
data = _login_failures.get(ip_key)
if not data or (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds:
data = {"first_failed": now_ts, "count": 0}
_login_failures[ip_key] = data
data["count"] = int(data.get("count", 0) or 0) + 1
if int(data["count"]) > max_failures * 5:
data["count"] = max_failures * 5
def clear_login_failures(ip_address: str) -> None:
ip_key = str(ip_address or "")
with _login_failures_lock:
_login_failures.pop(ip_key, None)
def check_login_captcha_required(ip_address: str) -> bool:
now_ts = time.time()
max_failures, window_seconds = _get_login_captcha_config()
ip_key = str(ip_address or "")
with _login_failures_lock:
data = _login_failures.get(ip_key)
if not data:
return False
if (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds:
_login_failures.pop(ip_key, None)
return False
return int(data.get("count", 0) or 0) >= max_failures
# ==================== Batch screenshots批次任务截图收集 ====================
_batch_task_screenshots: Dict[str, Dict[str, Any]] = {}

View File

@@ -1,34 +1,34 @@
{
"_email-DkWacopQ.js": {
"file": "assets/email-DkWacopQ.js",
"_email-BfqhxXOq.js": {
"file": "assets/email-BfqhxXOq.js",
"name": "email",
"imports": [
"index.html"
]
},
"_tasks-DQdWvww2.js": {
"file": "assets/tasks-DQdWvww2.js",
"_tasks-BtWKY-g7.js": {
"file": "assets/tasks-BtWKY-g7.js",
"name": "tasks",
"imports": [
"index.html"
]
},
"_update-BqIWfEYV.js": {
"file": "assets/update-BqIWfEYV.js",
"_update-BrAMPxiF.js": {
"file": "assets/update-BrAMPxiF.js",
"name": "update",
"imports": [
"index.html"
]
},
"_users-CPJP5r-B.js": {
"file": "assets/users-CPJP5r-B.js",
"_users-CToznuvL.js": {
"file": "assets/users-CToznuvL.js",
"name": "users",
"imports": [
"index.html"
]
},
"index.html": {
"file": "assets/index-B57Le1Kd.js",
"file": "assets/index-Da0EvMWc.js",
"name": "index",
"src": "index.html",
"isEntry": true,
@@ -43,11 +43,11 @@
"src/pages/SettingsPage.vue"
],
"css": [
"assets/index-CBVc8utT.css"
"assets/index-EWm4DZW8.css"
]
},
"src/pages/AnnouncementsPage.vue": {
"file": "assets/AnnouncementsPage-hvTOy02a.js",
"file": "assets/AnnouncementsPage-CbLi3NFK.js",
"name": "AnnouncementsPage",
"src": "src/pages/AnnouncementsPage.vue",
"isDynamicEntry": true,
@@ -59,20 +59,20 @@
]
},
"src/pages/EmailPage.vue": {
"file": "assets/EmailPage-Be5xmQro.js",
"file": "assets/EmailPage-CaUZghxJ.js",
"name": "EmailPage",
"src": "src/pages/EmailPage.vue",
"isDynamicEntry": true,
"imports": [
"_email-DkWacopQ.js",
"_email-BfqhxXOq.js",
"index.html"
],
"css": [
"assets/EmailPage-CnYKqgXc.css"
"assets/EmailPage-DD73oBux.css"
]
},
"src/pages/FeedbacksPage.vue": {
"file": "assets/FeedbacksPage-BsAKLoZh.js",
"file": "assets/FeedbacksPage-DCz_21CH.js",
"name": "FeedbacksPage",
"src": "src/pages/FeedbacksPage.vue",
"isDynamicEntry": true,
@@ -84,13 +84,13 @@
]
},
"src/pages/LogsPage.vue": {
"file": "assets/LogsPage-BO90cLUJ.js",
"file": "assets/LogsPage-k6AvTEc_.js",
"name": "LogsPage",
"src": "src/pages/LogsPage.vue",
"isDynamicEntry": true,
"imports": [
"_users-CPJP5r-B.js",
"_tasks-DQdWvww2.js",
"_users-CToznuvL.js",
"_tasks-BtWKY-g7.js",
"index.html"
],
"css": [
@@ -98,22 +98,22 @@
]
},
"src/pages/ReportPage.vue": {
"file": "assets/ReportPage-DAANCrfo.js",
"file": "assets/ReportPage-BkB6FuHA.js",
"name": "ReportPage",
"src": "src/pages/ReportPage.vue",
"isDynamicEntry": true,
"imports": [
"index.html",
"_email-DkWacopQ.js",
"_tasks-DQdWvww2.js",
"_update-BqIWfEYV.js"
"_email-BfqhxXOq.js",
"_tasks-BtWKY-g7.js",
"_update-BrAMPxiF.js"
],
"css": [
"assets/ReportPage-TpqQWWvU.css"
]
},
"src/pages/SettingsPage.vue": {
"file": "assets/SettingsPage-t6m2MHUb.js",
"file": "assets/SettingsPage-CeJoz6yA.js",
"name": "SettingsPage",
"src": "src/pages/SettingsPage.vue",
"isDynamicEntry": true,
@@ -125,12 +125,12 @@
]
},
"src/pages/SystemPage.vue": {
"file": "assets/SystemPage-D69-93Jh.js",
"file": "assets/SystemPage-Dmtz_emI.js",
"name": "SystemPage",
"src": "src/pages/SystemPage.vue",
"isDynamicEntry": true,
"imports": [
"_update-BqIWfEYV.js",
"_update-BrAMPxiF.js",
"index.html"
],
"css": [
@@ -138,12 +138,12 @@
]
},
"src/pages/UsersPage.vue": {
"file": "assets/UsersPage-DMRA-wLy.js",
"file": "assets/UsersPage-JTbL8-nm.js",
"name": "UsersPage",
"src": "src/pages/UsersPage.vue",
"isDynamicEntry": true,
"imports": [
"_users-CPJP5r-B.js",
"_users-CToznuvL.js",
"index.html"
],
"css": [

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.page-stack[data-v-988a6b4f]{display:flex;flex-direction:column;gap:12px}.toolbar[data-v-988a6b4f]{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.card[data-v-988a6b4f]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-head[data-v-988a6b4f]{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.section-title[data-v-988a6b4f]{margin:0;font-size:14px;font-weight:800}.help[data-v-988a6b4f]{margin-top:8px;font-size:12px;color:var(--app-muted)}.table-wrap[data-v-988a6b4f]{overflow-x:auto}.stat-card[data-v-988a6b4f]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-value[data-v-988a6b4f]{font-size:20px;font-weight:900;line-height:1.1}.stat-label[data-v-988a6b4f]{margin-top:6px;font-size:12px;color:var(--app-muted)}.ok[data-v-988a6b4f]{color:#047857}.err[data-v-988a6b4f]{color:#b91c1c}.sub-stats[data-v-988a6b4f]{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.ellipsis[data-v-988a6b4f]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pagination[data-v-988a6b4f]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;flex-wrap:wrap}.page-hint[data-v-988a6b4f]{font-size:12px}.dialog-actions[data-v-988a6b4f]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.spacer[data-v-988a6b4f]{flex:1}

View File

@@ -0,0 +1 @@
.page-stack[data-v-03fa4932]{display:flex;flex-direction:column;gap:12px}.toolbar[data-v-03fa4932]{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.card[data-v-03fa4932]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-head[data-v-03fa4932]{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.section-title[data-v-03fa4932]{margin:0;font-size:14px;font-weight:800}.help[data-v-03fa4932]{margin-top:8px;font-size:12px;color:var(--app-muted)}.table-wrap[data-v-03fa4932]{overflow-x:auto}.stat-card[data-v-03fa4932]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-value[data-v-03fa4932]{font-size:20px;font-weight:900;line-height:1.1}.stat-label[data-v-03fa4932]{margin-top:6px;font-size:12px;color:var(--app-muted)}.ok[data-v-03fa4932]{color:#047857}.err[data-v-03fa4932]{color:#b91c1c}.sub-stats[data-v-03fa4932]{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.ellipsis[data-v-03fa4932]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pagination[data-v-03fa4932]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;flex-wrap:wrap}.page-hint[data-v-03fa4932]{font-size:12px}.dialog-actions[data-v-03fa4932]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.spacer[data-v-03fa4932]{flex:1}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{S as m,_ as T,r as p,e as h,f as r,g as a,w as s,n as u,x as k,y as x,L as i,K as b}from"./index-B57Le1Kd.js";async function C(o){const{data:t}=await m.put("/admin/username",{new_username:o});return t}async function P(o){const{data:t}=await m.put("/admin/password",{new_password:o});return t}async function U(){const{data:o}=await m.post("/logout");return o}const E={class:"page-stack"},N={__name:"SettingsPage",setup(o){const t=p(""),d=p(""),n=p(!1);async function f(){try{await U()}catch{}finally{window.location.href="/yuyx"}}async function V(){const l=t.value.trim();if(!l){i.error("请输入新用户名");return}try{await b.confirm(`确定将管理员用户名修改为「${l}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await C(l),i.success("用户名修改成功,请重新登录"),t.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}async function B(){const l=d.value;if(!l){i.error("请输入新密码");return}if(l.length<6){i.error("密码至少6个字符");return}try{await b.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await P(l),i.success("密码修改成功,请重新登录"),d.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}return(l,e)=>{const w=u("el-input"),y=u("el-form-item"),v=u("el-form"),_=u("el-button"),g=u("el-card");return k(),h("div",E,[e[7]||(e[7]=r("div",{class:"app-page-title"},[r("h2",null,"设置"),r("span",{class:"app-muted"},"管理员账号设置")],-1)),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[3]||(e[3]=r("h3",{class:"section-title"},"修改管理员用户名",-1)),a(v,{"label-width":"120px"},{default:s(()=>[a(y,{label:"新用户名"},{default:s(()=>[a(w,{modelValue:t.value,"onUpdate:modelValue":e[0]||(e[0]=c=>t.value=c),placeholder:"输入新用户名",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:V},{default:s(()=>[...e[2]||(e[2]=[x("保存用户名",-1)])]),_:1},8,["loading"])]),_:1}),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[5]||(e[5]=r("h3",{class:"section-title"},"修改管理员密码",-1)),a(v,{"label-width":"120px"},{default:s(()=>[a(y,{label:"新密码"},{default:s(()=>[a(w,{modelValue:d.value,"onUpdate:modelValue":e[1]||(e[1]=c=>d.value=c),type:"password","show-password":"",placeholder:"输入新密码",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:B},{default:s(()=>[...e[4]||(e[4]=[x("保存密码",-1)])]),_:1},8,["loading"]),e[6]||(e[6]=r("div",{class:"help"},"建议使用更强密码至少8位且包含字母与数字。",-1))]),_:1})])}}},A=T(N,[["__scopeId","data-v-2f4b840f"]]);export{A as default};
import{S as m,_ as T,r as p,e as h,f as r,g as a,w as s,n as u,x as k,y as x,L as i,K as b}from"./index-Da0EvMWc.js";async function C(o){const{data:t}=await m.put("/admin/username",{new_username:o});return t}async function P(o){const{data:t}=await m.put("/admin/password",{new_password:o});return t}async function U(){const{data:o}=await m.post("/logout");return o}const E={class:"page-stack"},N={__name:"SettingsPage",setup(o){const t=p(""),d=p(""),n=p(!1);async function f(){try{await U()}catch{}finally{window.location.href="/yuyx"}}async function V(){const l=t.value.trim();if(!l){i.error("请输入新用户名");return}try{await b.confirm(`确定将管理员用户名修改为「${l}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await C(l),i.success("用户名修改成功,请重新登录"),t.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}async function B(){const l=d.value;if(!l){i.error("请输入新密码");return}if(l.length<6){i.error("密码至少6个字符");return}try{await b.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await P(l),i.success("密码修改成功,请重新登录"),d.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}return(l,e)=>{const w=u("el-input"),y=u("el-form-item"),v=u("el-form"),_=u("el-button"),g=u("el-card");return k(),h("div",E,[e[7]||(e[7]=r("div",{class:"app-page-title"},[r("h2",null,"设置"),r("span",{class:"app-muted"},"管理员账号设置")],-1)),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[3]||(e[3]=r("h3",{class:"section-title"},"修改管理员用户名",-1)),a(v,{"label-width":"120px"},{default:s(()=>[a(y,{label:"新用户名"},{default:s(()=>[a(w,{modelValue:t.value,"onUpdate:modelValue":e[0]||(e[0]=c=>t.value=c),placeholder:"输入新用户名",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:V},{default:s(()=>[...e[2]||(e[2]=[x("保存用户名",-1)])]),_:1},8,["loading"])]),_:1}),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[5]||(e[5]=r("h3",{class:"section-title"},"修改管理员密码",-1)),a(v,{"label-width":"120px"},{default:s(()=>[a(y,{label:"新密码"},{default:s(()=>[a(w,{modelValue:d.value,"onUpdate:modelValue":e[1]||(e[1]=c=>d.value=c),type:"password","show-password":"",placeholder:"输入新密码",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:B},{default:s(()=>[...e[4]||(e[4]=[x("保存密码",-1)])]),_:1},8,["loading"]),e[6]||(e[6]=r("div",{class:"help"},"建议使用更强密码至少8位且包含字母与数字。",-1))]),_:1})])}}},A=T(N,[["__scopeId","data-v-2f4b840f"]]);export{A as default};

View File

@@ -1,4 +1,4 @@
import{f as Ue,a as Pe,b as Ce,c as he,u as Q,e as Be,r as Te,d as Ie}from"./update-BqIWfEYV.js";import{S as X,_ as Ne,r,c as Se,o as Ae,U as Le,J as se,I as $e,e as C,f as i,g as l,w as t,n as m,h as v,x as u,y as n,j as x,F as je,p as Ee,m as y,K as L,L as b}from"./index-B57Le1Kd.js";async function Re(){const{data:h}=await X.get("/proxy/config");return h}async function De(h){const{data:w}=await X.post("/proxy/config",h);return w}async function Fe(h){const{data:w}=await X.post("/proxy/test",h);return w}const He={class:"page-stack"},Me={class:"app-page-title"},ze={class:"row-actions"},qe={class:"row-actions"},Oe={class:"row-actions",style:{"align-items":"center"}},We={class:"row-actions"},Ge={key:0},Je={key:1},Ke={key:2},Qe={key:3,class:"help"},Xe={key:4,class:"help"},Ye={__name:"SystemPage",setup(h){const w=r(!1),$=r(2),j=r(1),E=r(3),g=r(!1),R=r("02:00"),B=r("应读"),k=r(["1","2","3","4","5","6","7"]),T=r(!1),V=r(""),D=r(3),F=r(!1),H=r(10),M=r(7),G=r(!1),U=r(!1),_=r(null),z=r(""),d=r(null),q=r(""),J=r(!1),O=r(!1);let I=null;const re=[{label:"周一",value:"1"},{label:"周二",value:"2"},{label:"周三",value:"3"},{label:"周四",value:"4"},{label:"周五",value:"5"},{label:"周六",value:"6"},{label:"周日",value:"7"}],de={1:"周一",2:"周二",3:"周三",4:"周四",5:"周五",6:"周六",7:"周日"},ie=Se(()=>(k.value||[]).map(a=>de[Number(a)]||a).join("、"));function me(a){return String(a)==="注册前未读"?"注册前未读":"应读"}function N(a){const e=String(a||"").trim();return e?e.length>12?`${e.slice(0,12)}`:e:"-"}async function S({withLog:a=!0}={}){G.value=!0,z.value="";try{const[e,s]=await Promise.all([Pe(),Ce()]);e?.ok?_.value=e.data||null:(_.value=null,z.value=e?.error||"未发现更新状态Update-Agent 可能未运行)"),d.value=s?.ok?s.data:null;const f=d.value?.job_id;if(a&&f){const p=await he({job_id:f,max_bytes:2e5});q.value=p?.log||"",J.value=!!p?.truncated}else q.value="",J.value=!1}catch{}finally{G.value=!1}}function Y(){I||(I=setInterval(async()=>{d.value?.status==="running"&&await S()},5e3))}function pe(){I&&(clearInterval(I),I=null)}async function Z(){w.value=!0;try{const[a,e]=await Promise.all([Ue(),Re()]);$.value=a.max_concurrent_global??2,j.value=a.max_concurrent_per_account??1,E.value=a.max_screenshot_concurrent??3,g.value=(a.schedule_enabled??0)===1,R.value=a.schedule_time||"02:00",B.value=me(a.schedule_browse_type);const s=String(a.schedule_weekdays||"1,2,3,4,5,6,7").split(",").map(f=>f.trim()).filter(Boolean);k.value=s.length?s:["1","2","3","4","5","6","7"],F.value=(a.auto_approve_enabled??0)===1,H.value=a.auto_approve_hourly_limit??10,M.value=a.auto_approve_vip_days??7,T.value=(e.proxy_enabled??0)===1,V.value=e.proxy_api_url||"",D.value=e.proxy_expire_minutes??3,await S({withLog:!1}),Y()}catch{}finally{w.value=!1}}async function ce(){const a={max_concurrent_global:Number($.value),max_concurrent_per_account:Number(j.value),max_screenshot_concurrent:Number(E.value)};try{await L.confirm(`确定更新并发配置吗?
import{f as Ue,a as Pe,b as Ce,c as he,u as Q,e as Be,r as Te,d as Ie}from"./update-BrAMPxiF.js";import{S as X,_ as Ne,r,c as Se,o as Ae,U as Le,J as se,I as $e,e as C,f as i,g as l,w as t,n as m,h as v,x as u,y as n,j as x,F as je,p as Ee,m as y,K as L,L as b}from"./index-Da0EvMWc.js";async function Re(){const{data:h}=await X.get("/proxy/config");return h}async function De(h){const{data:w}=await X.post("/proxy/config",h);return w}async function Fe(h){const{data:w}=await X.post("/proxy/test",h);return w}const He={class:"page-stack"},Me={class:"app-page-title"},ze={class:"row-actions"},qe={class:"row-actions"},Oe={class:"row-actions",style:{"align-items":"center"}},We={class:"row-actions"},Ge={key:0},Je={key:1},Ke={key:2},Qe={key:3,class:"help"},Xe={key:4,class:"help"},Ye={__name:"SystemPage",setup(h){const w=r(!1),$=r(2),j=r(1),E=r(3),g=r(!1),R=r("02:00"),B=r("应读"),k=r(["1","2","3","4","5","6","7"]),T=r(!1),V=r(""),D=r(3),F=r(!1),H=r(10),M=r(7),G=r(!1),U=r(!1),_=r(null),z=r(""),d=r(null),q=r(""),J=r(!1),O=r(!1);let I=null;const re=[{label:"周一",value:"1"},{label:"周二",value:"2"},{label:"周三",value:"3"},{label:"周四",value:"4"},{label:"周五",value:"5"},{label:"周六",value:"6"},{label:"周日",value:"7"}],de={1:"周一",2:"周二",3:"周三",4:"周四",5:"周五",6:"周六",7:"周日"},ie=Se(()=>(k.value||[]).map(a=>de[Number(a)]||a).join("、"));function me(a){return String(a)==="注册前未读"?"注册前未读":"应读"}function N(a){const e=String(a||"").trim();return e?e.length>12?`${e.slice(0,12)}`:e:"-"}async function S({withLog:a=!0}={}){G.value=!0,z.value="";try{const[e,s]=await Promise.all([Pe(),Ce()]);e?.ok?_.value=e.data||null:(_.value=null,z.value=e?.error||"未发现更新状态Update-Agent 可能未运行)"),d.value=s?.ok?s.data:null;const f=d.value?.job_id;if(a&&f){const p=await he({job_id:f,max_bytes:2e5});q.value=p?.log||"",J.value=!!p?.truncated}else q.value="",J.value=!1}catch{}finally{G.value=!1}}function Y(){I||(I=setInterval(async()=>{d.value?.status==="running"&&await S()},5e3))}function pe(){I&&(clearInterval(I),I=null)}async function Z(){w.value=!0;try{const[a,e]=await Promise.all([Ue(),Re()]);$.value=a.max_concurrent_global??2,j.value=a.max_concurrent_per_account??1,E.value=a.max_screenshot_concurrent??3,g.value=(a.schedule_enabled??0)===1,R.value=a.schedule_time||"02:00",B.value=me(a.schedule_browse_type);const s=String(a.schedule_weekdays||"1,2,3,4,5,6,7").split(",").map(f=>f.trim()).filter(Boolean);k.value=s.length?s:["1","2","3","4","5","6","7"],F.value=(a.auto_approve_enabled??0)===1,H.value=a.auto_approve_hourly_limit??10,M.value=a.auto_approve_vip_days??7,T.value=(e.proxy_enabled??0)===1,V.value=e.proxy_api_url||"",D.value=e.proxy_expire_minutes??3,await S({withLog:!1}),Y()}catch{}finally{w.value=!1}}async function ce(){const a={max_concurrent_global:Number($.value),max_concurrent_per_account:Number(j.value),max_screenshot_concurrent:Number(E.value)};try{await L.confirm(`确定更新并发配置吗?
全局并发数: ${a.max_concurrent_global}
单账号并发数: ${a.max_concurrent_per_account}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{S as n}from"./index-B57Le1Kd.js";async function i(){const{data:a}=await n.get("/email/settings");return a}async function e(a){const{data:t}=await n.post("/email/settings",a);return t}async function c(){const{data:a}=await n.get("/email/stats");return a}async function o(a){const{data:t}=await n.get("/email/logs",{params:a});return t}async function l(a){const{data:t}=await n.post("/email/logs/cleanup",{days:a});return t}export{i as a,o as b,l as c,c as f,e as u};
import{S as n}from"./index-Da0EvMWc.js";async function i(){const{data:a}=await n.get("/email/settings");return a}async function e(a){const{data:t}=await n.post("/email/settings",a);return t}async function c(){const{data:a}=await n.get("/email/stats");return a}async function o(a){const{data:t}=await n.get("/email/logs",{params:a});return t}async function l(a){const{data:t}=await n.post("/email/logs/cleanup",{days:a});return t}export{i as a,o as b,l as c,c as f,e as u};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{S as a}from"./index-B57Le1Kd.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{r as a,c as b,e as c,i as d,f as e,o as f};
import{S as a}from"./index-Da0EvMWc.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{r as a,c as b,e as c,i as d,f as e,o as f};

View File

@@ -1 +1 @@
import{S as a}from"./index-B57Le1Kd.js";async function s(){const{data:t}=await a.get("/system/config");return t}async function c(t){const{data:e}=await a.post("/system/config",t);return e}async function u(){const{data:t}=await a.post("/schedule/execute",{});return t}async function o(){const{data:t}=await a.get("/update/status");return t}async function r(){const{data:t}=await a.get("/update/result");return t}async function d(t={}){const{data:e}=await a.get("/update/log",{params:t});return e}async function i(){const{data:t}=await a.post("/update/check",{});return t}async function f(t={}){const{data:e}=await a.post("/update/run",t);return e}export{o as a,r as b,d as c,f as d,u as e,s as f,i as r,c as u};
import{S as a}from"./index-Da0EvMWc.js";async function s(){const{data:t}=await a.get("/system/config");return t}async function c(t){const{data:e}=await a.post("/system/config",t);return e}async function u(){const{data:t}=await a.post("/schedule/execute",{});return t}async function o(){const{data:t}=await a.get("/update/status");return t}async function r(){const{data:t}=await a.get("/update/result");return t}async function d(t={}){const{data:e}=await a.get("/update/log",{params:t});return e}async function i(){const{data:t}=await a.post("/update/check",{});return t}async function f(t={}){const{data:e}=await a.post("/update/run",t);return e}export{o as a,r as b,d as c,f as d,u as e,s as f,i as r,c as u};

View File

@@ -1 +1 @@
import{S as t}from"./index-B57Le1Kd.js";async function n(){const{data:s}=await t.get("/users");return s}async function o(s){const{data:a}=await t.post(`/users/${s}/approve`);return a}async function c(s){const{data:a}=await t.post(`/users/${s}/reject`);return a}async function i(s){const{data:a}=await t.delete(`/users/${s}`);return a}async function u(s,a){const{data:e}=await t.post(`/users/${s}/vip`,{days:a});return e}async function p(s){const{data:a}=await t.delete(`/users/${s}/vip`);return a}async function d(s,a){const{data:e}=await t.post(`/users/${s}/reset_password`,{new_password:a});return e}export{o as a,p as b,d as c,i as d,n as f,c as r,u as s};
import{S as t}from"./index-Da0EvMWc.js";async function n(){const{data:s}=await t.get("/users");return s}async function o(s){const{data:a}=await t.post(`/users/${s}/approve`);return a}async function c(s){const{data:a}=await t.post(`/users/${s}/reject`);return a}async function i(s){const{data:a}=await t.delete(`/users/${s}`);return a}async function u(s,a){const{data:e}=await t.post(`/users/${s}/vip`,{days:a});return e}async function p(s){const{data:a}=await t.delete(`/users/${s}/vip`);return a}async function d(s,a){const{data:e}=await t.post(`/users/${s}/reset_password`,{new_password:a});return e}export{o as a,p as b,d as c,i as d,n as f,c as r,u as s};

View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>后台管理 - 知识管理平台</title>
<script type="module" crossorigin src="./assets/index-B57Le1Kd.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CBVc8utT.css">
<script type="module" crossorigin src="./assets/index-Da0EvMWc.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-EWm4DZW8.css">
</head>
<body>
<div id="app"></div>

View File

@@ -1,13 +1,13 @@
{
"_accounts-BFaVMUve.js": {
"file": "assets/accounts-BFaVMUve.js",
"_accounts-BXD0We06.js": {
"file": "assets/accounts-BXD0We06.js",
"name": "accounts",
"imports": [
"index.html"
]
},
"_auth-cku83FEM.js": {
"file": "assets/auth-cku83FEM.js",
"_auth-cf7b3Gq2.js": {
"file": "assets/auth-cf7b3Gq2.js",
"name": "auth",
"imports": [
"index.html"
@@ -18,7 +18,7 @@
"name": "password"
},
"index.html": {
"file": "assets/index-DSLtIIj4.js",
"file": "assets/index-DhsLPY8p.js",
"name": "index",
"src": "index.html",
"isEntry": true,
@@ -36,12 +36,12 @@
]
},
"src/pages/AccountsPage.vue": {
"file": "assets/AccountsPage-C48gJL8c.js",
"file": "assets/AccountsPage-38dq1Ex4.js",
"name": "AccountsPage",
"src": "src/pages/AccountsPage.vue",
"isDynamicEntry": true,
"imports": [
"_accounts-BFaVMUve.js",
"_accounts-BXD0We06.js",
"index.html"
],
"css": [
@@ -49,13 +49,13 @@
]
},
"src/pages/LoginPage.vue": {
"file": "assets/LoginPage-CbeuGOZL.js",
"file": "assets/LoginPage-B_fgHOTT.js",
"name": "LoginPage",
"src": "src/pages/LoginPage.vue",
"isDynamicEntry": true,
"imports": [
"index.html",
"_auth-cku83FEM.js",
"_auth-cf7b3Gq2.js",
"_password-7ryi82gE.js"
],
"css": [
@@ -63,26 +63,26 @@
]
},
"src/pages/RegisterPage.vue": {
"file": "assets/RegisterPage-B4t-njqd.js",
"file": "assets/RegisterPage-B_Z92PVI.js",
"name": "RegisterPage",
"src": "src/pages/RegisterPage.vue",
"isDynamicEntry": true,
"imports": [
"index.html",
"_auth-cku83FEM.js"
"_auth-cf7b3Gq2.js"
],
"css": [
"assets/RegisterPage-yylt2w7b.css"
]
},
"src/pages/ResetPasswordPage.vue": {
"file": "assets/ResetPasswordPage-Tuid_XBa.js",
"file": "assets/ResetPasswordPage-2f8v-5j9.js",
"name": "ResetPasswordPage",
"src": "src/pages/ResetPasswordPage.vue",
"isDynamicEntry": true,
"imports": [
"index.html",
"_auth-cku83FEM.js",
"_auth-cf7b3Gq2.js",
"_password-7ryi82gE.js"
],
"css": [
@@ -90,12 +90,12 @@
]
},
"src/pages/SchedulesPage.vue": {
"file": "assets/SchedulesPage-Dw-mXbG5.js",
"file": "assets/SchedulesPage-VLwHd9Sa.js",
"name": "SchedulesPage",
"src": "src/pages/SchedulesPage.vue",
"isDynamicEntry": true,
"imports": [
"_accounts-BFaVMUve.js",
"_accounts-BXD0We06.js",
"index.html"
],
"css": [
@@ -103,7 +103,7 @@
]
},
"src/pages/ScreenshotsPage.vue": {
"file": "assets/ScreenshotsPage-C6vX2U3V.js",
"file": "assets/ScreenshotsPage-Dtd_MXUX.js",
"name": "ScreenshotsPage",
"src": "src/pages/ScreenshotsPage.vue",
"isDynamicEntry": true,
@@ -115,7 +115,7 @@
]
},
"src/pages/VerifyResultPage.vue": {
"file": "assets/VerifyResultPage-BzGlCgtE.js",
"file": "assets/VerifyResultPage-8_v-5_kc.js",
"name": "VerifyResultPage",
"src": "src/pages/VerifyResultPage.vue",
"isDynamicEntry": true,

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{_ as M,r as j,a as p,c as B,o as A,b as S,d as t,w as o,e as m,u as H,f as g,g as n,h as U,i as x,j as N,t as q,k as E,E as d}from"./index-DSLtIIj4.js";import{g as z,f as F,c as G}from"./auth-cku83FEM.js";const J={class:"auth-wrap"},O={class:"hint app-muted"},Q={class:"captcha-row"},W=["src"],X={class:"actions"},Y={__name:"RegisterPage",setup(Z){const T=H(),a=j({username:"",password:"",confirm_password:"",email:"",captcha:""}),v=p(!1),f=p(""),h=p(""),b=p(!1),l=p(""),_=p(""),V=p(""),K=B(()=>v.value?"邮箱 *":"邮箱(可选)"),P=B(()=>v.value?"必填,用于账号验证":"选填,用于找回密码和接收通知");async function w(){try{const u=await z();h.value=u?.session_id||"",f.value=u?.captcha_image||"",a.captcha=""}catch{h.value="",f.value=""}}async function R(){try{const u=await F();v.value=!!u?.register_verify_enabled}catch{v.value=!1}}function D(){l.value="",_.value="",V.value=""}async function k(){D();const u=a.username.trim(),e=a.password,y=a.confirm_password,s=a.email.trim(),i=a.captcha.trim();if(u.length<3){l.value="用户名至少3个字符",d.error(l.value);return}if(e.length<6){l.value="密码至少6个字符",d.error(l.value);return}if(e!==y){l.value="两次输入的密码不一致",d.error(l.value);return}if(v.value&&!s){l.value="请填写邮箱地址用于账号验证",d.error(l.value);return}if(s&&!s.includes("@")){l.value="邮箱格式不正确",d.error(l.value);return}if(!i){l.value="请输入验证码",d.error(l.value);return}b.value=!0;try{const c=await G({username:u,password:e,email:s,captcha_session:h.value,captcha:i});_.value=c?.message||"注册成功",V.value=c?.need_verify?"请检查您的邮箱(包括垃圾邮件文件夹)":"",d.success("注册成功"),a.username="",a.password="",a.confirm_password="",a.email="",a.captcha="",setTimeout(()=>{window.location.href="/login"},3e3)}catch(c){const C=c?.response?.data;l.value=C?.error||"注册失败",d.error(l.value),await w()}finally{b.value=!1}}function I(){T.push("/login")}return A(async()=>{await w(),await R()}),(u,e)=>{const y=m("el-alert"),s=m("el-input"),i=m("el-form-item"),c=m("el-button"),C=m("el-form"),L=m("el-card");return g(),S("div",J,[t(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:o(()=>[e[11]||(e[11]=n("div",{class:"brand"},[n("div",{class:"brand-title"},"知识管理平台"),n("div",{class:"brand-sub app-muted"},"用户注册")],-1)),l.value?(g(),U(y,{key:0,type:"error",closable:!1,title:l.value,"show-icon":"",class:"alert"},null,8,["title"])):x("",!0),_.value?(g(),U(y,{key:1,type:"success",closable:!1,title:_.value,description:V.value,"show-icon":"",class:"alert"},null,8,["title","description"])):x("",!0),t(C,{"label-position":"top"},{default:o(()=>[t(i,{label:"用户名 *"},{default:o(()=>[t(s,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=r=>a.username=r),placeholder:"至少3个字符",autocomplete:"username"},null,8,["modelValue"]),e[5]||(e[5]=n("div",{class:"hint app-muted"},"至少3个字符",-1))]),_:1}),t(i,{label:"密码 *"},{default:o(()=>[t(s,{modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=r=>a.password=r),type:"password","show-password":"",placeholder:"至少6个字符",autocomplete:"new-password"},null,8,["modelValue"]),e[6]||(e[6]=n("div",{class:"hint app-muted"},"至少6个字符",-1))]),_:1}),t(i,{label:"确认密码 *"},{default:o(()=>[t(s,{modelValue:a.confirm_password,"onUpdate:modelValue":e[2]||(e[2]=r=>a.confirm_password=r),type:"password","show-password":"",placeholder:"请再次输入密码",autocomplete:"new-password",onKeyup:N(k,["enter"])},null,8,["modelValue"])]),_:1}),t(i,{label:K.value},{default:o(()=>[t(s,{modelValue:a.email,"onUpdate:modelValue":e[3]||(e[3]=r=>a.email=r),placeholder:"name@example.com",autocomplete:"email"},null,8,["modelValue"]),n("div",O,q(P.value),1)]),_:1},8,["label"]),t(i,{label:"验证码 *"},{default:o(()=>[n("div",Q,[t(s,{modelValue:a.captcha,"onUpdate:modelValue":e[4]||(e[4]=r=>a.captcha=r),placeholder:"请输入验证码",onKeyup:N(k,["enter"])},null,8,["modelValue"]),f.value?(g(),S("img",{key:0,class:"captcha-img",src:f.value,alt:"验证码",title:"点击刷新",onClick:w},null,8,W)):x("",!0),t(c,{onClick:w},{default:o(()=>[...e[7]||(e[7]=[E("刷新",-1)])]),_:1})])]),_:1})]),_:1}),t(c,{type:"primary",class:"submit-btn",loading:b.value,onClick:k},{default:o(()=>[...e[8]||(e[8]=[E("注册",-1)])]),_:1},8,["loading"]),n("div",X,[e[10]||(e[10]=n("span",{class:"app-muted"},"已有账号?",-1)),t(c,{link:"",type:"primary",onClick:I},{default:o(()=>[...e[9]||(e[9]=[E("立即登录",-1)])]),_:1})])]),_:1})])}}},ae=M(Y,[["__scopeId","data-v-75731a6d"]]);export{ae as default};
import{_ as M,r as j,a as p,c as B,o as A,b as S,d as t,w as o,e as m,u as H,f as g,g as n,h as U,i as x,j as N,t as q,k as E,E as d}from"./index-DhsLPY8p.js";import{g as z,f as F,c as G}from"./auth-cf7b3Gq2.js";const J={class:"auth-wrap"},O={class:"hint app-muted"},Q={class:"captcha-row"},W=["src"],X={class:"actions"},Y={__name:"RegisterPage",setup(Z){const T=H(),a=j({username:"",password:"",confirm_password:"",email:"",captcha:""}),v=p(!1),f=p(""),h=p(""),b=p(!1),l=p(""),_=p(""),V=p(""),K=B(()=>v.value?"邮箱 *":"邮箱(可选)"),P=B(()=>v.value?"必填,用于账号验证":"选填,用于找回密码和接收通知");async function w(){try{const u=await z();h.value=u?.session_id||"",f.value=u?.captcha_image||"",a.captcha=""}catch{h.value="",f.value=""}}async function R(){try{const u=await F();v.value=!!u?.register_verify_enabled}catch{v.value=!1}}function D(){l.value="",_.value="",V.value=""}async function k(){D();const u=a.username.trim(),e=a.password,y=a.confirm_password,s=a.email.trim(),i=a.captcha.trim();if(u.length<3){l.value="用户名至少3个字符",d.error(l.value);return}if(e.length<6){l.value="密码至少6个字符",d.error(l.value);return}if(e!==y){l.value="两次输入的密码不一致",d.error(l.value);return}if(v.value&&!s){l.value="请填写邮箱地址用于账号验证",d.error(l.value);return}if(s&&!s.includes("@")){l.value="邮箱格式不正确",d.error(l.value);return}if(!i){l.value="请输入验证码",d.error(l.value);return}b.value=!0;try{const c=await G({username:u,password:e,email:s,captcha_session:h.value,captcha:i});_.value=c?.message||"注册成功",V.value=c?.need_verify?"请检查您的邮箱(包括垃圾邮件文件夹)":"",d.success("注册成功"),a.username="",a.password="",a.confirm_password="",a.email="",a.captcha="",setTimeout(()=>{window.location.href="/login"},3e3)}catch(c){const C=c?.response?.data;l.value=C?.error||"注册失败",d.error(l.value),await w()}finally{b.value=!1}}function I(){T.push("/login")}return A(async()=>{await w(),await R()}),(u,e)=>{const y=m("el-alert"),s=m("el-input"),i=m("el-form-item"),c=m("el-button"),C=m("el-form"),L=m("el-card");return g(),S("div",J,[t(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:o(()=>[e[11]||(e[11]=n("div",{class:"brand"},[n("div",{class:"brand-title"},"知识管理平台"),n("div",{class:"brand-sub app-muted"},"用户注册")],-1)),l.value?(g(),U(y,{key:0,type:"error",closable:!1,title:l.value,"show-icon":"",class:"alert"},null,8,["title"])):x("",!0),_.value?(g(),U(y,{key:1,type:"success",closable:!1,title:_.value,description:V.value,"show-icon":"",class:"alert"},null,8,["title","description"])):x("",!0),t(C,{"label-position":"top"},{default:o(()=>[t(i,{label:"用户名 *"},{default:o(()=>[t(s,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=r=>a.username=r),placeholder:"至少3个字符",autocomplete:"username"},null,8,["modelValue"]),e[5]||(e[5]=n("div",{class:"hint app-muted"},"至少3个字符",-1))]),_:1}),t(i,{label:"密码 *"},{default:o(()=>[t(s,{modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=r=>a.password=r),type:"password","show-password":"",placeholder:"至少6个字符",autocomplete:"new-password"},null,8,["modelValue"]),e[6]||(e[6]=n("div",{class:"hint app-muted"},"至少6个字符",-1))]),_:1}),t(i,{label:"确认密码 *"},{default:o(()=>[t(s,{modelValue:a.confirm_password,"onUpdate:modelValue":e[2]||(e[2]=r=>a.confirm_password=r),type:"password","show-password":"",placeholder:"请再次输入密码",autocomplete:"new-password",onKeyup:N(k,["enter"])},null,8,["modelValue"])]),_:1}),t(i,{label:K.value},{default:o(()=>[t(s,{modelValue:a.email,"onUpdate:modelValue":e[3]||(e[3]=r=>a.email=r),placeholder:"name@example.com",autocomplete:"email"},null,8,["modelValue"]),n("div",O,q(P.value),1)]),_:1},8,["label"]),t(i,{label:"验证码 *"},{default:o(()=>[n("div",Q,[t(s,{modelValue:a.captcha,"onUpdate:modelValue":e[4]||(e[4]=r=>a.captcha=r),placeholder:"请输入验证码",onKeyup:N(k,["enter"])},null,8,["modelValue"]),f.value?(g(),S("img",{key:0,class:"captcha-img",src:f.value,alt:"验证码",title:"点击刷新",onClick:w},null,8,W)):x("",!0),t(c,{onClick:w},{default:o(()=>[...e[7]||(e[7]=[E("刷新",-1)])]),_:1})])]),_:1})]),_:1}),t(c,{type:"primary",class:"submit-btn",loading:b.value,onClick:k},{default:o(()=>[...e[8]||(e[8]=[E("注册",-1)])]),_:1},8,["loading"]),n("div",X,[e[10]||(e[10]=n("span",{class:"app-muted"},"已有账号?",-1)),t(c,{link:"",type:"primary",onClick:I},{default:o(()=>[...e[9]||(e[9]=[E("立即登录",-1)])]),_:1})])]),_:1})])}}},ae=M(Y,[["__scopeId","data-v-75731a6d"]]);export{ae as default};

View File

@@ -1 +1 @@
import{_ as L,a as n,l as M,r as U,c as j,o as F,m as K,b as v,d as s,w as a,e as l,u as D,f as m,g as w,F as T,k,h as q,i as x,j as z,t as G,E as y}from"./index-DSLtIIj4.js";import{d as H}from"./auth-cku83FEM.js";import{v as J}from"./password-7ryi82gE.js";const O={class:"auth-wrap"},Q={class:"actions"},W={class:"actions"},X={key:0,class:"app-muted"},Y={__name:"ResetPasswordPage",setup(Z){const B=M(),A=D(),r=n(String(B.params.token||"")),i=n(!0),b=n(""),t=U({newPassword:"",confirmPassword:""}),g=n(!1),f=n(""),d=n(0);let u=null;function C(){if(typeof window>"u")return null;const o=window.__APP_INITIAL_STATE__;return!o||typeof o!="object"?null:(window.__APP_INITIAL_STATE__=null,o)}const I=j(()=>!!(i.value&&r.value&&!f.value));function S(){A.push("/login")}function N(){d.value=3,u=window.setInterval(()=>{d.value-=1,d.value<=0&&(window.clearInterval(u),u=null,window.location.href="/login")},1e3)}async function V(){if(!I.value)return;const o=t.newPassword,e=t.confirmPassword,c=J(o);if(!c.ok){y.error(c.message);return}if(o!==e){y.error("两次输入的密码不一致");return}g.value=!0;try{await H({token:r.value,new_password:o}),f.value="密码重置成功3秒后跳转到登录页面...",y.success("密码重置成功"),N()}catch(p){const _=p?.response?.data;y.error(_?.error||"重置失败")}finally{g.value=!1}}return F(()=>{const o=C();o?.page==="reset_password"?(r.value=String(o?.token||r.value||""),i.value=!!o?.valid,b.value=o?.error_message||(i.value?"":"重置链接无效或已过期,请重新申请密码重置")):r.value||(i.value=!1,b.value="重置链接无效或已过期,请重新申请密码重置")}),K(()=>{u&&window.clearInterval(u)}),(o,e)=>{const c=l("el-alert"),p=l("el-button"),_=l("el-input"),h=l("el-form-item"),R=l("el-form"),E=l("el-card");return m(),v("div",O,[s(E,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:a(()=>[e[5]||(e[5]=w("div",{class:"brand"},[w("div",{class:"brand-title"},"知识管理平台"),w("div",{class:"brand-sub app-muted"},"重置密码")],-1)),i.value?(m(),v(T,{key:1},[f.value?(m(),q(c,{key:0,type:"success",closable:!1,title:"重置成功",description:f.value,"show-icon":"",class:"alert"},null,8,["description"])):x("",!0),s(R,{"label-position":"top"},{default:a(()=>[s(h,{label:"新密码至少8位且包含字母和数字"},{default:a(()=>[s(_,{modelValue:t.newPassword,"onUpdate:modelValue":e[0]||(e[0]=P=>t.newPassword=P),type:"password","show-password":"",placeholder:"请输入新密码",autocomplete:"new-password"},null,8,["modelValue"])]),_:1}),s(h,{label:"确认密码"},{default:a(()=>[s(_,{modelValue:t.confirmPassword,"onUpdate:modelValue":e[1]||(e[1]=P=>t.confirmPassword=P),type:"password","show-password":"",placeholder:"请再次输入新密码",autocomplete:"new-password",onKeyup:z(V,["enter"])},null,8,["modelValue"])]),_:1})]),_:1}),s(p,{type:"primary",class:"submit-btn",loading:g.value,disabled:!I.value,onClick:V},{default:a(()=>[...e[3]||(e[3]=[k(" 确认重置 ",-1)])]),_:1},8,["loading","disabled"]),w("div",W,[s(p,{link:"",type:"primary",onClick:S},{default:a(()=>[...e[4]||(e[4]=[k("返回登录",-1)])]),_:1}),d.value>0?(m(),v("span",X,G(d.value)+" 秒后自动跳转…",1)):x("",!0)])],64)):(m(),v(T,{key:0},[s(c,{type:"error",closable:!1,title:"链接已失效",description:b.value,"show-icon":""},null,8,["description"]),w("div",Q,[s(p,{type:"primary",onClick:S},{default:a(()=>[...e[2]||(e[2]=[k("返回登录",-1)])]),_:1})])],64))]),_:1})])}}},se=L(Y,[["__scopeId","data-v-0bbb511c"]]);export{se as default};
import{_ as L,a as n,l as M,r as U,c as j,o as F,m as K,b as v,d as s,w as a,e as l,u as D,f as m,g as w,F as T,k,h as q,i as x,j as z,t as G,E as y}from"./index-DhsLPY8p.js";import{d as H}from"./auth-cf7b3Gq2.js";import{v as J}from"./password-7ryi82gE.js";const O={class:"auth-wrap"},Q={class:"actions"},W={class:"actions"},X={key:0,class:"app-muted"},Y={__name:"ResetPasswordPage",setup(Z){const B=M(),A=D(),r=n(String(B.params.token||"")),i=n(!0),b=n(""),t=U({newPassword:"",confirmPassword:""}),g=n(!1),f=n(""),d=n(0);let u=null;function C(){if(typeof window>"u")return null;const o=window.__APP_INITIAL_STATE__;return!o||typeof o!="object"?null:(window.__APP_INITIAL_STATE__=null,o)}const I=j(()=>!!(i.value&&r.value&&!f.value));function S(){A.push("/login")}function N(){d.value=3,u=window.setInterval(()=>{d.value-=1,d.value<=0&&(window.clearInterval(u),u=null,window.location.href="/login")},1e3)}async function V(){if(!I.value)return;const o=t.newPassword,e=t.confirmPassword,c=J(o);if(!c.ok){y.error(c.message);return}if(o!==e){y.error("两次输入的密码不一致");return}g.value=!0;try{await H({token:r.value,new_password:o}),f.value="密码重置成功3秒后跳转到登录页面...",y.success("密码重置成功"),N()}catch(p){const _=p?.response?.data;y.error(_?.error||"重置失败")}finally{g.value=!1}}return F(()=>{const o=C();o?.page==="reset_password"?(r.value=String(o?.token||r.value||""),i.value=!!o?.valid,b.value=o?.error_message||(i.value?"":"重置链接无效或已过期,请重新申请密码重置")):r.value||(i.value=!1,b.value="重置链接无效或已过期,请重新申请密码重置")}),K(()=>{u&&window.clearInterval(u)}),(o,e)=>{const c=l("el-alert"),p=l("el-button"),_=l("el-input"),h=l("el-form-item"),R=l("el-form"),E=l("el-card");return m(),v("div",O,[s(E,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:a(()=>[e[5]||(e[5]=w("div",{class:"brand"},[w("div",{class:"brand-title"},"知识管理平台"),w("div",{class:"brand-sub app-muted"},"重置密码")],-1)),i.value?(m(),v(T,{key:1},[f.value?(m(),q(c,{key:0,type:"success",closable:!1,title:"重置成功",description:f.value,"show-icon":"",class:"alert"},null,8,["description"])):x("",!0),s(R,{"label-position":"top"},{default:a(()=>[s(h,{label:"新密码至少8位且包含字母和数字"},{default:a(()=>[s(_,{modelValue:t.newPassword,"onUpdate:modelValue":e[0]||(e[0]=P=>t.newPassword=P),type:"password","show-password":"",placeholder:"请输入新密码",autocomplete:"new-password"},null,8,["modelValue"])]),_:1}),s(h,{label:"确认密码"},{default:a(()=>[s(_,{modelValue:t.confirmPassword,"onUpdate:modelValue":e[1]||(e[1]=P=>t.confirmPassword=P),type:"password","show-password":"",placeholder:"请再次输入新密码",autocomplete:"new-password",onKeyup:z(V,["enter"])},null,8,["modelValue"])]),_:1})]),_:1}),s(p,{type:"primary",class:"submit-btn",loading:g.value,disabled:!I.value,onClick:V},{default:a(()=>[...e[3]||(e[3]=[k(" 确认重置 ",-1)])]),_:1},8,["loading","disabled"]),w("div",W,[s(p,{link:"",type:"primary",onClick:S},{default:a(()=>[...e[4]||(e[4]=[k("返回登录",-1)])]),_:1}),d.value>0?(m(),v("span",X,G(d.value)+" 秒后自动跳转…",1)):x("",!0)])],64)):(m(),v(T,{key:0},[s(c,{type:"error",closable:!1,title:"链接已失效",description:b.value,"show-icon":""},null,8,["description"]),w("div",Q,[s(p,{type:"primary",onClick:S},{default:a(()=>[...e[2]||(e[2]=[k("返回登录",-1)])]),_:1})])],64))]),_:1})])}}},se=L(Y,[["__scopeId","data-v-0bbb511c"]]);export{se as default};

View File

@@ -1 +1 @@
import{_ as U,a as o,c as I,o as E,m as R,b as k,d as i,w as s,e as d,u as W,f as _,g as l,i as B,h as $,k as T,t as v}from"./index-DSLtIIj4.js";const j={class:"auth-wrap"},z={class:"actions"},D={key:0,class:"countdown app-muted"},M={__name:"VerifyResultPage",setup(q){const x=W(),p=o(!1),f=o(""),m=o(""),w=o(""),y=o(""),r=o(""),u=o(""),c=o(""),n=o(0);let a=null;function C(){if(typeof window>"u")return null;const e=window.__APP_INITIAL_STATE__;return!e||typeof e!="object"?null:(window.__APP_INITIAL_STATE__=null,e)}function N(e){const t=!!e?.success;p.value=t,f.value=e?.title||(t?"验证成功":"验证失败"),m.value=e?.message||e?.error_message||(t?"操作已完成,现在可以继续使用系统。":"操作失败,请稍后重试。"),w.value=e?.primary_label||(t?"立即登录":"重新注册"),y.value=e?.primary_url||(t?"/login":"/register"),r.value=e?.secondary_label||(t?"":"返回登录"),u.value=e?.secondary_url||(t?"":"/login"),c.value=e?.redirect_url||(t?"/login":""),n.value=Number(e?.redirect_seconds||(t?5:0))||0}const A=I(()=>!!(r.value&&u.value)),b=I(()=>!!(c.value&&n.value>0));async function g(e){if(e){if(e.startsWith("http://")||e.startsWith("https://")){window.location.href=e;return}await x.push(e)}}function P(){b.value&&(a=window.setInterval(()=>{n.value-=1,n.value<=0&&(window.clearInterval(a),a=null,window.location.href=c.value)},1e3))}return E(()=>{const e=C();N(e),P()}),R(()=>{a&&window.clearInterval(a)}),(e,t)=>{const h=d("el-button"),V=d("el-result"),L=d("el-card");return _(),k("div",j,[i(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:s(()=>[t[2]||(t[2]=l("div",{class:"brand"},[l("div",{class:"brand-title"},"知识管理平台"),l("div",{class:"brand-sub app-muted"},"验证结果")],-1)),i(V,{icon:p.value?"success":"error",title:f.value,"sub-title":m.value,class:"result"},{extra:s(()=>[l("div",z,[i(h,{type:"primary",onClick:t[0]||(t[0]=S=>g(y.value))},{default:s(()=>[T(v(w.value),1)]),_:1}),A.value?(_(),$(h,{key:0,onClick:t[1]||(t[1]=S=>g(u.value))},{default:s(()=>[T(v(r.value),1)]),_:1})):B("",!0)]),b.value?(_(),k("div",D,v(n.value)+" 秒后自动跳转... ",1)):B("",!0)]),_:1},8,["icon","title","sub-title"])]),_:1})])}}},G=U(M,[["__scopeId","data-v-1fc6b081"]]);export{G as default};
import{_ as U,a as o,c as I,o as E,m as R,b as k,d as i,w as s,e as d,u as W,f as _,g as l,i as B,h as $,k as T,t as v}from"./index-DhsLPY8p.js";const j={class:"auth-wrap"},z={class:"actions"},D={key:0,class:"countdown app-muted"},M={__name:"VerifyResultPage",setup(q){const x=W(),p=o(!1),f=o(""),m=o(""),w=o(""),y=o(""),r=o(""),u=o(""),c=o(""),n=o(0);let a=null;function C(){if(typeof window>"u")return null;const e=window.__APP_INITIAL_STATE__;return!e||typeof e!="object"?null:(window.__APP_INITIAL_STATE__=null,e)}function N(e){const t=!!e?.success;p.value=t,f.value=e?.title||(t?"验证成功":"验证失败"),m.value=e?.message||e?.error_message||(t?"操作已完成,现在可以继续使用系统。":"操作失败,请稍后重试。"),w.value=e?.primary_label||(t?"立即登录":"重新注册"),y.value=e?.primary_url||(t?"/login":"/register"),r.value=e?.secondary_label||(t?"":"返回登录"),u.value=e?.secondary_url||(t?"":"/login"),c.value=e?.redirect_url||(t?"/login":""),n.value=Number(e?.redirect_seconds||(t?5:0))||0}const A=I(()=>!!(r.value&&u.value)),b=I(()=>!!(c.value&&n.value>0));async function g(e){if(e){if(e.startsWith("http://")||e.startsWith("https://")){window.location.href=e;return}await x.push(e)}}function P(){b.value&&(a=window.setInterval(()=>{n.value-=1,n.value<=0&&(window.clearInterval(a),a=null,window.location.href=c.value)},1e3))}return E(()=>{const e=C();N(e),P()}),R(()=>{a&&window.clearInterval(a)}),(e,t)=>{const h=d("el-button"),V=d("el-result"),L=d("el-card");return _(),k("div",j,[i(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:s(()=>[t[2]||(t[2]=l("div",{class:"brand"},[l("div",{class:"brand-title"},"知识管理平台"),l("div",{class:"brand-sub app-muted"},"验证结果")],-1)),i(V,{icon:p.value?"success":"error",title:f.value,"sub-title":m.value,class:"result"},{extra:s(()=>[l("div",z,[i(h,{type:"primary",onClick:t[0]||(t[0]=S=>g(y.value))},{default:s(()=>[T(v(w.value),1)]),_:1}),A.value?(_(),$(h,{key:0,onClick:t[1]||(t[1]=S=>g(u.value))},{default:s(()=>[T(v(r.value),1)]),_:1})):B("",!0)]),b.value?(_(),k("div",D,v(n.value)+" 秒后自动跳转... ",1)):B("",!0)]),_:1},8,["icon","title","sub-title"])]),_:1})])}}},G=U(M,[["__scopeId","data-v-1fc6b081"]]);export{G as default};

View File

@@ -1 +1 @@
import{p as c}from"./index-DSLtIIj4.js";async function o(t={}){const{data:a}=await c.get("/accounts",{params:t});return a}async function u(t){const{data:a}=await c.post("/accounts",t);return a}async function r(t,a){const{data:n}=await c.put(`/accounts/${t}`,a);return n}async function e(t){const{data:a}=await c.delete(`/accounts/${t}`);return a}async function i(t,a){const{data:n}=await c.put(`/accounts/${t}/remark`,a);return n}async function p(t,a){const{data:n}=await c.post(`/accounts/${t}/start`,a);return n}async function d(t){const{data:a}=await c.post(`/accounts/${t}/stop`,{});return a}async function f(t){const{data:a}=await c.post("/accounts/batch/start",t);return a}async function w(t){const{data:a}=await c.post("/accounts/batch/stop",t);return a}async function y(){const{data:t}=await c.post("/accounts/clear",{});return t}async function A(t,a={}){const{data:n}=await c.post(`/accounts/${t}/screenshot`,a);return n}export{w as a,f as b,y as c,d,e,o as f,u as g,i as h,p as s,A as t,r as u};
import{p as c}from"./index-DhsLPY8p.js";async function o(t={}){const{data:a}=await c.get("/accounts",{params:t});return a}async function u(t){const{data:a}=await c.post("/accounts",t);return a}async function r(t,a){const{data:n}=await c.put(`/accounts/${t}`,a);return n}async function e(t){const{data:a}=await c.delete(`/accounts/${t}`);return a}async function i(t,a){const{data:n}=await c.put(`/accounts/${t}/remark`,a);return n}async function p(t,a){const{data:n}=await c.post(`/accounts/${t}/start`,a);return n}async function d(t){const{data:a}=await c.post(`/accounts/${t}/stop`,{});return a}async function f(t){const{data:a}=await c.post("/accounts/batch/start",t);return a}async function w(t){const{data:a}=await c.post("/accounts/batch/stop",t);return a}async function y(){const{data:t}=await c.post("/accounts/clear",{});return t}async function A(t,a={}){const{data:n}=await c.post(`/accounts/${t}/screenshot`,a);return n}export{w as a,f as b,y as c,d,e,o as f,u as g,i as h,p as s,A as t,r as u};

View File

@@ -1 +1 @@
import{p as s}from"./index-DSLtIIj4.js";async function r(){const{data:t}=await s.get("/email/verify-status");return t}async function e(){const{data:t}=await s.post("/generate_captcha",{});return t}async function o(t){const{data:a}=await s.post("/login",t);return a}async function i(t){const{data:a}=await s.post("/register",t);return a}async function c(t){const{data:a}=await s.post("/resend-verify-email",t);return a}async function u(t){const{data:a}=await s.post("/forgot-password",t);return a}async function f(t){const{data:a}=await s.post("/reset_password_request",t);return a}async function d(t){const{data:a}=await s.post("/reset-password-confirm",t);return a}export{u as a,c as b,i as c,d,r as f,e as g,o as l,f as r};
import{p as s}from"./index-DhsLPY8p.js";async function r(){const{data:t}=await s.get("/email/verify-status");return t}async function e(){const{data:t}=await s.post("/generate_captcha",{});return t}async function o(t){const{data:a}=await s.post("/login",t);return a}async function i(t){const{data:a}=await s.post("/register",t);return a}async function c(t){const{data:a}=await s.post("/resend-verify-email",t);return a}async function u(t){const{data:a}=await s.post("/forgot-password",t);return a}async function f(t){const{data:a}=await s.post("/reset_password_request",t);return a}async function d(t){const{data:a}=await s.post("/reset-password-confirm",t);return a}export{u as a,c as b,i as c,d,r as f,e as g,o as l,f as r};

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>知识管理平台</title>
<script type="module" crossorigin src="./assets/index-DSLtIIj4.js"></script>
<script type="module" crossorigin src="./assets/index-DhsLPY8p.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CD3NfpmF.css">
</head>
<body>

View File

@@ -1510,6 +1510,23 @@
.replace(/'/g, '&#039;');
}
function getCsrfToken() {
const match = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
const originalFetch = window.fetch.bind(window);
window.fetch = (input, init = {}) => {
const method = String(init.method || 'GET').toUpperCase();
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const headers = new Headers(init.headers || {});
const token = getCsrfToken();
if (token) headers.set('X-CSRF-Token', token);
init = { ...init, headers };
}
return originalFetch(input, init);
};
// 页面加载时初始化
window.addEventListener('load', () => {
loadStats();
@@ -1853,11 +1870,11 @@
<tr>
<td>${user.id}</td>
<td>
<div><strong>${user.username}</strong> ${getVipBadge(user)}</div>
<div><strong>${escapeHtml(user.username)}</strong> ${getVipBadge(user)}</div>
${getVipExpire(user)}
</td>
<td>${user.email || '-'}</td>
<td>${user.created_at}</td>
<td>${escapeHtml(user.email || '-')}</td>
<td>${escapeHtml(user.created_at)}</td>
<td>
<div class="action-buttons">
<button class="btn btn-small btn-success" onclick="approveUser(${user.id})">通过</button>
@@ -1903,9 +1920,9 @@
<tr>
<td>${user.id}</td>
<td>
<div><strong>${user.username}</strong> ${getVipBadge(user)}</div>
<div><strong>${escapeHtml(user.username)}</strong> ${getVipBadge(user)}</div>
${getVipExpire(user)}
${user.email ? '<div class="user-info">'+user.email+'</div>' : ''}
${user.email ? '<div class="user-info">'+escapeHtml(user.email)+'</div>' : ''}
</td>
<td>
<span class="status-badge status-${user.status}">
@@ -1913,8 +1930,8 @@
</span>
</td>
<td>
${user.created_at}
${user.approved_at ? '<div class="user-info">审核:'+user.approved_at+'</div>' : ''}
${escapeHtml(user.created_at)}
${user.approved_at ? '<div class="user-info">审核:'+escapeHtml(user.approved_at)+'</div>' : ''}
</td>
<td>
<div class="action-buttons">
@@ -2481,7 +2498,8 @@
runningList.innerHTML = '<div style="color: #999; text-align: center; padding: 10px;">暂无运行中的任务</div>';
} else {
runningList.innerHTML = data.running.map(task => {
const source = sourceMap[task.source] || {text: task.source, color: '#666'};
const sourceKey = String(task.source || '');
const source = sourceMap[sourceKey] || {text: escapeHtml(sourceKey), color: '#666'};
// 状态颜色映射
const statusColorMap = {
'初始化': '#6c757d',
@@ -2491,6 +2509,11 @@
'正在截图': '#17a2b8'
};
const statusColor = statusColorMap[task.detail_status] || '#666';
const safeUser = escapeHtml(task.user_username || '');
const safeAccount = escapeHtml(task.username || '');
const safeBrowse = escapeHtml(task.browse_type || '');
const safeDetail = escapeHtml(task.detail_status || '');
const safeElapsed = escapeHtml(task.elapsed_display || '');
// 进度显示
const progressText = task.progress_items > 0 || task.progress_attachments > 0
? `(${task.progress_items}/${task.progress_attachments})`
@@ -2499,18 +2522,18 @@
<div style="flex: 1;">
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 5px;">
<span style="color: ${source.color}; font-weight: 500; font-size: 12px;">[${source.text}]</span>
<span style="color: #333;">${task.user_username}</span>
<span style="color: #333;">${safeUser}</span>
<span style="color: #666;">→</span>
<span style="color: #007bff; font-weight: 500;">${task.username}</span>
<span style="background: #e9ecef; padding: 2px 6px; border-radius: 3px; font-size: 11px; color: #666;">${task.browse_type}</span>
<span style="color: #007bff; font-weight: 500;">${safeAccount}</span>
<span style="background: #e9ecef; padding: 2px 6px; border-radius: 3px; font-size: 11px; color: #666;">${safeBrowse}</span>
</div>
<div style="margin-top: 4px; display: flex; align-items: center; gap: 8px;">
<span style="color: ${statusColor}; font-weight: 500; font-size: 12px;">● ${task.detail_status}</span>
<span style="color: ${statusColor}; font-weight: 500; font-size: 12px;">● ${safeDetail}</span>
${progressText ? `<span style="color: #999; font-size: 11px;">内容/附件: ${progressText}</span>` : ''}
</div>
</div>
<div style="text-align: right; min-width: 70px;">
<div style="color: #28a745; font-weight: 500;">${task.elapsed_display}</div>
<div style="color: #28a745; font-weight: 500;">${safeElapsed}</div>
</div>
</div>`;
}).join('');
@@ -2522,22 +2545,28 @@
queuingList.innerHTML = '<div style="color: #999; text-align: center; padding: 10px;">暂无排队中的任务</div>';
} else {
queuingList.innerHTML = data.queuing.map(task => {
const source = sourceMap[task.source] || {text: task.source, color: '#666'};
const sourceKey = String(task.source || '');
const source = sourceMap[sourceKey] || {text: escapeHtml(sourceKey), color: '#666'};
const safeUser = escapeHtml(task.user_username || '');
const safeAccount = escapeHtml(task.username || '');
const safeBrowse = escapeHtml(task.browse_type || '');
const safeDetail = escapeHtml(task.detail_status || '等待资源');
const safeElapsed = escapeHtml(task.elapsed_display || '');
return `<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #fff8e6; border-radius: 5px; margin-bottom: 5px; border-left: 3px solid #fd7e14;">
<div style="flex: 1;">
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 5px;">
<span style="color: ${source.color}; font-weight: 500; font-size: 12px;">[${source.text}]</span>
<span style="color: #333;">${task.user_username}</span>
<span style="color: #333;">${safeUser}</span>
<span style="color: #666;">→</span>
<span style="color: #007bff; font-weight: 500;">${task.username}</span>
<span style="background: #ffeeba; padding: 2px 6px; border-radius: 3px; font-size: 11px; color: #856404;">${task.browse_type}</span>
<span style="color: #007bff; font-weight: 500;">${safeAccount}</span>
<span style="background: #ffeeba; padding: 2px 6px; border-radius: 3px; font-size: 11px; color: #856404;">${safeBrowse}</span>
</div>
<div style="margin-top: 4px;">
<span style="color: #fd7e14; font-size: 12px;">● ${task.detail_status || '等待资源'}</span>
<span style="color: #fd7e14; font-size: 12px;">● ${safeDetail}</span>
</div>
</div>
<div style="text-align: right; min-width: 80px;">
<div style="color: #fd7e14; font-weight: 500;">等待 ${task.elapsed_display}</div>
<div style="color: #fd7e14; font-weight: 500;">等待 ${safeElapsed}</div>
</div>
</div>`;
}).join('');
@@ -2563,7 +2592,7 @@
const select = document.getElementById('logUserFilter');
select.innerHTML = '<option value="">全部</option>';
users.forEach(user => {
select.innerHTML += `<option value="${user.id}">${user.username}</option>`;
select.innerHTML += `<option value="${user.id}">${escapeHtml(user.username)}</option>`;
});
}
} catch (error) {
@@ -2653,18 +2682,24 @@
'immediate': {text: '即时', color: '#fd7e14'},
'resumed': {text: '恢复', color: '#6c757d'}
};
const sourceInfo = sourceMap[log.source] || {text: log.source || '手动', color: '#28a745'};
const sourceKey = log.source || 'manual';
const sourceInfo = sourceMap[sourceKey] || {text: escapeHtml(sourceKey), color: '#28a745'};
const safeCreatedAt = escapeHtml(log.created_at || '');
const safeUser = escapeHtml(log.user_username || 'N/A');
const safeAccount = escapeHtml(log.username || '');
const safeBrowse = escapeHtml(log.browse_type || '');
const safeError = escapeHtml(log.error_message || '-');
row.innerHTML = `
<td>${log.created_at}</td>
<td>${safeCreatedAt}</td>
<td><span style="color: ${sourceInfo.color}; font-weight: 500;">${sourceInfo.text}</span></td>
<td>${log.user_username || 'N/A'}</td>
<td>${log.username}</td>
<td>${log.browse_type}</td>
<td>${safeUser}</td>
<td>${safeAccount}</td>
<td>${safeBrowse}</td>
<td><span class="status-badge ${statusClass}">${statusText}</span></td>
<td>${log.total_items} / ${log.total_attachments}</td>
<td style="color: #2F80ED; font-weight: 500;">${formatDuration(log.duration)}</td>
<td style="color: #dc3545; font-size: 11px;">${log.error_message || '-'}</td>
<td style="color: #dc3545; font-size: 11px;">${safeError}</td>
`;
tbody.appendChild(row);
});
@@ -2782,9 +2817,9 @@
${passwordResets.map(reset => `
<tr>
<td>${reset.id}</td>
<td><strong>${reset.username}</strong></td>
<td>${reset.email || '-'}</td>
<td>${reset.created_at}</td>
<td><strong>${escapeHtml(reset.username)}</strong></td>
<td>${escapeHtml(reset.email || '-')}</td>
<td>${escapeHtml(reset.created_at)}</td>
<td>
<div class="action-buttons">
<button class="btn btn-small btn-success" onclick="approvePasswordReset(${reset.id})">批准</button>
@@ -2935,13 +2970,13 @@
feedbacksList.forEach(fb => {
html += '<tr>';
html += '<td>' + fb.id + '</td>';
html += '<td><strong>' + (fb.username || 'N/A') + '</strong></td>';
html += '<td style="max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="' + (fb.title||'') + '">' + (fb.title||'') + '</td>';
html += '<td style="max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="' + (fb.description||'') + '">' + (fb.description||'') + '</td>';
html += '<td>' + (fb.contact || '-') + '</td>';
html += '<td><strong>' + escapeHtml(fb.username || 'N/A') + '</strong></td>';
html += '<td style="max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="' + escapeHtml(fb.title || '') + '">' + escapeHtml(fb.title || '') + '</td>';
html += '<td style="max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="' + escapeHtml(fb.description || '') + '">' + escapeHtml(fb.description || '') + '</td>';
html += '<td>' + escapeHtml(fb.contact || '-') + '</td>';
html += '<td>' + getStatusBadge(fb.status) + '</td>';
html += '<td>' + fb.created_at + '</td>';
html += '<td style="max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="' + (fb.admin_reply || '') + '">' + (fb.admin_reply || '-') + '</td>';
html += '<td>' + escapeHtml(fb.created_at) + '</td>';
html += '<td style="max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="' + escapeHtml(fb.admin_reply || '') + '">' + escapeHtml(fb.admin_reply || '-') + '</td>';
html += '<td><div class="action-buttons">';
if (fb.status !== 'closed') {
html += '<button class="btn btn-small btn-primary" onclick="replyFeedback(' + fb.id + ')">回复</button>';
@@ -3393,20 +3428,24 @@
};
let html = '<div class="table-container"><table style="width: 100%; font-size: 12px;"><thead><tr>';
html += '<th>时间</th><th>收件人</th><th>类型</th><th>主题</th><th>状态</th><th>错误</th>';
html += '<th>时间</th><th>收件人</th><th>来源用户</th><th>类型</th><th>主题</th><th>状态</th><th>错误</th>';
html += '</tr></thead><tbody>';
logs.forEach(log => {
const statusClass = log.status === 'success' ? 'color: #27ae60;' : 'color: #e74c3c;';
const statusText = log.status === 'success' ? '成功' : '失败';
const userLabel = log.username
? `${log.username} (#${log.user_id})`
: (log.user_id ? `用户#${log.user_id}` : '系统');
html += '<tr>';
html += `<td style="white-space: nowrap;">${log.created_at}</td>`;
html += `<td>${log.email_to}</td>`;
html += `<td>${typeMap[log.email_type] || log.email_type}</td>`;
html += `<td style="max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${log.subject}">${log.subject}</td>`;
html += `<td style="white-space: nowrap;">${escapeHtml(log.created_at)}</td>`;
html += `<td>${escapeHtml(log.email_to)}</td>`;
html += `<td>${escapeHtml(userLabel)}</td>`;
html += `<td>${escapeHtml(typeMap[log.email_type] || log.email_type)}</td>`;
html += `<td style="max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(log.subject)}">${escapeHtml(log.subject)}</td>`;
html += `<td style="${statusClass} font-weight: bold;">${statusText}</td>`;
html += `<td style="max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #999;" title="${log.error_message || ''}">${log.error_message || '-'}</td>`;
html += `<td style="max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #999;" title="${escapeHtml(log.error_message || '')}">${escapeHtml(log.error_message || '-')}</td>`;
html += '</tr>';
});

View File

@@ -2151,6 +2151,23 @@
return div.innerHTML;
}
function getCsrfToken() {
const match = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
const originalFetch = window.fetch.bind(window);
window.fetch = (input, init = {}) => {
const method = String(init.method || 'GET').toUpperCase();
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const headers = new Headers(init.headers || {});
const token = getCsrfToken();
if (token) headers.set('X-CSRF-Token', token);
init = { ...init, headers };
}
return originalFetch(input, init);
};
function logout() {
try {
for (let i = sessionStorage.length - 1; i >= 0; i--) {