feat: add api admin ui and container status fix

This commit is contained in:
JOJO 2026-01-25 10:49:52 +08:00
parent 5f2a5af86e
commit 51f61b04d2
16 changed files with 1767 additions and 39 deletions

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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"]

View File

@ -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
# 尽量从容器状态读取运行标记

View File

@ -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/<username>', 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/<username>/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():

View File

@ -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"

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>API 管理面板</title>
<link rel="stylesheet" href="/static/dist/assets/adminApi.css" />
<script src="/static/security.js"></script>
</head>
<body>
<div id="admin-api-app"></div>
<script type="module" src="/static/dist/assets/adminApi.js"></script>
</body>
</html>

View File

@ -4,6 +4,27 @@
<p>正在加载监控数据...</p>
</div>
<div v-else class="admin-page">
<div class="secondary-overlay" v-if="!secondaryVerified">
<div class="secondary-card">
<h3>请输入管理员二级密码</h3>
<p class="muted">为保护敏感监控数据需二级校验后查看</p>
<input
class="secondary-input"
type="password"
v-model="secondaryPassword"
:disabled="secondaryLoading"
placeholder="二级密码"
@keyup.enter="handleVerifySecondary"
/>
<div class="secondary-actions">
<button type="button" :disabled="secondaryLoading" @click="handleVerifySecondary">
{{ secondaryLoading ? '校验中...' : '确认进入' }}
</button>
<button type="button" class="ghost-btn" :disabled="secondaryLoading" @click="checkSecondary">重新检测</button>
</div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div>
</div>
<header class="admin-header">
<div>
<h1>管理员监控面板</h1>
@ -17,6 +38,7 @@
{{ refreshing ? '刷新中...' : '立即刷新' }}
</button>
<a class="link-btn" href="/admin/policy" target="_blank" rel="noopener">策略配置</a>
<a class="link-btn" href="/admin/api" target="_blank" rel="noopener">API 管理</a>
</div>
</header>
@ -310,11 +332,15 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { useSecondaryPass } from './useSecondaryPass';
type Snapshot = Record<string, any> | null;
type SectionId = 'overview' | 'usage' | 'users' | 'containers' | 'uploads' | 'balance' | 'invites';
const { verified: secondaryVerified, loading: secondaryLoading, error: secondaryError, check: checkSecondary, verify: verifySecondary } = useSecondaryPass();
const secondaryPassword = ref('');
const loading = ref(true);
const refreshing = ref(false);
const snapshot = ref<Snapshot>(null);
@ -341,6 +367,10 @@ const sectionTabs: Array<{ id: SectionId; label: string }> = [
const isInitialLoading = computed(() => loading.value && !snapshot.value);
const fetchDashboard = async (background = false) => {
if (!secondaryVerified.value) {
loading.value = false;
return;
}
if (background) {
refreshing.value = true;
} else if (!snapshot.value) {
@ -384,7 +414,7 @@ const scheduleAutoRefresh = () => {
clearInterval(timer);
timer = undefined;
}
if (autoRefresh.value) {
if (autoRefresh.value && secondaryVerified.value) {
timer = window.setInterval(() => fetchDashboard(true), 30000);
}
};
@ -409,6 +439,15 @@ const fetchBalance = async () => {
}
};
const handleVerifySecondary = async () => {
await verifySecondary(secondaryPassword.value);
if (secondaryVerified.value) {
secondaryPassword.value = '';
fetchDashboard(false);
scheduleAutoRefresh();
}
};
watch(autoRefresh, () => {
scheduleAutoRefresh();
});
@ -422,9 +461,25 @@ watch(
}
);
onMounted(() => {
onMounted(async () => {
await checkSecondary();
if (secondaryVerified.value) {
fetchDashboard(false);
scheduleAutoRefresh();
} else {
loading.value = false;
}
});
watch(secondaryVerified, (val) => {
if (val) {
fetchDashboard(false);
scheduleAutoRefresh();
} else if (timer) {
clearInterval(timer);
timer = undefined;
loading.value = false;
}
});
onBeforeUnmount(() => {
@ -707,6 +762,65 @@ button:not(:disabled):hover {
color: #7c3418;
}
.secondary-overlay {
position: fixed;
inset: 0;
background: rgba(20, 12, 5, 0.35);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.secondary-card {
width: 360px;
max-width: 90vw;
background: #f6ecda;
border: 1px solid rgba(118, 103, 84, 0.35);
border-radius: 18px;
padding: 20px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.16);
}
.secondary-card h3 {
margin: 0 0 8px;
}
.secondary-card p {
margin: 0 0 12px;
color: #5b4b35;
}
.secondary-input {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
margin-bottom: 12px;
font-size: 15px;
}
.secondary-actions {
display: flex;
gap: 10px;
align-items: center;
}
.secondary-actions .ghost-btn {
background: transparent;
border: 1px dashed rgba(44, 32, 19, 0.35);
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
}
.secondary-error {
color: #b5473d;
margin-top: 10px;
}
.admin-layout {
display: flex;
gap: 20px;

View File

@ -0,0 +1,800 @@
<template>
<div class="api-page">
<div class="secondary-overlay" v-if="!secondaryVerified">
<div class="secondary-card">
<h3>API 后台 · 二级验证</h3>
<p class="muted">请输入管理员二级密码后查看 API 调用与账户</p>
<input
class="secondary-input"
type="password"
v-model="secondaryPassword"
:disabled="secondaryLoading"
placeholder="二级密码"
@keyup.enter="handleVerifySecondary"
/>
<div class="secondary-actions">
<button type="button" :disabled="secondaryLoading" @click="handleVerifySecondary">
{{ secondaryLoading ? '校验中...' : '确认' }}
</button>
<button type="button" class="ghost-btn" :disabled="secondaryLoading" @click="checkSecondary">重新检测</button>
</div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div>
</div>
<header class="api-header">
<div>
<h1>API 管理面板</h1>
<p>监控 Token / 容器 / 上传审计并管理 API 账户</p>
<small class="muted">数据时间{{ timeAgo(snapshot?.generated_at) }}</small>
</div>
<div class="header-actions">
<label><input type="checkbox" v-model="autoRefresh" /> 自动刷新</label>
<button type="button" :disabled="loading" @click="fetchAll">
{{ loading ? '刷新中...' : '手动刷新' }}
</button>
</div>
</header>
<section class="panel">
<div class="metrics-grid">
<div class="metric-card">
<p>API 用户数</p>
<strong>{{ overview.totals?.users || 0 }}</strong>
</div>
<div class="metric-card">
<p>总请求</p>
<strong>{{ formatNumber(totalRequests) }}</strong>
</div>
<div class="metric-card">
<p>累计 Token</p>
<strong>{{ formatNumber(overview.token_totals?.total_tokens || 0) }}</strong>
<span class="muted">输入 {{ formatNumber(overview.token_totals?.input_tokens || 0) }} / 输出 {{ formatNumber(overview.token_totals?.output_tokens || 0) }}</span>
</div>
<div class="metric-card">
<p>活动容器</p>
<strong>{{ overview.totals?.containers_active || 0 }}</strong>
<span class="muted">可用槽位 {{ overview.totals?.available_container_slots ?? '—' }}</span>
</div>
</div>
</section>
<section class="panel">
<div class="section-head">
<h2>API 账户管理</h2>
<div class="create-form">
<input v-model="createForm.username" type="text" placeholder="新账号名 (小写英数/-/_)" />
<input v-model="createForm.note" type="text" placeholder="备注(可选)" />
<button type="button" :disabled="creating" @click="createUser">
{{ creating ? '创建中...' : '创建账号' }}
</button>
</div>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>账号</th>
<th>备注</th>
<th>创建时间</th>
<th>请求</th>
<th>最近调用</th>
<th>Token 累计</th>
<th>存储</th>
<th>容器</th>
<th>密钥</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="!users.length">
<td colspan="10">暂无 API 账号</td>
</tr>
<tr v-for="u in users" :key="u.username">
<td><strong>{{ u.username }}</strong></td>
<td>{{ u.note || '—' }}</td>
<td>{{ formatTime(u.created_at) }}</td>
<td>{{ formatNumber(u.usage?.total_requests || 0) }}</td>
<td>{{ timeAgo(u.usage?.last_request_at) }}</td>
<td>{{ formatNumber(u.tokens?.total_tokens || 0) }}</td>
<td>{{ formatBytes(u.storage?.total_bytes) }}</td>
<td>
<span v-if="u.container?.running" class="status-badge online">运行中</span>
<span v-else class="status-badge offline">未运行</span>
</td>
<td class="token-cell">
<span class="token-text">
{{ revealTokens[u.username]?.visible ? (revealTokens[u.username]?.token || '—') : '••••••••••' }}
</span>
<button class="icon-btn" @click="toggleReveal(u.username)" :disabled="revealLoading === u.username">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path
d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12Zm11 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
</svg>
</button>
<button class="icon-btn" @click="copyToken(u.username)" :disabled="revealLoading === u.username">
📋
</button>
</td>
<td>
<button class="link danger" @click="deleteUser(u.username)" :disabled="deleting === u.username">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="createError" class="error-text">{{ createError }}</p>
</section>
<section class="panel grid">
<div>
<div class="section-head">
<h3>容器状态</h3>
<small class="muted">实时抓取</small>
</div>
<div class="table-wrapper small scrollable">
<table>
<thead>
<tr>
<th>Key</th>
<th>CPU</th>
<th>内存%</th>
<th>内存</th>
<th>网络 RX/TX</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-if="!containerItems.length">
<td colspan="6">暂无容器</td>
</tr>
<tr v-for="c in containerItems" :key="c.key">
<td>{{ c.key }}</td>
<td>{{ formatPercentNumber(c.stats?.cpu_percent) }}</td>
<td>{{ formatPercentNumber(c.stats?.memory?.percent) }}</td>
<td>{{ formatBytes(c.stats?.memory?.used_bytes) }}</td>
<td>{{ formatBytes(c.stats?.net_io?.rx_bytes) }} / {{ formatBytes(c.stats?.net_io?.tx_bytes) }}</td>
<td>
<span :class="['status-badge', c.running ? 'online' : 'offline']">
{{ c.running ? '运行中' : '未运行' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<div class="section-head">
<h3>上传审计</h3>
<small class="muted">来自隔离区日志</small>
</div>
<ul class="upload-feed scrollable">
<li v-if="!uploads.recent_events?.length" class="upload-item">暂无记录</li>
<li v-for="item in uploads.recent_events || []" :key="item.upload_id" class="upload-item">
<div>
<strong>{{ item.original_name || '未命名文件' }}</strong>
<div class="muted small">
用户 {{ item.username }} · {{ formatBytes(item.size) }} · {{ item.source || 'unknown' }}
</div>
</div>
<div class="muted small">
{{ timeAgo(item.timestamp) }}
<span :class="['status-badge', item.accepted ? 'online' : 'danger']">
{{ item.accepted ? '通过' : '阻断' }}
</span>
</div>
</li>
</ul>
</div>
</section>
<section class="panel">
<div class="balance-header">
<div>
<h3>余额查询</h3>
<p class="muted">Kimi / DeepSeek / Qwen</p>
</div>
<button type="button" :disabled="balanceLoading" @click="fetchBalance">
{{ balanceLoading ? '查询中...' : '刷新余额' }}
</button>
</div>
<div class="balance-grid">
<div v-for="card in balanceCards" :key="card.key" class="balance-card" :class="{ error: !card.success }">
<div class="balance-card-head">
<h4>{{ card.label }}</h4>
<span :class="['status-badge', card.success ? 'online' : 'danger']">
{{ card.success ? '正常' : '失败' }}
</span>
</div>
<strong>{{ card.amount ?? '—' }} {{ card.currency }}</strong>
<span class="muted small" v-if="card.amountCny"> ¥{{ card.amountCny }}</span>
<p class="muted small" v-else-if="card.error">{{ card.error }}</p>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useSecondaryPass } from './useSecondaryPass';
const { verified: secondaryVerified, loading: secondaryLoading, error: secondaryError, check: checkSecondary, verify: verifySecondary } = useSecondaryPass();
const secondaryPassword = ref('');
const loading = ref(false);
const autoRefresh = ref(true);
let timer: number | undefined;
const snapshot = ref<any>(null);
const users = ref<any[]>([]);
const revealTokens = ref<Record<string, { token: string; visible: boolean }>>({});
const revealLoading = ref('');
const deleting = ref('');
const creating = ref(false);
const createForm = ref({ username: '', note: '' });
const createError = ref('');
const balanceData = ref<Record<string, any> | null>(null);
const balanceLoading = ref(false);
const overview = computed(() => snapshot.value?.overview || {});
const uploads = computed(() => snapshot.value?.uploads || {});
const containerItems = computed(() => {
const items = snapshot.value?.containers || {};
return Object.keys(items).map((key) => ({ key, ...(items[key] || {}) }));
});
const totalRequests = computed(() =>
users.value.reduce((sum, u) => sum + ((u.usage && u.usage.total_requests) ? u.usage.total_requests : 0), 0)
);
const balanceCards = computed(() => {
const keys = ['kimi', 'deepseek', 'qwen'];
return keys.map((k) => {
const data = (balanceData.value as any)?.[k] || {};
return {
key: k,
label: k.toUpperCase(),
success: data.success === true,
amount: data.available ?? data.total ?? null,
amountCny: data.amount_cny ?? data.amountCny ?? null,
currency: data.currency || '',
error: data.error,
};
});
});
const formatNumber = (val: any) => {
const num = Number(val || 0);
return Number.isFinite(num) ? num.toLocaleString('zh-CN') : '0';
};
const formatBytes = (v: any) => {
const num = Number(v || 0);
if (!num) return '0B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let n = num;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i += 1;
}
return `${n.toFixed(1)}${units[i]}`;
};
const formatTime = (iso?: string) => {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
};
const timeAgo = (iso?: string) => {
if (!iso) return '—';
const ts = new Date(iso).getTime();
if (!Number.isFinite(ts)) return '—';
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return '刚刚';
if (mins < 60) return `${mins} 分钟前`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
return `${days} 天前`;
};
const formatPercentNumber = (v: any) => {
if (v === null || v === undefined) return '—';
const num = Number(v);
if (!Number.isFinite(num)) return '—';
return `${num.toFixed(1)}%`;
};
const fetchDashboard = async () => {
if (!secondaryVerified.value) return;
loading.value = true;
try {
const resp = await fetch('/api/admin/api-dashboard', { credentials: 'same-origin' });
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || '加载失败');
snapshot.value = data.data;
} catch (err: any) {
console.error(err);
} finally {
loading.value = false;
}
};
const fetchUsers = async () => {
if (!secondaryVerified.value) return;
try {
const resp = await fetch('/api/admin/api-users', { credentials: 'same-origin' });
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || '加载失败');
const map: Record<string, any> = {};
users.value = (data.data || []).map((u: any) => ({
...u,
usage: u.usage || { total_requests: 0, endpoints: {}, last_request_at: null },
tokens: u.tokens || { total_tokens: 0, input_tokens: 0, output_tokens: 0 },
storage: u.storage || { total_bytes: 0 },
}));
users.value.forEach((u) => {
map[u.username] = revealTokens.value[u.username] || { token: '', visible: false };
});
revealTokens.value = map;
} catch (err: any) {
console.error(err);
}
};
const fetchAll = async () => {
await Promise.all([fetchDashboard(), fetchUsers()]);
};
const scheduleAutoRefresh = () => {
if (timer) {
clearInterval(timer);
timer = undefined;
}
if (autoRefresh.value && secondaryVerified.value) {
timer = window.setInterval(fetchAll, 30000);
}
};
const createUser = async () => {
if (!secondaryVerified.value) return;
createError.value = '';
const username = createForm.value.username.trim().toLowerCase();
if (!username) {
createError.value = '请输入账号名';
return;
}
const pattern = /^[a-z0-9_-]{3,32}$/;
if (!pattern.test(username)) {
createError.value = '账号名需为 3-32 位小写字母/数字/_/-';
return;
}
creating.value = true;
try {
const resp = await fetch('/api/admin/api-users', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, note: createForm.value.note }),
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || '创建失败');
await fetchUsers();
revealTokens.value[username] = { token: data.data.token, visible: true };
createForm.value = { username: '', note: '' };
} catch (err: any) {
createError.value = err.message || '创建失败';
} finally {
creating.value = false;
}
};
const deleteUser = async (username: string) => {
if (!secondaryVerified.value) return;
if (!confirm(`确认删除 API 用户 ${username} 吗?`)) return;
deleting.value = username;
try {
const resp = await fetch(`/api/admin/api-users/${encodeURIComponent(username)}`, {
method: 'DELETE',
credentials: 'same-origin',
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || '删除失败');
await fetchUsers();
} catch (err: any) {
alert(err.message || '删除失败');
} finally {
deleting.value = '';
}
};
const toggleReveal = async (username: string) => {
const entry = revealTokens.value[username];
if (entry?.visible) {
revealTokens.value[username] = { ...(entry || {}), visible: false };
return;
}
revealLoading.value = username;
try {
const resp = await fetch(`/api/admin/api-users/${encodeURIComponent(username)}/token`, {
credentials: 'same-origin',
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || '获取失败');
revealTokens.value[username] = { token: data.data.token, visible: true };
} catch (err: any) {
alert(err.message || '获取 token 失败');
} finally {
revealLoading.value = '';
}
};
const copyToken = async (username: string) => {
const entry = revealTokens.value[username];
if (!entry?.token) {
await toggleReveal(username);
}
const token = revealTokens.value[username]?.token;
if (!token) return;
try {
await navigator.clipboard.writeText(token);
} catch (err) {
console.error(err);
}
};
const fetchBalance = async () => {
if (!secondaryVerified.value) return;
balanceLoading.value = true;
try {
const resp = await fetch('/api/admin/balance', { credentials: 'same-origin' });
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || '查询失败');
balanceData.value = data.data;
} catch (err) {
console.error(err);
} finally {
balanceLoading.value = false;
}
};
const handleVerifySecondary = async () => {
await verifySecondary(secondaryPassword.value);
if (secondaryVerified.value) {
secondaryPassword.value = '';
await fetchAll();
scheduleAutoRefresh();
}
};
onMounted(async () => {
await checkSecondary();
if (secondaryVerified.value) {
await fetchAll();
scheduleAutoRefresh();
}
});
watch(autoRefresh, scheduleAutoRefresh);
watch(secondaryVerified, (val) => {
if (val) {
fetchAll();
scheduleAutoRefresh();
} else if (timer) {
clearInterval(timer);
timer = undefined;
}
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
</script>
<style scoped>
:global(body) {
margin: 0;
background: #f7f3ea;
font-family: 'Iowan Old Style', ui-serif, Georgia, Cambria, "Times New Roman", serif;
color: #2a2013;
}
:global(#admin-api-app) {
min-height: 100vh;
padding: 24px 32px 48px;
box-sizing: border-box;
}
.api-page {
max-width: 1400px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.api-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.panel {
background: #f6ecda;
border-radius: 20px;
padding: 18px;
border: 1px solid rgba(118, 103, 84, 0.35);
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.08);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.metric-card {
background: rgba(255,255,255,0.94);
border-radius: 16px;
padding: 14px;
border: 1px solid rgba(44,32,19,0.12);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.6);
}
.metric-card p {
margin: 0;
color: #6b5b44;
font-size: 13px;
letter-spacing: 0.05em;
}
.metric-card strong {
font-size: 26px;
display: block;
margin-top: 6px;
}
.muted {
color: #6b5b44;
}
.muted.small {
font-size: 12px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.create-form {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.create-form input {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.12);
}
button {
border: none;
border-radius: 12px;
padding: 10px 14px;
background: var(--claude-highlight, #f3d2a8);
color: #2a2013;
font-weight: 600;
cursor: pointer;
}
.ghost-btn {
background: transparent;
border: 1px dashed rgba(44, 32, 19, 0.35);
}
.table-wrapper {
width: 100%;
overflow: auto;
}
.table-wrapper.scrollable {
max-height: 320px;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 10px;
border-bottom: 1px solid rgba(0,0,0,0.08);
text-align: left;
font-size: 14px;
}
.token-cell {
display: flex;
align-items: center;
gap: 8px;
}
.icon-btn {
background: rgba(255,255,255,0.9);
border: 1px solid rgba(0,0,0,0.12);
border-radius: 8px;
padding: 6px;
cursor: pointer;
}
.link.danger {
background: transparent;
color: #b5473d;
border: 1px solid rgba(181, 71, 61, 0.4);
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 10px;
font-size: 12px;
}
.status-badge.online {
background: rgba(73, 160, 120, 0.15);
color: #2f6b4f;
}
.status-badge.offline {
background: rgba(0,0,0,0.06);
color: #5f5140;
}
.status-badge.danger {
background: rgba(189, 93, 58, 0.15);
color: #b5473d;
}
.upload-feed {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.upload-feed.scrollable {
max-height: 280px;
overflow: auto;
}
.upload-item {
padding: 10px;
border-radius: 12px;
background: rgba(255,255,255,0.92);
border: 1px solid rgba(0,0,0,0.06);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 14px;
}
.balance-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.balance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.balance-card {
background: rgba(255,255,255,0.92);
border: 1px solid rgba(44,32,19,0.12);
border-radius: 14px;
padding: 12px;
}
.balance-card.error {
border-color: rgba(181, 71, 61, 0.35);
}
.balance-card-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.error-text {
color: #b5473d;
margin-top: 8px;
}
.secondary-overlay {
position: fixed;
inset: 0;
background: rgba(20, 12, 5, 0.35);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.secondary-card {
width: 360px;
max-width: 90vw;
background: #f6ecda;
border: 1px solid rgba(118, 103, 84, 0.35);
border-radius: 18px;
padding: 20px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.16);
}
.secondary-card h3 {
margin: 0 0 8px;
}
.secondary-card p {
margin: 0 0 12px;
color: #5b4b35;
}
.secondary-input {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
margin-bottom: 12px;
font-size: 15px;
}
.secondary-actions {
display: flex;
gap: 10px;
align-items: center;
}
.secondary-actions .ghost-btn {
background: transparent;
border: 1px dashed rgba(44, 32, 19, 0.35);
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
}
.secondary-error {
color: #b5473d;
margin-top: 10px;
}
</style>

View File

@ -1,5 +1,26 @@
<template>
<div class="custom-tools-page" :class="{ 'editor-only': editorPageMode }">
<div class="secondary-overlay" v-if="!secondaryVerified">
<div class="secondary-card">
<h3>二级密码校验</h3>
<p class="muted">输入管理员二级密码后可管理自定义工具</p>
<input
class="secondary-input"
type="password"
v-model="secondaryPassword"
:disabled="secondaryLoading"
placeholder="二级密码"
@keyup.enter="handleVerifySecondary"
/>
<div class="secondary-actions">
<button type="button" class="primary" :disabled="secondaryLoading" @click="handleVerifySecondary">
{{ secondaryLoading ? '校验中...' : '确认' }}
</button>
<button type="button" class="ghost" :disabled="secondaryLoading" @click="checkSecondary">重新检测</button>
</div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div>
</div>
<!-- 列表模式 -->
<template v-if="!editorPageMode">
<header class="page-header">
@ -124,7 +145,8 @@
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { onMounted, reactive, ref, watch } from 'vue';
import { useSecondaryPass } from './useSecondaryPass';
interface CustomTool {
id: string;
@ -137,6 +159,9 @@ interface CustomTool {
return_file?: string;
}
const { verified: secondaryVerified, loading: secondaryLoading, error: secondaryError, check: checkSecondary, verify: verifySecondary } = useSecondaryPass();
const secondaryPassword = ref('');
const tools = ref<CustomTool[]>([]);
const loading = ref(false);
const error = ref('');
@ -154,6 +179,7 @@ const createForm = reactive({ id: '', description: '' });
const buffers = reactive({ definition: '', execution: '', return: '', meta: '' });
const refresh = async () => {
if (!secondaryVerified.value) return;
loading.value = true;
error.value = '';
try {
@ -193,6 +219,7 @@ const openEditorPage = (tool: CustomTool) => {
};
const loadBuffers = async (id: string) => {
if (!secondaryVerified.value) return;
try {
buffers.definition = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=definition.json`);
buffers.execution = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=execution.py`);
@ -211,6 +238,7 @@ const fetchText = async (url: string, fallback = ''): Promise<string> => {
};
const saveAll = async () => {
if (!secondaryVerified.value) return;
if (!activeTool.value) return;
saving.value = true;
try {
@ -255,6 +283,7 @@ const openDeleteConfirm = (tool?: CustomTool) => {
};
const performDelete = async () => {
if (!secondaryVerified.value) return;
if (!deleteTargetId.value) return;
deleting.value = true;
try {
@ -278,6 +307,7 @@ const openCreateModal = () => {
};
const createTool = async () => {
if (!secondaryVerified.value) return;
const id = createForm.id.trim();
if (!id) {
error.value = '请填写工具 ID';
@ -325,6 +355,14 @@ const paramCount = (tool: CustomTool) => {
return Object.keys(props).length;
};
const handleVerifySecondary = async () => {
await verifySecondary(secondaryPassword.value);
if (secondaryVerified.value) {
secondaryPassword.value = '';
await refresh();
}
};
onMounted(async () => {
const params = new URLSearchParams(window.location.search);
const toolId = params.get('tool');
@ -332,7 +370,16 @@ onMounted(async () => {
pendingOpenId.value = toolId;
editorPageMode.value = true;
}
await checkSecondary();
if (secondaryVerified.value) {
await refresh();
}
});
watch(secondaryVerified, async (val) => {
if (val) {
await refresh();
}
});
</script>
@ -672,4 +719,64 @@ onMounted(async () => {
min-height: 220px;
}
}
.secondary-overlay {
position: fixed;
inset: 0;
background: rgba(20, 12, 5, 0.35);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.secondary-card {
width: 360px;
max-width: 90vw;
background: #f6ecda;
border: 1px solid rgba(118, 103, 84, 0.35);
border-radius: 18px;
padding: 20px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.16);
}
.secondary-card h3 {
margin: 0 0 8px;
}
.secondary-card p {
margin: 0 0 12px;
color: #5b4b35;
}
.secondary-input {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
margin-bottom: 12px;
font-size: 15px;
}
.secondary-actions {
display: flex;
gap: 10px;
align-items: center;
}
.secondary-actions .ghost {
background: transparent;
border: 1px dashed rgba(44, 32, 19, 0.35);
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
color: #2a2013;
}
.secondary-error {
color: #b5473d;
margin-top: 10px;
}
</style>

View File

@ -1,5 +1,26 @@
<template>
<div class="policy-page">
<div class="secondary-overlay" v-if="!secondaryVerified">
<div class="secondary-card">
<h3>二级密码校验</h3>
<p class="muted">请输入管理员二级密码后继续配置策略</p>
<input
class="secondary-input"
type="password"
v-model="secondaryPassword"
:disabled="secondaryLoading"
placeholder="二级密码"
@keyup.enter="handleVerifySecondary"
/>
<div class="secondary-actions">
<button type="button" class="primary" :disabled="secondaryLoading" @click="handleVerifySecondary">
{{ secondaryLoading ? '校验中...' : '确认' }}
</button>
<button type="button" class="ghost-btn" :disabled="secondaryLoading" @click="checkSecondary">重新检测</button>
</div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div>
</div>
<header class="policy-header">
<div>
<h1>管理员策略配置</h1>
@ -194,7 +215,8 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted, onBeforeUnmount } from 'vue';
import { computed, reactive, ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { useSecondaryPass } from './useSecondaryPass';
type TargetType = 'global' | 'role' | 'user' | 'invite';
@ -220,6 +242,9 @@ const defaults = reactive({
ui_block_keys: [] as string[]
});
const { verified: secondaryVerified, loading: secondaryLoading, error: secondaryError, check: checkSecondary, verify: verifySecondary } = useSecondaryPass();
const secondaryPassword = ref('');
const form = reactive({
target_type: 'global' as TargetType,
target_value: '',
@ -487,6 +512,7 @@ function rebuildCategoryOverrides() {
}
async function fetchDefaults() {
if (!secondaryVerified.value) return;
const resp = await fetch('/api/admin/policy', { credentials: 'same-origin' });
const data = await resp.json();
if (!resp.ok || !data.success) {
@ -534,6 +560,7 @@ function applyScopeConfig() {
}
async function loadScope() {
if (!secondaryVerified.value) return;
if (!policyCache.value) {
await fetchDefaults();
return;
@ -542,6 +569,7 @@ async function loadScope() {
}
async function savePolicy() {
if (!secondaryVerified.value) return;
rebuildCategoryOverrides();
saving.value = true;
try {
@ -571,19 +599,43 @@ async function savePolicy() {
}
}
onMounted(() => {
document.addEventListener('click', handleDocClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocClick);
});
const handleVerifySecondary = async () => {
await verifySecondary(secondaryPassword.value);
if (secondaryVerified.value) {
secondaryPassword.value = '';
fetchDefaults().catch((err) => {
console.error(err);
banner.message = err?.message || '加载策略失败';
banner.type = 'error';
});
}
};
onMounted(async () => {
document.addEventListener('click', handleDocClick);
await checkSecondary();
if (secondaryVerified.value) {
fetchDefaults().catch((err) => {
console.error(err);
banner.message = err?.message || '加载策略失败';
banner.type = 'error';
});
}
});
watch(secondaryVerified, (val) => {
if (val) {
fetchDefaults().catch((err) => {
console.error(err);
banner.message = err?.message || '加载策略失败';
banner.type = 'error';
});
}
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocClick);
});
</script>
<style scoped>
@ -1071,4 +1123,63 @@ button:disabled {
.tiny {
font-size: 12px;
}
.secondary-overlay {
position: fixed;
inset: 0;
background: rgba(20, 12, 5, 0.35);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.secondary-card {
width: 360px;
max-width: 90vw;
background: #f6ecda;
border: 1px solid rgba(118, 103, 84, 0.35);
border-radius: 18px;
padding: 20px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.16);
}
.secondary-card h3 {
margin: 0 0 8px;
}
.secondary-card p {
margin: 0 0 12px;
color: #5b4b35;
}
.secondary-input {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
margin-bottom: 12px;
font-size: 15px;
}
.secondary-actions {
display: flex;
gap: 10px;
align-items: center;
}
.secondary-actions .ghost-btn {
background: transparent;
border: 1px dashed rgba(44, 32, 19, 0.35);
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
}
.secondary-error {
color: #b5473d;
margin-top: 10px;
}
</style>

View File

@ -0,0 +1,4 @@
import { createApp } from 'vue';
import ApiAdminApp from './ApiAdminApp.vue';
createApp(ApiAdminApp).mount('#admin-api-app');

View File

@ -0,0 +1,55 @@
import { ref } from 'vue';
export function useSecondaryPass() {
const verified = ref(false);
const loading = ref(false);
const error = ref<string | null>(null);
const check = async () => {
loading.value = true;
error.value = null;
try {
const resp = await fetch('/api/admin/secondary/status', { credentials: 'same-origin' });
if (!resp.ok) throw new Error(`状态请求失败:${resp.status}`);
const data = await resp.json();
verified.value = !!data.verified;
} catch (err: any) {
error.value = err.message || '无法验证二级密码状态';
} finally {
loading.value = false;
}
};
const verify = async (password: string) => {
loading.value = true;
error.value = null;
try {
const resp = await fetch('/api/admin/secondary/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ password }),
});
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || '二级密码验证失败');
}
verified.value = true;
} catch (err: any) {
error.value = err.message || '验证失败';
verified.value = false;
} finally {
loading.value = false;
}
};
return {
verified,
loading,
error,
check,
verify,
};
}
export type SecondaryPassState = ReturnType<typeof useSecondaryPass>;

View File

@ -501,6 +501,9 @@
<button type="button" class="admin-monitor-button" @click="openAdminPanel">
打开监控面板
</button>
<button type="button" class="admin-monitor-button ghost" @click="openApiAdmin">
API 管理
</button>
<p class="admin-monitor-hint">新窗口开启不打断当前对话与配置</p>
</div>
</div>
@ -758,6 +761,11 @@ const openCustomTools = () => {
personalization.closeDrawer();
};
const openApiAdmin = () => {
window.open('/admin/api', '_blank', 'noopener');
personalization.closeDrawer();
};
// ===== =====
import { useTheme } from '@/utils/theme';
import type { ThemeKey } from '@/utils/theme';
@ -900,6 +908,12 @@ const applyThemeOption = (theme: ThemeKey) => {
justify-content: center;
gap: 8px;
}
.admin-monitor-button.ghost {
background: transparent;
color: var(--claude-text);
border: 1px dashed rgba(61, 57, 41, 0.25);
box-shadow: none;
}
.admin-monitor-button:hover {
transform: translateY(-1px);

View File

@ -34,6 +34,7 @@
{{ containerStatusText }}
</span>
</div>
<template v-if="containerStatus && containerStatus.mode === 'docker'">
<template v-if="hasContainerStats">
<div class="stat-grid stat-grid--double">
<div class="stat-block">
@ -54,6 +55,8 @@
</div>
</div>
</template>
<div class="usage-placeholder" v-else>容器已运行等待采集指标...</div>
</template>
<div class="usage-placeholder" v-else>当前运行在宿主机模式暂无容器指标</div>
</div>
<div class="usage-cell usage-cell--left usage-cell--quota panel-card">

View File

@ -7,6 +7,7 @@ const adminEntry = fileURLToPath(new URL('./static/src/admin/main.ts', import.me
const adminPolicyEntry = fileURLToPath(new URL('./static/src/admin/policyMain.ts', import.meta.url));
const adminCustomToolsEntry = fileURLToPath(new URL('./static/src/admin/customToolsMain.ts', import.meta.url));
const adminCustomToolsGuideEntry = fileURLToPath(new URL('./static/src/admin/customToolsGuideMain.ts', import.meta.url));
const adminApiEntry = fileURLToPath(new URL('./static/src/admin/apiMain.ts', import.meta.url));
export default defineConfig({
plugins: [vue()],
@ -19,7 +20,8 @@ export default defineConfig({
admin: adminEntry,
adminPolicy: adminPolicyEntry,
adminCustomTools: adminCustomToolsEntry,
adminCustomToolsGuide: adminCustomToolsGuideEntry
adminCustomToolsGuide: adminCustomToolsGuideEntry,
adminApi: adminApiEntry
},
output: {
entryFileNames: 'assets/[name].js',