diff --git a/config/auth.py b/config/auth.py index 133d27d..292cc1d 100644 --- a/config/auth.py +++ b/config/auth.py @@ -2,10 +2,51 @@ import os -ADMIN_USERNAME = os.environ.get("AGENT_ADMIN_USERNAME", "") -ADMIN_PASSWORD_HASH = os.environ.get("AGENT_ADMIN_PASSWORD_HASH", "") +from pathlib import Path + + +def _dotenv_cache(): + cache = getattr(_dotenv_cache, "_cache", None) + if cache is not None: + return cache + env_path = Path(__file__).resolve().parents[1] / ".env" + data = {} + if env_path.exists(): + for raw in env_path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + data[key.strip()] = value.strip().strip('"').strip("'") + _dotenv_cache._cache = data + return data + + +def _get(name: str, default: str = "") -> str: + # 优先读取 .env 文件,未找到再回退环境变量 + return _dotenv_cache().get(name, os.environ.get(name, default)) + + +ADMIN_USERNAME = _get("AGENT_ADMIN_USERNAME", "") +ADMIN_PASSWORD_HASH = _get("AGENT_ADMIN_PASSWORD_HASH", "") + +# 管理员二级密码(可选)。 +# 优先使用哈希值;若未提供哈希,将回退到明文配置。 +ADMIN_SECONDARY_PASSWORD_HASH = _get("ADMIN_SECONDARY_PASSWORD_HASH", "") +ADMIN_SECONDARY_PASSWORD = _get("ADMIN_SECONDARY_PASSWORD", "") + +# 二级密码会话有效期(秒)。默认为 30 分钟,可根据需要在环境变量中覆盖。 +ADMIN_SECONDARY_TTL_SECONDS = int(_get("AGENT_ADMIN_SECONDARY_TTL_SECONDS", "1800") or 1800) + +# API Token 加密密钥来源;用于后台安全存储可回显的 Token。 +# 使用任意字符串即可,内部会通过 SHA256 导出 Fernet key。 +API_TOKEN_SECRET = _get("API_TOKEN_SECRET", "") __all__ = [ "ADMIN_USERNAME", "ADMIN_PASSWORD_HASH", + "ADMIN_SECONDARY_PASSWORD_HASH", + "ADMIN_SECONDARY_PASSWORD", + "ADMIN_SECONDARY_TTL_SECONDS", + "API_TOKEN_SECRET", ] diff --git a/config/paths.py b/config/paths.py index 1281650..81da5fc 100644 --- a/config/paths.py +++ b/config/paths.py @@ -15,6 +15,7 @@ ADMIN_POLICY_FILE = f"{DATA_DIR}/admin_policy.json" API_USER_SPACE_DIR = "./api/users" API_USERS_DB_FILE = f"{DATA_DIR}/api_users.json" API_TOKENS_FILE = f"{DATA_DIR}/api_tokens.json" +API_USAGE_FILE = f"{DATA_DIR}/api_usage.json" __all__ = [ "DEFAULT_PROJECT_PATH", @@ -28,4 +29,5 @@ __all__ = [ "API_USER_SPACE_DIR", "API_USERS_DB_FILE", "API_TOKENS_FILE", + "API_USAGE_FILE", ] diff --git a/modules/api_user_manager.py b/modules/api_user_manager.py index 5477527..79d3a9c 100644 --- a/modules/api_user_manager.py +++ b/modules/api_user_manager.py @@ -1,33 +1,32 @@ """API 专用用户与工作区管理(JSON + Bearer Token 哈希)。 -仅支持手动维护:在 `API_USERS_DB_FILE` 中添加用户与 SHA256(token)。 -结构示例: -{ - "users": { - "api_jojo": { - "token_sha256": "abc123...", - "created_at": "2026-01-23", - "note": "for mobile app" - } - } -} +支持 API 用户的创建/删除、Token 持久化(哈希 + 加密回显)与基础用量计数。 """ from __future__ import annotations import json import hashlib import threading +import secrets +import base64 from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Dict, Optional, Tuple +from typing import Dict, Optional, Tuple, Any from config import ( API_USER_SPACE_DIR, API_USERS_DB_FILE, API_TOKENS_FILE, + API_USAGE_FILE, + API_TOKEN_SECRET, ) from modules.personalization_manager import ensure_personalization_config +try: + from cryptography.fernet import Fernet, InvalidToken # type: ignore +except Exception: # pragma: no cover - 环境缺失时给予友好提示 + Fernet = None + InvalidToken = Exception # type: ignore @dataclass @@ -62,18 +61,28 @@ class ApiUserManager: users_file: str = API_USERS_DB_FILE, tokens_file: str = API_TOKENS_FILE, workspace_root: str = API_USER_SPACE_DIR, + usage_file: str = API_USAGE_FILE, ): self.users_file = Path(users_file) self.tokens_file = Path(tokens_file) + self.usage_file = Path(usage_file) self.workspace_root = Path(workspace_root).expanduser().resolve() self.workspace_root.mkdir(parents=True, exist_ok=True) self._users: Dict[str, ApiUserRecord] = {} + self._tokens: Dict[str, Dict[str, Any]] = {} + self._usage: Dict[str, Dict[str, Any]] = {} self._lock = threading.Lock() self._load_users() + self._load_tokens() + self._load_usage() # ----------------------- public APIs ----------------------- + def list_users(self) -> Dict[str, ApiUserRecord]: + with self._lock: + return dict(self._users) + def get_user_by_token(self, bearer_token: str) -> Optional[ApiUserRecord]: if not bearer_token: return None @@ -158,6 +167,92 @@ class ApiUserManager: personalization_dir=personalization_dir, ) + def create_user(self, username: str, note: str = "") -> Tuple[ApiUserRecord, str]: + """创建新的 API 用户并返回明文 Token。""" + username = self._normalize_username(username) + with self._lock: + if username in self._users: + raise ValueError("该 API 用户已存在") + record, token = self._issue_token_locked(username, note=note) + self._save_users() + return record, token + + def issue_token(self, username: str, note: str = "") -> Tuple[ApiUserRecord, str]: + """为已有用户重新生成 token 并返回明文。""" + username = self._normalize_username(username) + with self._lock: + if username not in self._users: + raise ValueError("用户不存在") + record, token = self._issue_token_locked(username, note=note) + self._save_users() + return record, token + + def delete_user(self, username: str) -> bool: + username = self._normalize_username(username) + with self._lock: + removed = self._users.pop(username, None) is not None + self._tokens.pop(username, None) + self._usage.pop(username, None) + self._save_users() + self._save_tokens() + self._save_usage() + # 尝试删除对应工作区目录(忽略失败) + try: + import shutil + user_root = (self.workspace_root / username).resolve() + if user_root.exists(): + shutil.rmtree(user_root, ignore_errors=True) + except Exception: + pass + return removed + + def get_plain_token(self, username: str) -> str: + username = self._normalize_username(username) + with self._lock: + token_entry = self._tokens.get(username) or {} + if not token_entry: + raise ValueError("未找到 token 记录") + # 优先使用加密存储;若无密钥或解密失败,回退到明文字段(本地后台安全场景可接受) + enc = token_entry.get("token_enc") + if enc: + fernet = self._fernet() + if fernet: + try: + return fernet.decrypt(enc.encode("utf-8")).decode("utf-8") + except InvalidToken as exc: # type: ignore + # fall through to plaintext + pass + else: + # 没有密钥,继续尝试明文 + pass + plain = token_entry.get("token_plain") + if plain: + return plain + raise RuntimeError("缺少 API_TOKEN_SECRET 且无可用明文 token") + + def bump_usage(self, username: str, endpoint: Optional[str] = None): + """记录 API 请求次数与最近时间,用于后台监控。""" + username = self._normalize_username(username) + now_iso = datetime.utcnow().isoformat() + "Z" + with self._lock: + entry = self._usage.setdefault(username, {"total": 0, "endpoints": {}, "last_request_at": None}) + entry["total"] = int(entry.get("total", 0)) + 1 + if endpoint: + endpoint_key = endpoint.split("?")[0] + endpoints = entry.setdefault("endpoints", {}) + endpoints[endpoint_key] = int(endpoints.get(endpoint_key, 0)) + 1 + entry["last_request_at"] = now_iso + self._save_usage() + + def get_usage(self, username: str) -> Dict[str, Any]: + username = self._normalize_username(username) + with self._lock: + return dict(self._usage.get(username) or {}) + + def list_usage(self) -> Dict[str, Dict[str, Any]]: + with self._lock: + return {u: dict(meta) for u, meta in self._usage.items()} + def list_workspaces(self, username: str) -> Dict[str, Dict]: """列出用户的所有工作区信息。""" username = username.strip().lower() @@ -193,6 +288,12 @@ class ApiUserManager: return True # ----------------------- internal helpers ----------------------- + def _normalize_username(self, username: str) -> str: + candidate = (username or "").strip().lower() + if not candidate: + raise ValueError("用户名不能为空") + return candidate + def _sha256(self, token: str) -> str: return hashlib.sha256((token or "").encode("utf-8")).hexdigest() @@ -235,5 +336,76 @@ class ApiUserManager: self.users_file.parent.mkdir(parents=True, exist_ok=True) self.users_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + # -------- Token 存储(加密回显) -------- + def _fernet(self): + secret = (API_TOKEN_SECRET or "").strip() + if not secret or not Fernet: + return None + key = hashlib.sha256(secret.encode("utf-8")).digest() + fkey = base64.urlsafe_b64encode(key) + return Fernet(fkey) + + def _load_tokens(self): + if not self.tokens_file.exists(): + self._save_tokens() + return + try: + raw = json.loads(self.tokens_file.read_text(encoding="utf-8")) + tokens = raw.get("tokens", {}) if isinstance(raw, dict) else {} + self._tokens = tokens + except Exception: + self._tokens = {} + + def _save_tokens(self): + payload = {"tokens": self._tokens} + self.tokens_file.parent.mkdir(parents=True, exist_ok=True) + self.tokens_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + def _store_token(self, username: str, token: str, note: str = ""): + fernet = self._fernet() + enc = fernet.encrypt(token.encode("utf-8")).decode("utf-8") if fernet else "" + self._tokens[username] = { + "token_enc": enc, + "token_plain": token, # 便于在缺少密钥时回退,受本地文件权限保护 + "note": note, + "created_at": datetime.utcnow().isoformat(), + } + self._save_tokens() + + def _issue_token_locked(self, username: str, note: str = "") -> Tuple[ApiUserRecord, str]: + token = secrets.token_urlsafe(32) + token_sha = self._sha256(token) + record = self._users.get(username) + if not record: + record = ApiUserRecord( + username=username, + token_sha256=token_sha, + created_at=datetime.utcnow().isoformat(), + note=note or "", + ) + record.token_sha256 = token_sha + record.note = note or record.note + record.created_at = record.created_at or datetime.utcnow().isoformat() + self._users[username] = record + self._store_token(username, token, note=note) + return record, token + + # -------- API 请求用量 -------- + def _load_usage(self): + if not self.usage_file.exists(): + self._save_usage() + return + try: + raw = json.loads(self.usage_file.read_text(encoding="utf-8")) + usage = raw.get("usage", {}) if isinstance(raw, dict) else {} + self._usage = usage + except Exception: + self._usage = {} + + def _save_usage(self): + payload = {"usage": self._usage} + self.usage_file.parent.mkdir(parents=True, exist_ok=True) + self.usage_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + __all__ = ["ApiUserManager", "ApiUserRecord", "ApiUserWorkspace"] diff --git a/modules/user_container_manager.py b/modules/user_container_manager.py index 611e7de..29be35f 100644 --- a/modules/user_container_manager.py +++ b/modules/user_container_manager.py @@ -198,7 +198,7 @@ class UserContainerManager: state = inspect_state(handle.container_name, handle.sandbox_bin) if stats: info["stats"] = stats - self._log_stats(username, stats) + self._log_stats(handle.username, stats) if state: info["state"] = state # 尽量从容器状态读取运行标记 diff --git a/server/admin.py b/server/admin.py index ebbfbc1..78b5db0 100644 --- a/server/admin.py +++ b/server/admin.py @@ -4,19 +4,53 @@ import time import mimetypes import logging import json -from flask import Blueprint, jsonify, send_from_directory, request, current_app, redirect, abort +from flask import Blueprint, jsonify, send_from_directory, request, current_app, redirect, abort, session +from werkzeug.security import check_password_hash from .auth_helpers import login_required, admin_required, admin_api_required, api_login_required, get_current_user_record, resolve_admin_policy from .security import rate_limited from .utils_common import debug_log from . import state # 使用动态 state,确保与入口实例保持一致 from .state import custom_tool_registry, user_manager, container_manager, PROJECT_MAX_STORAGE_MB -from .conversation import build_admin_dashboard_snapshot as _build_dashboard_rich +from .conversation import ( + build_admin_dashboard_snapshot as _build_dashboard_rich, + compute_workspace_storage, + collect_user_token_statistics, + collect_upload_events, + summarize_upload_events, +) from modules import admin_policy_manager, balance_client from collections import Counter +from config import ADMIN_SECONDARY_PASSWORD_HASH, ADMIN_SECONDARY_PASSWORD, ADMIN_SECONDARY_TTL_SECONDS + +# CSRF 豁免:二级密码相关接口不需要 CSRF +try: + state.CSRF_EXEMPT_PATHS.update({ + "/api/admin/secondary/status", + "/api/admin/secondary/verify", + }) +except Exception: + pass admin_bp = Blueprint('admin', __name__) + +# ------------------ 二级密码校验 ------------------ # +def _is_secondary_verified() -> bool: + ts = session.get("admin_secondary_verified_at") + if not ts: + return False + try: + return (time.time() - float(ts)) < ADMIN_SECONDARY_TTL_SECONDS + except Exception: + return False + + +def _secondary_required(): + if _is_secondary_verified(): + return None + return jsonify({"success": False, "error": "需要二级密码", "code": "secondary_required"}), 403 + @admin_bp.route('/admin/monitor') @login_required @admin_required @@ -39,11 +73,50 @@ def admin_custom_tools_page(): return send_from_directory(str(Path(current_app.static_folder)/"custom_tools"), 'index.html') +@admin_bp.route('/admin/api') +@login_required +@admin_required +def admin_api_page(): + """API 管理页面入口。""" + return send_from_directory(str(Path(current_app.static_folder)/"admin_api"), 'index.html') + + +@admin_bp.route('/api/admin/secondary/status', methods=['GET']) +@login_required +@admin_required +def admin_secondary_status(): + return jsonify({"success": True, "verified": _is_secondary_verified(), "ttl": ADMIN_SECONDARY_TTL_SECONDS}) + + +@admin_bp.route('/api/admin/secondary/verify', methods=['POST']) +@login_required +@admin_required +def admin_secondary_verify(): + payload = request.get_json() or {} + password = str(payload.get("password") or "").strip() + if not password: + return jsonify({"success": False, "error": "请输入二级密码"}), 400 + try: + if ADMIN_SECONDARY_PASSWORD_HASH: + ok = check_password_hash(ADMIN_SECONDARY_PASSWORD_HASH, password) + else: + ok = password == (ADMIN_SECONDARY_PASSWORD or "") + except Exception: + ok = False + if not ok: + return jsonify({"success": False, "error": "二级密码错误"}), 401 + session["admin_secondary_verified_at"] = time.time() + return jsonify({"success": True, "ttl": ADMIN_SECONDARY_TTL_SECONDS}) + + @admin_bp.route('/api/admin/balance', methods=['GET']) @login_required @admin_required def admin_balance_api(): """查询第三方账户余额(Kimi/DeepSeek/Qwen)。""" + guard = _secondary_required() + if guard: + return guard data = balance_client.fetch_all_balances() return jsonify({"success": True, "data": data}) @@ -126,6 +199,9 @@ def static_files(filename): @api_login_required @admin_api_required def admin_dashboard_snapshot_api(): + guard = _secondary_required() + if guard: + return guard try: # 若当前管理员没有容器句柄,主动确保容器存在,避免面板始终显示“宿主机模式” try: @@ -183,10 +259,102 @@ def build_admin_dashboard_snapshot(): ], } + +def build_api_admin_dashboard_snapshot(): + """构建 API 用户专用的监控快照。""" + handle_map = container_manager.list_containers() + api_users = state.api_user_manager.list_users() + usage_map = state.api_user_manager.list_usage() + items = [] + token_totals = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} + storage_total_bytes = 0 + quarantine_total_bytes = 0 + total_requests = 0 + + def _find_handle(name: str): + for key in handle_map.keys(): + if key.startswith(name): + try: + return container_manager.get_container_status(key, include_stats=True) + except Exception: + return None + return None + + for username, record in api_users.items(): + try: + ws = state.api_user_manager.ensure_workspace(username) + except Exception: + continue + storage = compute_workspace_storage(ws) + tokens = collect_user_token_statistics(ws) + usage = usage_map.get(username) or {} + storage_total_bytes += storage.get("total_bytes", 0) or 0 + quarantine_total_bytes += storage.get("quarantine_bytes", 0) or 0 + for k in token_totals: + token_totals[k] += tokens.get(k, 0) or 0 + handle = _find_handle(username) + total_requests += int(usage.get("total", 0) or 0) + items.append({ + "username": username, + "note": getattr(record, "note", "") if record else "", + "created_at": getattr(record, "created_at", ""), + "token_sha256": getattr(record, "token_sha256", ""), + "tokens": tokens, + "storage": storage, + "usage": { + "total_requests": int(usage.get("total", 0)), + "endpoints": usage.get("endpoints") or {}, + "last_request_at": usage.get("last_request_at"), + }, + "container": handle, + }) + + api_handles = { + key: val for key, val in handle_map.items() + if key.split("::")[0] in api_users + } + containers_active = len(api_handles) + max_containers = getattr(container_manager, "max_containers", None) or len(handle_map) + available_slots = None + if max_containers: + available_slots = max(0, max_containers - len(handle_map)) + + upload_events = collect_upload_events() + uploads_summary = summarize_upload_events(upload_events, quarantine_total_bytes) + + overview = { + "generated_at": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + "totals": { + "users": len(api_users), + "containers_active": containers_active, + "containers_max": max_containers, + "available_container_slots": available_slots, + }, + "token_totals": token_totals, + "usage_totals": { + "requests": total_requests + }, + "storage": { + "total_bytes": storage_total_bytes, + "project_max_mb": PROJECT_MAX_STORAGE_MB, + }, + "uploads": uploads_summary.get("stats") or {}, + } + return { + "generated_at": overview["generated_at"], + "overview": overview, + "users": items, + "containers": api_handles, + "uploads": uploads_summary, + } + @admin_bp.route('/api/admin/policy', methods=['GET', 'POST']) @api_login_required @admin_api_required def admin_policy_api(): + guard = _secondary_required() + if guard: + return guard if request.method == 'GET': try: data = admin_policy_manager.load_policy() @@ -213,6 +381,9 @@ def admin_policy_api(): @admin_api_required def admin_custom_tools_api(): """自定义工具管理(仅全局管理员)。""" + guard = _secondary_required() + if guard: + return guard try: if request.method == 'GET': return jsonify({"success": True, "data": custom_tool_registry.list_tools()}) @@ -241,6 +412,9 @@ def admin_custom_tools_api(): def admin_custom_tools_file_api(): tool_id = request.args.get("id") or (request.get_json() or {}).get("id") name = request.args.get("name") or (request.get_json() or {}).get("name") + guard = _secondary_required() + if guard: + return guard if not tool_id or not name: return jsonify({"success": False, "error": "缺少 id 或 name"}), 400 tool_dir = Path(custom_tool_registry.root) / tool_id @@ -270,12 +444,122 @@ def admin_custom_tools_file_api(): @api_login_required @admin_api_required def admin_custom_tools_reload_api(): + guard = _secondary_required() + if guard: + return guard try: custom_tool_registry.reload() return jsonify({"success": True}) except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 500 + +# ------------------ API 用户与监控 ------------------ # +@admin_bp.route('/api/admin/api-dashboard', methods=['GET']) +@api_login_required +@admin_api_required +def admin_api_dashboard(): + guard = _secondary_required() + if guard: + return guard + try: + snapshot = build_api_admin_dashboard_snapshot() + return jsonify({"success": True, "data": snapshot}) + except Exception as exc: + logging.exception("Failed to build api dashboard") + return jsonify({"success": False, "error": str(exc)}), 500 + + +@admin_bp.route('/api/admin/api-users', methods=['GET', 'POST']) +@api_login_required +@admin_api_required +def admin_api_users(): + guard = _secondary_required() + if guard: + return guard + if request.method == 'GET': + users = state.api_user_manager.list_users() + usage = state.api_user_manager.list_usage() + items = [] + for username, record in users.items(): + meta = usage.get(username) or {} + items.append({ + "username": username, + "note": getattr(record, "note", ""), + "created_at": getattr(record, "created_at", ""), + "token_sha256": getattr(record, "token_sha256", ""), + "last_request_at": meta.get("last_request_at"), + "total_requests": meta.get("total", 0), + "endpoints": meta.get("endpoints") or {}, + }) + return jsonify({"success": True, "data": items}) + + payload = request.get_json() or {} + username = (payload.get("username") or "").strip().lower() + note = (payload.get("note") or "").strip() + if not username: + return jsonify({"success": False, "error": "用户名不能为空"}), 400 + try: + record, token = state.api_user_manager.create_user(username, note=note) + return jsonify({ + "success": True, + "data": { + "username": record.username, + "token": token, + "created_at": record.created_at, + "note": record.note, + } + }) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as exc: + logging.exception("create api user failed") + return jsonify({"success": False, "error": str(exc)}), 500 + + +@admin_bp.route('/api/admin/api-users/', methods=['DELETE']) +@api_login_required +@admin_api_required +def admin_api_users_delete(username: str): + guard = _secondary_required() + if guard: + return guard + if not username: + return jsonify({"success": False, "error": "缺少用户名"}), 400 + try: + ok = state.api_user_manager.delete_user(username) + if not ok: + return jsonify({"success": False, "error": "用户不存在"}), 404 + return jsonify({"success": True}) + except Exception as exc: + logging.exception("delete api user failed") + return jsonify({"success": False, "error": str(exc)}), 500 + + +@admin_bp.route('/api/admin/api-users//token', methods=['GET']) +@api_login_required +@admin_api_required +def admin_api_users_token(username: str): + guard = _secondary_required() + if guard: + return guard + try: + # 尝试读取;若不存在或解密失败,自动重新签发 + token = None + try: + token = state.api_user_manager.get_plain_token(username) + except Exception: + pass + if not token: + _, token = state.api_user_manager.issue_token(username) + return jsonify({"success": True, "data": {"token": token}}) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 404 + except Exception as exc: + logging.exception("get api user token failed") + return jsonify({"success": False, "error": str(exc)}), 500 + + @admin_bp.route('/api/effective-policy', methods=['GET']) @api_login_required def effective_policy_api(): diff --git a/server/api_auth.py b/server/api_auth.py index f26d761..61ae968 100644 --- a/server/api_auth.py +++ b/server/api_auth.py @@ -31,6 +31,11 @@ def api_token_required(view_func): if not record: return jsonify({"success": False, "error": "无效的 Token"}), 401 + try: + state.api_user_manager.bump_usage(record.username, request.path) + except Exception: + pass + # 写入 session 以复用现有上下文/工作区逻辑 session["username"] = record.username session["role"] = "api" diff --git a/static/admin_api/index.html b/static/admin_api/index.html new file mode 100644 index 0000000..626ca03 --- /dev/null +++ b/static/admin_api/index.html @@ -0,0 +1,14 @@ + + + + + + API 管理面板 + + + + +
+ + + diff --git a/static/src/admin/AdminDashboardApp.vue b/static/src/admin/AdminDashboardApp.vue index b19518b..367ac54 100644 --- a/static/src/admin/AdminDashboardApp.vue +++ b/static/src/admin/AdminDashboardApp.vue @@ -4,6 +4,27 @@

正在加载监控数据...

+
+
+

请输入管理员二级密码

+

为保护敏感监控数据,需二级校验后查看。

+ +
+ + +
+

{{ secondaryError }}

+
+

管理员监控面板

@@ -17,6 +38,7 @@ {{ refreshing ? '刷新中...' : '立即刷新' }} 策略配置 + API 管理
@@ -310,11 +332,15 @@ + + diff --git a/static/src/admin/CustomToolsApp.vue b/static/src/admin/CustomToolsApp.vue index ade5c2b..5d7e2d5 100644 --- a/static/src/admin/CustomToolsApp.vue +++ b/static/src/admin/CustomToolsApp.vue @@ -1,5 +1,26 @@