feat: add api admin ui and container status fix
This commit is contained in:
parent
5f2a5af86e
commit
51f61b04d2
@ -2,10 +2,51 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
ADMIN_USERNAME = os.environ.get("AGENT_ADMIN_USERNAME", "")
|
from pathlib import Path
|
||||||
ADMIN_PASSWORD_HASH = os.environ.get("AGENT_ADMIN_PASSWORD_HASH", "")
|
|
||||||
|
|
||||||
|
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__ = [
|
__all__ = [
|
||||||
"ADMIN_USERNAME",
|
"ADMIN_USERNAME",
|
||||||
"ADMIN_PASSWORD_HASH",
|
"ADMIN_PASSWORD_HASH",
|
||||||
|
"ADMIN_SECONDARY_PASSWORD_HASH",
|
||||||
|
"ADMIN_SECONDARY_PASSWORD",
|
||||||
|
"ADMIN_SECONDARY_TTL_SECONDS",
|
||||||
|
"API_TOKEN_SECRET",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -15,6 +15,7 @@ ADMIN_POLICY_FILE = f"{DATA_DIR}/admin_policy.json"
|
|||||||
API_USER_SPACE_DIR = "./api/users"
|
API_USER_SPACE_DIR = "./api/users"
|
||||||
API_USERS_DB_FILE = f"{DATA_DIR}/api_users.json"
|
API_USERS_DB_FILE = f"{DATA_DIR}/api_users.json"
|
||||||
API_TOKENS_FILE = f"{DATA_DIR}/api_tokens.json"
|
API_TOKENS_FILE = f"{DATA_DIR}/api_tokens.json"
|
||||||
|
API_USAGE_FILE = f"{DATA_DIR}/api_usage.json"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_PROJECT_PATH",
|
"DEFAULT_PROJECT_PATH",
|
||||||
@ -28,4 +29,5 @@ __all__ = [
|
|||||||
"API_USER_SPACE_DIR",
|
"API_USER_SPACE_DIR",
|
||||||
"API_USERS_DB_FILE",
|
"API_USERS_DB_FILE",
|
||||||
"API_TOKENS_FILE",
|
"API_TOKENS_FILE",
|
||||||
|
"API_USAGE_FILE",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,33 +1,32 @@
|
|||||||
"""API 专用用户与工作区管理(JSON + Bearer Token 哈希)。
|
"""API 专用用户与工作区管理(JSON + Bearer Token 哈希)。
|
||||||
|
|
||||||
仅支持手动维护:在 `API_USERS_DB_FILE` 中添加用户与 SHA256(token)。
|
支持 API 用户的创建/删除、Token 持久化(哈希 + 加密回显)与基础用量计数。
|
||||||
结构示例:
|
|
||||||
{
|
|
||||||
"users": {
|
|
||||||
"api_jojo": {
|
|
||||||
"token_sha256": "abc123...",
|
|
||||||
"created_at": "2026-01-23",
|
|
||||||
"note": "for mobile app"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
import threading
|
import threading
|
||||||
|
import secrets
|
||||||
|
import base64
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional, Tuple
|
from typing import Dict, Optional, Tuple, Any
|
||||||
|
|
||||||
from config import (
|
from config import (
|
||||||
API_USER_SPACE_DIR,
|
API_USER_SPACE_DIR,
|
||||||
API_USERS_DB_FILE,
|
API_USERS_DB_FILE,
|
||||||
API_TOKENS_FILE,
|
API_TOKENS_FILE,
|
||||||
|
API_USAGE_FILE,
|
||||||
|
API_TOKEN_SECRET,
|
||||||
)
|
)
|
||||||
from modules.personalization_manager import ensure_personalization_config
|
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
|
@dataclass
|
||||||
@ -62,18 +61,28 @@ class ApiUserManager:
|
|||||||
users_file: str = API_USERS_DB_FILE,
|
users_file: str = API_USERS_DB_FILE,
|
||||||
tokens_file: str = API_TOKENS_FILE,
|
tokens_file: str = API_TOKENS_FILE,
|
||||||
workspace_root: str = API_USER_SPACE_DIR,
|
workspace_root: str = API_USER_SPACE_DIR,
|
||||||
|
usage_file: str = API_USAGE_FILE,
|
||||||
):
|
):
|
||||||
self.users_file = Path(users_file)
|
self.users_file = Path(users_file)
|
||||||
self.tokens_file = Path(tokens_file)
|
self.tokens_file = Path(tokens_file)
|
||||||
|
self.usage_file = Path(usage_file)
|
||||||
self.workspace_root = Path(workspace_root).expanduser().resolve()
|
self.workspace_root = Path(workspace_root).expanduser().resolve()
|
||||||
self.workspace_root.mkdir(parents=True, exist_ok=True)
|
self.workspace_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
self._users: Dict[str, ApiUserRecord] = {}
|
self._users: Dict[str, ApiUserRecord] = {}
|
||||||
|
self._tokens: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._usage: Dict[str, Dict[str, Any]] = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
self._load_users()
|
self._load_users()
|
||||||
|
self._load_tokens()
|
||||||
|
self._load_usage()
|
||||||
|
|
||||||
# ----------------------- public APIs -----------------------
|
# ----------------------- 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]:
|
def get_user_by_token(self, bearer_token: str) -> Optional[ApiUserRecord]:
|
||||||
if not bearer_token:
|
if not bearer_token:
|
||||||
return None
|
return None
|
||||||
@ -158,6 +167,92 @@ class ApiUserManager:
|
|||||||
personalization_dir=personalization_dir,
|
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]:
|
def list_workspaces(self, username: str) -> Dict[str, Dict]:
|
||||||
"""列出用户的所有工作区信息。"""
|
"""列出用户的所有工作区信息。"""
|
||||||
username = username.strip().lower()
|
username = username.strip().lower()
|
||||||
@ -193,6 +288,12 @@ class ApiUserManager:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# ----------------------- internal helpers -----------------------
|
# ----------------------- 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:
|
def _sha256(self, token: str) -> str:
|
||||||
return hashlib.sha256((token or "").encode("utf-8")).hexdigest()
|
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.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self.users_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
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"]
|
__all__ = ["ApiUserManager", "ApiUserRecord", "ApiUserWorkspace"]
|
||||||
|
|||||||
@ -198,7 +198,7 @@ class UserContainerManager:
|
|||||||
state = inspect_state(handle.container_name, handle.sandbox_bin)
|
state = inspect_state(handle.container_name, handle.sandbox_bin)
|
||||||
if stats:
|
if stats:
|
||||||
info["stats"] = stats
|
info["stats"] = stats
|
||||||
self._log_stats(username, stats)
|
self._log_stats(handle.username, stats)
|
||||||
if state:
|
if state:
|
||||||
info["state"] = state
|
info["state"] = state
|
||||||
# 尽量从容器状态读取运行标记
|
# 尽量从容器状态读取运行标记
|
||||||
|
|||||||
288
server/admin.py
288
server/admin.py
@ -4,19 +4,53 @@ import time
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import logging
|
import logging
|
||||||
import json
|
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 .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 .security import rate_limited
|
||||||
from .utils_common import debug_log
|
from .utils_common import debug_log
|
||||||
from . import state # 使用动态 state,确保与入口实例保持一致
|
from . import state # 使用动态 state,确保与入口实例保持一致
|
||||||
from .state import custom_tool_registry, user_manager, container_manager, PROJECT_MAX_STORAGE_MB
|
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 modules import admin_policy_manager, balance_client
|
||||||
from collections import Counter
|
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__)
|
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')
|
@admin_bp.route('/admin/monitor')
|
||||||
@login_required
|
@login_required
|
||||||
@admin_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')
|
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'])
|
@admin_bp.route('/api/admin/balance', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def admin_balance_api():
|
def admin_balance_api():
|
||||||
"""查询第三方账户余额(Kimi/DeepSeek/Qwen)。"""
|
"""查询第三方账户余额(Kimi/DeepSeek/Qwen)。"""
|
||||||
|
guard = _secondary_required()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
data = balance_client.fetch_all_balances()
|
data = balance_client.fetch_all_balances()
|
||||||
return jsonify({"success": True, "data": data})
|
return jsonify({"success": True, "data": data})
|
||||||
|
|
||||||
@ -126,6 +199,9 @@ def static_files(filename):
|
|||||||
@api_login_required
|
@api_login_required
|
||||||
@admin_api_required
|
@admin_api_required
|
||||||
def admin_dashboard_snapshot_api():
|
def admin_dashboard_snapshot_api():
|
||||||
|
guard = _secondary_required()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
try:
|
try:
|
||||||
# 若当前管理员没有容器句柄,主动确保容器存在,避免面板始终显示“宿主机模式”
|
# 若当前管理员没有容器句柄,主动确保容器存在,避免面板始终显示“宿主机模式”
|
||||||
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'])
|
@admin_bp.route('/api/admin/policy', methods=['GET', 'POST'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
@admin_api_required
|
@admin_api_required
|
||||||
def admin_policy_api():
|
def admin_policy_api():
|
||||||
|
guard = _secondary_required()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
try:
|
try:
|
||||||
data = admin_policy_manager.load_policy()
|
data = admin_policy_manager.load_policy()
|
||||||
@ -213,6 +381,9 @@ def admin_policy_api():
|
|||||||
@admin_api_required
|
@admin_api_required
|
||||||
def admin_custom_tools_api():
|
def admin_custom_tools_api():
|
||||||
"""自定义工具管理(仅全局管理员)。"""
|
"""自定义工具管理(仅全局管理员)。"""
|
||||||
|
guard = _secondary_required()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
try:
|
try:
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
return jsonify({"success": True, "data": custom_tool_registry.list_tools()})
|
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():
|
def admin_custom_tools_file_api():
|
||||||
tool_id = request.args.get("id") or (request.get_json() or {}).get("id")
|
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")
|
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:
|
if not tool_id or not name:
|
||||||
return jsonify({"success": False, "error": "缺少 id 或 name"}), 400
|
return jsonify({"success": False, "error": "缺少 id 或 name"}), 400
|
||||||
tool_dir = Path(custom_tool_registry.root) / tool_id
|
tool_dir = Path(custom_tool_registry.root) / tool_id
|
||||||
@ -270,12 +444,122 @@ def admin_custom_tools_file_api():
|
|||||||
@api_login_required
|
@api_login_required
|
||||||
@admin_api_required
|
@admin_api_required
|
||||||
def admin_custom_tools_reload_api():
|
def admin_custom_tools_reload_api():
|
||||||
|
guard = _secondary_required()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
try:
|
try:
|
||||||
custom_tool_registry.reload()
|
custom_tool_registry.reload()
|
||||||
return jsonify({"success": True})
|
return jsonify({"success": True})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({"success": False, "error": str(exc)}), 500
|
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'])
|
@admin_bp.route('/api/effective-policy', methods=['GET'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
def effective_policy_api():
|
def effective_policy_api():
|
||||||
|
|||||||
@ -31,6 +31,11 @@ def api_token_required(view_func):
|
|||||||
if not record:
|
if not record:
|
||||||
return jsonify({"success": False, "error": "无效的 Token"}), 401
|
return jsonify({"success": False, "error": "无效的 Token"}), 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
state.api_user_manager.bump_usage(record.username, request.path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 写入 session 以复用现有上下文/工作区逻辑
|
# 写入 session 以复用现有上下文/工作区逻辑
|
||||||
session["username"] = record.username
|
session["username"] = record.username
|
||||||
session["role"] = "api"
|
session["role"] = "api"
|
||||||
|
|||||||
14
static/admin_api/index.html
Normal file
14
static/admin_api/index.html
Normal 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>
|
||||||
@ -4,6 +4,27 @@
|
|||||||
<p>正在加载监控数据...</p>
|
<p>正在加载监控数据...</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="admin-page">
|
<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">
|
<header class="admin-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>管理员监控面板</h1>
|
<h1>管理员监控面板</h1>
|
||||||
@ -17,6 +38,7 @@
|
|||||||
{{ refreshing ? '刷新中...' : '立即刷新' }}
|
{{ refreshing ? '刷新中...' : '立即刷新' }}
|
||||||
</button>
|
</button>
|
||||||
<a class="link-btn" href="/admin/policy" target="_blank" rel="noopener">策略配置</a>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -310,11 +332,15 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||||
|
import { useSecondaryPass } from './useSecondaryPass';
|
||||||
|
|
||||||
type Snapshot = Record<string, any> | null;
|
type Snapshot = Record<string, any> | null;
|
||||||
|
|
||||||
type SectionId = 'overview' | 'usage' | 'users' | 'containers' | 'uploads' | 'balance' | 'invites';
|
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 loading = ref(true);
|
||||||
const refreshing = ref(false);
|
const refreshing = ref(false);
|
||||||
const snapshot = ref<Snapshot>(null);
|
const snapshot = ref<Snapshot>(null);
|
||||||
@ -341,6 +367,10 @@ const sectionTabs: Array<{ id: SectionId; label: string }> = [
|
|||||||
const isInitialLoading = computed(() => loading.value && !snapshot.value);
|
const isInitialLoading = computed(() => loading.value && !snapshot.value);
|
||||||
|
|
||||||
const fetchDashboard = async (background = false) => {
|
const fetchDashboard = async (background = false) => {
|
||||||
|
if (!secondaryVerified.value) {
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (background) {
|
if (background) {
|
||||||
refreshing.value = true;
|
refreshing.value = true;
|
||||||
} else if (!snapshot.value) {
|
} else if (!snapshot.value) {
|
||||||
@ -384,7 +414,7 @@ const scheduleAutoRefresh = () => {
|
|||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
timer = undefined;
|
timer = undefined;
|
||||||
}
|
}
|
||||||
if (autoRefresh.value) {
|
if (autoRefresh.value && secondaryVerified.value) {
|
||||||
timer = window.setInterval(() => fetchDashboard(true), 30000);
|
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, () => {
|
watch(autoRefresh, () => {
|
||||||
scheduleAutoRefresh();
|
scheduleAutoRefresh();
|
||||||
});
|
});
|
||||||
@ -422,9 +461,25 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
await checkSecondary();
|
||||||
|
if (secondaryVerified.value) {
|
||||||
fetchDashboard(false);
|
fetchDashboard(false);
|
||||||
scheduleAutoRefresh();
|
scheduleAutoRefresh();
|
||||||
|
} else {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(secondaryVerified, (val) => {
|
||||||
|
if (val) {
|
||||||
|
fetchDashboard(false);
|
||||||
|
scheduleAutoRefresh();
|
||||||
|
} else if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = undefined;
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@ -707,6 +762,65 @@ button:not(:disabled):hover {
|
|||||||
color: #7c3418;
|
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 {
|
.admin-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|||||||
800
static/src/admin/ApiAdminApp.vue
Normal file
800
static/src/admin/ApiAdminApp.vue
Normal 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>
|
||||||
@ -1,5 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="custom-tools-page" :class="{ 'editor-only': editorPageMode }">
|
<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">
|
<template v-if="!editorPageMode">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
@ -124,7 +145,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue';
|
import { onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
import { useSecondaryPass } from './useSecondaryPass';
|
||||||
|
|
||||||
interface CustomTool {
|
interface CustomTool {
|
||||||
id: string;
|
id: string;
|
||||||
@ -137,6 +159,9 @@ interface CustomTool {
|
|||||||
return_file?: string;
|
return_file?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { verified: secondaryVerified, loading: secondaryLoading, error: secondaryError, check: checkSecondary, verify: verifySecondary } = useSecondaryPass();
|
||||||
|
const secondaryPassword = ref('');
|
||||||
|
|
||||||
const tools = ref<CustomTool[]>([]);
|
const tools = ref<CustomTool[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
@ -154,6 +179,7 @@ const createForm = reactive({ id: '', description: '' });
|
|||||||
const buffers = reactive({ definition: '', execution: '', return: '', meta: '' });
|
const buffers = reactive({ definition: '', execution: '', return: '', meta: '' });
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
|
if (!secondaryVerified.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
@ -193,6 +219,7 @@ const openEditorPage = (tool: CustomTool) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadBuffers = async (id: string) => {
|
const loadBuffers = async (id: string) => {
|
||||||
|
if (!secondaryVerified.value) return;
|
||||||
try {
|
try {
|
||||||
buffers.definition = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=definition.json`);
|
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`);
|
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 () => {
|
const saveAll = async () => {
|
||||||
|
if (!secondaryVerified.value) return;
|
||||||
if (!activeTool.value) return;
|
if (!activeTool.value) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
@ -255,6 +283,7 @@ const openDeleteConfirm = (tool?: CustomTool) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const performDelete = async () => {
|
const performDelete = async () => {
|
||||||
|
if (!secondaryVerified.value) return;
|
||||||
if (!deleteTargetId.value) return;
|
if (!deleteTargetId.value) return;
|
||||||
deleting.value = true;
|
deleting.value = true;
|
||||||
try {
|
try {
|
||||||
@ -278,6 +307,7 @@ const openCreateModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createTool = async () => {
|
const createTool = async () => {
|
||||||
|
if (!secondaryVerified.value) return;
|
||||||
const id = createForm.id.trim();
|
const id = createForm.id.trim();
|
||||||
if (!id) {
|
if (!id) {
|
||||||
error.value = '请填写工具 ID';
|
error.value = '请填写工具 ID';
|
||||||
@ -325,6 +355,14 @@ const paramCount = (tool: CustomTool) => {
|
|||||||
return Object.keys(props).length;
|
return Object.keys(props).length;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVerifySecondary = async () => {
|
||||||
|
await verifySecondary(secondaryPassword.value);
|
||||||
|
if (secondaryVerified.value) {
|
||||||
|
secondaryPassword.value = '';
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const toolId = params.get('tool');
|
const toolId = params.get('tool');
|
||||||
@ -332,7 +370,16 @@ onMounted(async () => {
|
|||||||
pendingOpenId.value = toolId;
|
pendingOpenId.value = toolId;
|
||||||
editorPageMode.value = true;
|
editorPageMode.value = true;
|
||||||
}
|
}
|
||||||
|
await checkSecondary();
|
||||||
|
if (secondaryVerified.value) {
|
||||||
await refresh();
|
await refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(secondaryVerified, async (val) => {
|
||||||
|
if (val) {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -672,4 +719,64 @@ onMounted(async () => {
|
|||||||
min-height: 220px;
|
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>
|
</style>
|
||||||
|
|||||||
@ -1,5 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="policy-page">
|
<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">
|
<header class="policy-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>管理员策略配置</h1>
|
<h1>管理员策略配置</h1>
|
||||||
@ -194,7 +215,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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';
|
type TargetType = 'global' | 'role' | 'user' | 'invite';
|
||||||
|
|
||||||
@ -220,6 +242,9 @@ const defaults = reactive({
|
|||||||
ui_block_keys: [] as string[]
|
ui_block_keys: [] as string[]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { verified: secondaryVerified, loading: secondaryLoading, error: secondaryError, check: checkSecondary, verify: verifySecondary } = useSecondaryPass();
|
||||||
|
const secondaryPassword = ref('');
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
target_type: 'global' as TargetType,
|
target_type: 'global' as TargetType,
|
||||||
target_value: '',
|
target_value: '',
|
||||||
@ -487,6 +512,7 @@ function rebuildCategoryOverrides() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDefaults() {
|
async function fetchDefaults() {
|
||||||
|
if (!secondaryVerified.value) return;
|
||||||
const resp = await fetch('/api/admin/policy', { credentials: 'same-origin' });
|
const resp = await fetch('/api/admin/policy', { credentials: 'same-origin' });
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!resp.ok || !data.success) {
|
if (!resp.ok || !data.success) {
|
||||||
@ -534,6 +560,7 @@ function applyScopeConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadScope() {
|
async function loadScope() {
|
||||||
|
if (!secondaryVerified.value) return;
|
||||||
if (!policyCache.value) {
|
if (!policyCache.value) {
|
||||||
await fetchDefaults();
|
await fetchDefaults();
|
||||||
return;
|
return;
|
||||||
@ -542,6 +569,7 @@ async function loadScope() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function savePolicy() {
|
async function savePolicy() {
|
||||||
|
if (!secondaryVerified.value) return;
|
||||||
rebuildCategoryOverrides();
|
rebuildCategoryOverrides();
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
@ -571,19 +599,43 @@ async function savePolicy() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
const handleVerifySecondary = async () => {
|
||||||
document.addEventListener('click', handleDocClick);
|
await verifySecondary(secondaryPassword.value);
|
||||||
});
|
if (secondaryVerified.value) {
|
||||||
|
secondaryPassword.value = '';
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener('click', handleDocClick);
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchDefaults().catch((err) => {
|
fetchDefaults().catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
banner.message = err?.message || '加载策略失败';
|
banner.message = err?.message || '加载策略失败';
|
||||||
banner.type = 'error';
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -1071,4 +1123,63 @@ button:disabled {
|
|||||||
.tiny {
|
.tiny {
|
||||||
font-size: 12px;
|
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>
|
</style>
|
||||||
|
|||||||
4
static/src/admin/apiMain.ts
Normal file
4
static/src/admin/apiMain.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import ApiAdminApp from './ApiAdminApp.vue';
|
||||||
|
|
||||||
|
createApp(ApiAdminApp).mount('#admin-api-app');
|
||||||
55
static/src/admin/useSecondaryPass.ts
Normal file
55
static/src/admin/useSecondaryPass.ts
Normal 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>;
|
||||||
@ -501,6 +501,9 @@
|
|||||||
<button type="button" class="admin-monitor-button" @click="openAdminPanel">
|
<button type="button" class="admin-monitor-button" @click="openAdminPanel">
|
||||||
打开监控面板
|
打开监控面板
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="admin-monitor-button ghost" @click="openApiAdmin">
|
||||||
|
API 管理
|
||||||
|
</button>
|
||||||
<p class="admin-monitor-hint">新窗口开启,不打断当前对话与配置。</p>
|
<p class="admin-monitor-hint">新窗口开启,不打断当前对话与配置。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -758,6 +761,11 @@ const openCustomTools = () => {
|
|||||||
personalization.closeDrawer();
|
personalization.closeDrawer();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openApiAdmin = () => {
|
||||||
|
window.open('/admin/api', '_blank', 'noopener');
|
||||||
|
personalization.closeDrawer();
|
||||||
|
};
|
||||||
|
|
||||||
// ===== 主题切换 =====
|
// ===== 主题切换 =====
|
||||||
import { useTheme } from '@/utils/theme';
|
import { useTheme } from '@/utils/theme';
|
||||||
import type { ThemeKey } from '@/utils/theme';
|
import type { ThemeKey } from '@/utils/theme';
|
||||||
@ -900,6 +908,12 @@ const applyThemeOption = (theme: ThemeKey) => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
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 {
|
.admin-monitor-button:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
|||||||
@ -34,6 +34,7 @@
|
|||||||
{{ containerStatusText }}
|
{{ containerStatusText }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="containerStatus && containerStatus.mode === 'docker'">
|
||||||
<template v-if="hasContainerStats">
|
<template v-if="hasContainerStats">
|
||||||
<div class="stat-grid stat-grid--double">
|
<div class="stat-grid stat-grid--double">
|
||||||
<div class="stat-block">
|
<div class="stat-block">
|
||||||
@ -54,6 +55,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<div class="usage-placeholder" v-else>容器已运行,等待采集指标...</div>
|
||||||
|
</template>
|
||||||
<div class="usage-placeholder" v-else>当前运行在宿主机模式,暂无容器指标。</div>
|
<div class="usage-placeholder" v-else>当前运行在宿主机模式,暂无容器指标。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="usage-cell usage-cell--left usage-cell--quota panel-card">
|
<div class="usage-cell usage-cell--left usage-cell--quota panel-card">
|
||||||
|
|||||||
@ -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 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 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 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({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
@ -19,7 +20,8 @@ export default defineConfig({
|
|||||||
admin: adminEntry,
|
admin: adminEntry,
|
||||||
adminPolicy: adminPolicyEntry,
|
adminPolicy: adminPolicyEntry,
|
||||||
adminCustomTools: adminCustomToolsEntry,
|
adminCustomTools: adminCustomToolsEntry,
|
||||||
adminCustomToolsGuide: adminCustomToolsGuideEntry
|
adminCustomToolsGuide: adminCustomToolsGuideEntry,
|
||||||
|
adminApi: adminApiEntry
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: 'assets/[name].js',
|
entryFileNames: 'assets/[name].js',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user