feat: 新增 API Token 和离线 API 文档

This commit is contained in:
2026-05-23 03:00:34 +08:00
parent 211f87e65e
commit 4784ed36ca
14 changed files with 1160 additions and 14 deletions
+1
View File
@@ -45,6 +45,7 @@ CapacityReportData/
.env.local
auth.ini
license.dat
api_tokens.json
# Logs
*.log
+22
View File
@@ -9,6 +9,7 @@ CapacityReport 用于导入每周容量报表数据,按 `Configure.json` 的
- MySQL 数据表查看、清空、删除、CSV/XLSX 导出。
- SQL 脚本在线查看、保存和执行。
- 处理历史、日志查看、历史原始数据打包下载。
- API Token 管理和登录后可见的离线 API 文档,便于内网系统直接调用上传、远程处理、查表和 SQL 执行接口。
- 按 ZIP 文件名数据日期校验本地授权期限,过期后可输入激活码顺延。
- 系统设置:数据库、远程数据源、Sheet 过滤、字段映射、历史保留、密码修改。
- 发行形态:Server Portable、Tauri 桌面版、Docker 服务端版。
@@ -219,12 +220,33 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W
- `ExtractField`:字段映射配置。
登录密码保存在本地 `auth.ini`,该文件不应提交到版本库。
API Token 保存在本地 `api_tokens.json`,只保存 HMAC 哈希和显示用前后缀;完整 Token 只在创建或重生成时显示一次。该文件属于运行时数据,不应提交到版本库。
## API Token 与文档
登录后进入左侧 `API 中心` 可生成、停用、设置永久或指定日期到期的 API Token,并查看内置 API 文档。API 文档基于本地 `swagger-ui-dist` 打包,不依赖外网 CDN。
Token 调用方式:
```text
Authorization: Bearer <token>
```
也兼容:
```text
X-API-Token: <token>
```
API Token 与登录态一样可访问业务 API,包括文件上传、远程下载并处理、数据库表查询、筛选查询、导出以及 `/api/database/execute` 自定义 SQL 执行。Token 管理、系统配置、授权和 API 文档本身仍要求登录后访问。
授权到期日期保存在本地加密文件 `license.dat`,默认到期日由 `app/services/license.py` 中的 `DEFAULT_EXPIRES_ON` 控制,当前为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。登录后连续点击左上角品牌图标 8 次,可主动打开授权延期窗口。
## 常用接口
- `POST /api/login`:登录
- `GET /api/tokens`:列出 API Token(仅登录)
- `POST /api/tokens/create`:生成 API Token(仅登录)
- `GET /api/openapi.json`:OpenAPI JSON(仅登录)
- `GET /api/docs-info`:API 文档入口信息(仅登录)
- `POST /api/change-password`:修改密码
- `POST /api/upload`:上传文件
- `POST /api/remote/test`:测试 FTP/SFTP 连接
+115
View File
@@ -0,0 +1,115 @@
from typing import Any
from fastapi import APIRouter, Body, HTTPException, Request
from app.auth import resolve_login_context
from app.services.api_tokens import create_token, delete_token, list_tokens, regenerate_token, update_token
router = APIRouter(tags=["api-tokens"])
def _require_login(request: Request) -> None:
if resolve_login_context(request) is None:
raise HTTPException(status_code=401, detail="未登录或登录已过期")
def _bad_expiration_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail="到期日期格式无效,请使用 YYYY-MM-DD 或 ISO 日期时间")
@router.get("/api/tokens")
async def get_tokens(request: Request):
_require_login(request)
return {"success": True, "tokens": list_tokens()}
@router.post("/api/tokens/create")
async def create_api_token(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
name = str(payload.get("name", "")).strip()
expires_at = payload.get("expires_at")
enabled = bool(payload.get("enabled", True))
permanent = bool(payload.get("permanent", False))
expires_in_days = payload.get("expires_in_days")
raw_expires_at = None if permanent else (str(expires_at).strip() if expires_at else None)
try:
raw_token, record = create_token(
name=name,
expires_at=raw_expires_at,
enabled=enabled,
expires_in_days=int(expires_in_days) if expires_in_days is not None else None,
)
except ValueError as exc:
raise _bad_expiration_error(exc) from exc
return {
"success": True,
"message": "API Token 创建成功",
"token": raw_token,
"record": record,
}
@router.post("/api/tokens/update")
async def update_api_token(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
token_id = str(payload.get("id", "")).strip()
if not token_id:
raise HTTPException(status_code=400, detail="缺少 Token ID")
try:
record = update_token(
token_id,
name=payload.get("name"),
enabled=payload.get("enabled"),
expires_at=None if payload.get("permanent") else payload.get("expires_at"),
)
return {"success": True, "message": "API Token 已更新", "record": record}
except ValueError as exc:
raise _bad_expiration_error(exc) from exc
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/api/tokens/regenerate")
async def regenerate_api_token(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
token_id = str(payload.get("id", "")).strip()
if not token_id:
raise HTTPException(status_code=400, detail="缺少 Token ID")
try:
raw_token, record = regenerate_token(token_id)
return {
"success": True,
"message": "API Token 已重新生成",
"token": raw_token,
"record": record,
}
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/api/tokens/delete")
async def delete_api_token(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
token_id = str(payload.get("id", "")).strip()
if not token_id:
raise HTTPException(status_code=400, detail="缺少 Token ID")
delete_token(token_id)
return {"success": True, "message": "API Token 已删除"}
@router.get("/api/docs-info")
async def docs_info(request: Request):
_require_login(request)
return {
"success": True,
"docs_url": "/api/docs-ui",
"openapi_url": "/api/openapi.json",
"token_header": "Authorization: Bearer <token>",
"alt_header": "X-API-Token: <token>",
"note": "API 文档仅登录后可访问。",
}
+64
View File
@@ -4,8 +4,11 @@ import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from typing import Optional
from starlette.requests import Request
from app.config import BASE_DIR
@@ -15,6 +18,12 @@ DEFAULT_USERNAME = "root"
DEFAULT_PASSWORD = "Capacity"
@dataclass(frozen=True)
class AuthContext:
kind: str
payload: dict
def _ensure_auth_ini() -> None:
if AUTH_INI_PATH.exists():
return
@@ -68,6 +77,61 @@ def verify_jwt_token(token: str) -> Optional[dict]:
return None
def extract_login_token(request: Request) -> str | None:
auth_header = request.headers.get("Authorization", "").strip()
if auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1].strip()
if token:
return token
cookie_token = request.cookies.get("token", "").strip()
return cookie_token or None
def extract_access_token(request: Request) -> str | None:
auth_header = request.headers.get("Authorization", "").strip()
if auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1].strip()
if token:
return token
api_token = request.headers.get("X-API-Token", "").strip()
if api_token:
return api_token
cookie_token = request.cookies.get("token", "").strip()
return cookie_token or None
def resolve_login_context(request: Request) -> AuthContext | None:
token = extract_login_token(request)
if not token:
return None
payload = verify_jwt_token(token)
if not payload:
return None
return AuthContext(kind="jwt", payload=payload)
def resolve_access_context(request: Request) -> AuthContext | None:
token = extract_access_token(request)
if not token:
return None
payload = verify_jwt_token(token)
if payload:
return AuthContext(kind="jwt", payload=payload)
from app.services.api_tokens import verify_api_token
api_payload = verify_api_token(token)
if api_payload:
return AuthContext(kind="api_token", payload=api_payload)
return None
def _encode_json(data: dict) -> str:
raw = json.dumps(data, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
+95 -13
View File
@@ -1,21 +1,43 @@
from pathlib import Path
import argparse
from pathlib import Path
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.openapi.utils import get_openapi
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from app import state
from app.api.routers import auth, cache, config, database, health, history, license, remote, script, tasks, upload
from app.auth import verify_jwt_token
from app.api.routers import (
api_tokens,
auth,
cache,
config,
database,
health,
history,
license,
remote,
script,
tasks,
upload,
)
from app.auth import extract_access_token, resolve_access_context, resolve_login_context
from app.config import BASE_DIR
from app.services.api_tokens import touch_token_usage
APP_VERSION = "3.0.0"
APP_HOST = "0.0.0.0"
APP_PORT = 9081
FRONTEND_DIST_DIR = BASE_DIR / "frontend" / "dist"
LOGIN_ONLY_API_PREFIXES = (
"/api/config",
"/api/change-password",
"/api/license",
"/api/tokens",
)
LOGIN_ONLY_API_PATHS = {"/api/openapi.json", "/api/docs-ui", "/api/docs-info"}
def create_app() -> FastAPI:
@@ -27,6 +49,7 @@ def create_app() -> FastAPI:
redoc_url=None,
openapi_url=None,
)
app.openapi = lambda: custom_openapi(app) # type: ignore[method-assign]
app.add_middleware(
CORSMiddleware,
@@ -36,31 +59,43 @@ def create_app() -> FastAPI:
allow_headers=["*"],
)
app.middleware("http")(jwt_middleware)
app.middleware("http")(auth_middleware)
register_routes(app)
register_frontend(app)
return app
async def jwt_middleware(request: Request, call_next):
async def auth_middleware(request: Request, call_next):
if request.method == "OPTIONS":
return await call_next(request)
path = request.url.path
if path.startswith("/api/") and path != "/api/login":
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
if path == "/api/login":
return await call_next(request)
if _is_login_only_api(path):
if resolve_login_context(request) is None:
return JSONResponse(status_code=401, content={"detail": "未登录或登录已过期"})
return await call_next(request)
if path.startswith("/api/"):
access_context = resolve_access_context(request)
if access_context is None:
return JSONResponse(status_code=401, content={"detail": "未授权,请提供有效的 Token"})
token = auth_header.split(" ", 1)[1]
if not verify_jwt_token(token):
return JSONResponse(status_code=401, content={"detail": "Token 无效或已过期"})
request.state.auth_context = access_context
if access_context.kind == "api_token":
access_token = extract_access_token(request)
if access_token:
client_host = request.client.host if request.client else None
touch_token_usage(access_token, client_host)
return await call_next(request)
def register_routes(app: FastAPI) -> None:
routers = [
api_tokens.router,
auth.router,
health.router,
upload.router,
@@ -78,12 +113,23 @@ def register_routes(app: FastAPI) -> None:
def register_frontend(app: FastAPI) -> None:
@app.get("/api/openapi.json", include_in_schema=False)
async def serve_openapi(request: Request):
if resolve_login_context(request) is None:
return JSONResponse(status_code=401, content={"detail": "未登录或登录已过期"})
return JSONResponse(app.openapi())
@app.get("/api/docs-ui", include_in_schema=False)
async def serve_docs_ui(request: Request):
if resolve_login_context(request) is None:
return JSONResponse(status_code=401, content={"detail": "未登录或登录已过期"})
return RedirectResponse(url="/api-center", status_code=302)
@app.get("/", include_in_schema=False)
@app.get("/{path:path}", include_in_schema=False)
async def serve_frontend(path: str = ""):
if path.startswith("api/"):
raise HTTPException(status_code=404, detail="接口不存在")
return _serve_frontend_path(path)
@@ -118,6 +164,42 @@ def _safe_file(root: Path, path: str) -> Path | None:
return requested
def _is_login_only_api(path: str) -> bool:
return path in LOGIN_ONLY_API_PATHS or path.startswith(LOGIN_ONLY_API_PREFIXES)
def custom_openapi(app: FastAPI) -> dict:
if app.openapi_schema:
return app.openapi_schema
schema = get_openapi(title=app.title, version=app.version, description=app.description, routes=app.routes)
components = schema.setdefault("components", {})
security_schemes = components.setdefault("securitySchemes", {})
security_schemes["BearerAuth"] = {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "登录 JWT 或 API Token,均可通过 Authorization: Bearer <token> 传递;API Token 也支持 X-API-Token: <token>。",
}
security_schemes["ApiTokenHeader"] = {
"type": "apiKey",
"in": "header",
"name": "X-API-Token",
"description": "API Token 也可以通过 X-API-Token 请求头传递。",
}
for path, methods in schema.get("paths", {}).items():
if not path.startswith("/api/") or path in {"/api/login"}:
continue
security = [{"BearerAuth": []}] if _is_login_only_api(path) else [{"BearerAuth": []}, {"ApiTokenHeader": []}]
for operation in methods.values():
if isinstance(operation, dict):
operation["security"] = security
app.openapi_schema = schema
return schema
app = create_app()
+274
View File
@@ -0,0 +1,274 @@
import base64
import hashlib
import hmac
import json
import secrets
from dataclasses import asdict, dataclass
from datetime import date, datetime, time, timedelta, timezone
from threading import RLock
from typing import Any
from app.config import BASE_DIR
API_TOKENS_PATH = BASE_DIR / "api_tokens.json"
API_TOKEN_PREFIX = "cap_"
API_TOKEN_SECRET = "CapaReportApiTokenSecret2026"
_STORE_LOCK = RLock()
@dataclass
class ApiTokenRecord:
id: str
name: str
token_hash: str
prefix: str
suffix: str
created_at: str
expires_at: str | None
enabled: bool
last_used_at: str | None = None
last_used_from: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def ensure_store() -> None:
if API_TOKENS_PATH.exists():
return
with _STORE_LOCK:
if API_TOKENS_PATH.exists():
return
API_TOKENS_PATH.write_text(json.dumps({"tokens": []}, ensure_ascii=False, indent=2), encoding="utf-8")
def list_tokens() -> list[dict[str, Any]]:
with _STORE_LOCK:
return [record_to_public_dict(record) for record in _load_records()]
def create_token(
name: str,
expires_in_days: int | None = None,
enabled: bool = True,
expires_at: str | None = None,
) -> tuple[str, dict[str, Any]]:
raw_token = generate_raw_token()
resolved_expires_at = normalize_expiration(expires_at)
if resolved_expires_at is None and expires_in_days is not None:
resolved_expires_at = expires_at_from_days(expires_in_days)
record = ApiTokenRecord(
id=secrets.token_hex(8),
name=name.strip() or "未命名 Token",
token_hash=hash_token(raw_token),
prefix=raw_token[:12],
suffix=raw_token[-12:],
created_at=utc_now(),
expires_at=resolved_expires_at,
enabled=bool(enabled),
)
with _STORE_LOCK:
records = _load_records()
records.append(record)
_save_records(records)
return raw_token, record_to_public_dict(record)
def update_token(token_id: str, **changes: Any) -> dict[str, Any]:
with _STORE_LOCK:
records = _load_records()
for index, record in enumerate(records):
if record.id != token_id:
continue
if "name" in changes and isinstance(changes["name"], str):
record.name = changes["name"].strip() or record.name
if "enabled" in changes:
record.enabled = bool(changes["enabled"])
if "expires_at" in changes:
record.expires_at = normalize_expiration(changes["expires_at"])
records[index] = record
_save_records(records)
return record_to_public_dict(record)
raise KeyError(f"Token not found: {token_id}")
def delete_token(token_id: str) -> None:
with _STORE_LOCK:
records = [record for record in _load_records() if record.id != token_id]
_save_records(records)
def regenerate_token(token_id: str) -> tuple[str, dict[str, Any]]:
with _STORE_LOCK:
records = _load_records()
for index, record in enumerate(records):
if record.id != token_id:
continue
raw_token = generate_raw_token()
record.token_hash = hash_token(raw_token)
record.prefix = raw_token[:12]
record.suffix = raw_token[-12:]
record.created_at = utc_now()
record.last_used_at = None
record.last_used_from = None
records[index] = record
_save_records(records)
return raw_token, record_to_public_dict(record)
raise KeyError(f"Token not found: {token_id}")
def verify_api_token(raw_token: str) -> dict[str, Any] | None:
ensure_store()
token_hash = hash_token(raw_token)
now = datetime.now(timezone.utc)
with _STORE_LOCK:
for record in _load_records():
if not record.enabled or record.token_hash != token_hash:
continue
expires_at = parse_datetime(record.expires_at)
if expires_at and expires_at < now:
continue
return record_to_context(record)
return None
def touch_token_usage(raw_token: str, source: str | None = None) -> None:
token_hash = hash_token(raw_token)
now = utc_now()
with _STORE_LOCK:
records = _load_records()
updated = False
for index, record in enumerate(records):
if record.token_hash != token_hash:
continue
record.last_used_at = now
record.last_used_from = source or record.last_used_from
records[index] = record
updated = True
break
if updated:
_save_records(records)
def generate_raw_token() -> str:
return API_TOKEN_PREFIX + secrets.token_urlsafe(36)
def hash_token(raw_token: str) -> str:
digest = hmac.new(API_TOKEN_SECRET.encode(), raw_token.encode(), hashlib.sha256).digest()
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
def normalize_expiration(value: str | None) -> str | None:
if value is None:
return None
trimmed = str(value).strip()
if not trimmed:
return None
parsed = parse_datetime(trimmed)
if parsed is None:
raise ValueError("Invalid expiration date")
return parsed.isoformat(timespec="seconds")
def expires_at_from_days(days: int) -> str:
safe_days = max(int(days), 1)
expires_at = datetime.now(timezone.utc) + timedelta(days=safe_days)
return expires_at.isoformat(timespec="seconds")
def parse_datetime(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
try:
parsed_date = date.fromisoformat(value)
except ValueError:
return None
parsed = datetime.combine(parsed_date, time.max)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def record_to_public_dict(record: ApiTokenRecord, include_hash: bool = False) -> dict[str, Any]:
expires_at = parse_datetime(record.expires_at)
data = {
"id": record.id,
"name": record.name,
"prefix": record.prefix,
"suffix": record.suffix,
"created_at": record.created_at,
"expires_at": record.expires_at,
"enabled": record.enabled,
"last_used_at": record.last_used_at,
"last_used_from": record.last_used_from,
"expired": bool(expires_at and expires_at < datetime.now(timezone.utc)),
}
if include_hash:
data["token_hash"] = record.token_hash
return data
def record_to_context(record: ApiTokenRecord) -> dict[str, Any]:
return {
"token_id": record.id,
"name": record.name,
"created_at": record.created_at,
"expires_at": record.expires_at,
"token_type": "api_token",
}
def _load_records() -> list[ApiTokenRecord]:
ensure_store()
try:
raw = json.loads(API_TOKENS_PATH.read_text(encoding="utf-8"))
except json.JSONDecodeError:
raw = {"tokens": []}
tokens = raw.get("tokens", []) if isinstance(raw, dict) else []
records: list[ApiTokenRecord] = []
for item in tokens:
if not isinstance(item, dict):
continue
records.append(
ApiTokenRecord(
id=str(item.get("id", "")) or secrets.token_hex(8),
name=str(item.get("name", "未命名 Token")),
token_hash=str(item.get("token_hash", "")),
prefix=str(item.get("prefix", "")),
suffix=str(item.get("suffix", "")),
created_at=str(item.get("created_at", utc_now())),
expires_at=item.get("expires_at"),
enabled=bool(item.get("enabled", True)),
last_used_at=item.get("last_used_at"),
last_used_from=item.get("last_used_from"),
)
)
records.sort(key=lambda record: record.created_at, reverse=True)
return records
def _save_records(records: list[ApiTokenRecord]) -> None:
payload = {"tokens": [record.to_dict() for record in records]}
API_TOKENS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+8
View File
@@ -1,4 +1,12 @@
# 项目上下文记录
## 2026-05-23:新增 API Token 和离线 API 文档
- 新增 `app/services/api_tokens.py` 和 `app/api/routers/api_tokens.py`:API Token 存储在运行时 `api_tokens.json`,只保存 HMAC-SHA256 哈希、前后缀、启用状态、到期时间和最近使用信息;完整 Token 仅在创建或重生成时返回一次。`api_tokens.json` 已加入 `.gitignore`。
- `app/auth.py` 增加登录态和访问态解析:登录态只接受 JWT/cookie,业务访问态接受登录 JWT、`Authorization: Bearer <api-token>` 和 `X-API-Token`。Token 管理、系统配置、授权和 API 文档仍要求登录后访问。
- `app/main.py` 的全局鉴权中间件改为 JWT + API Token 双鉴权;业务 API 可由 API Token 调用,`/api/openapi.json` 和 `/api/docs-info` 仅登录后可见。OpenAPI schema 同时声明 `BearerAuth` 和 `ApiTokenHeader`,便于外部系统对接。
- 前端新增 `frontend/src/components/ApiCenter.vue`,侧边栏新增 `API 中心`;页面支持 Token 列表、创建、编辑启停/有效期、重生成、删除、复制一次性完整 Token,并内嵌本地 `swagger-ui-dist` 文档。API 中心懒加载,避免 Swagger UI 影响普通页面首屏。
- 新增前端依赖 `swagger-ui-dist`,并在 `frontend/package.json` 中关闭 Scarf 安装期匿名统计配置,确保运行期和离线部署不依赖外网 CDN。
- 已验证:`.venv\Scripts\python.exe -m compileall app`、`npm run build` 通过;本地服务实测未登录访问 `/api/openapi.json` 返回 401,登录创建临时 Token 成功,API Token 可访问 `/api/database/tables` 且不能访问 `/api/tokens`,登录访问 `/api/openapi.json` 成功,非法到期日期返回 400,临时 Token 已删除。
## 2026-05-22:统一日志框与桌面端下载路径反馈
- 数据处理页菜单从“数据上传”改为“数据处理”,路由标题同步改为“数据处理”。
+17
View File
@@ -12,6 +12,7 @@
"@vicons/ionicons5": "^0.13.0",
"monaco-editor": "^0.55.1",
"naive-ui": "^2.42.0",
"swagger-ui-dist": "^5.32.6",
"vue": "^3.5.0",
"vue-router": "^5.0.7"
},
@@ -1005,6 +1006,13 @@
"win32"
]
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@tauri-apps/api": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
@@ -1883,6 +1891,15 @@
"node": ">=0.10.0"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.32.6",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.6.tgz",
"integrity": "sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+4
View File
@@ -8,11 +8,15 @@
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"scarfSettings": {
"enabled": false
},
"dependencies": {
"@tauri-apps/api": "^2.11.0",
"@vicons/ionicons5": "^0.13.0",
"monaco-editor": "^0.55.1",
"naive-ui": "^2.42.0",
"swagger-ui-dist": "^5.32.6",
"vue": "^3.5.0",
"vue-router": "^5.0.7"
},
+3 -1
View File
@@ -146,6 +146,7 @@ import { useMessage, type DropdownOption, type MenuOption, NIcon } from 'naive-u
import {
ChevronDownOutline,
CloudUploadOutline,
CodeSlashOutline,
ConstructOutline,
FileTrayFullOutline,
LogOutOutline,
@@ -173,7 +174,7 @@ const activationLoading = ref(false);
const activationKeyLabel = ref('2026/06/20');
const licenseMessage = ref('输入激活码可将授权到期日期延长 30 天。');
const brandClickCount = ref(0);
const menuKeys = ['workflow', 'history', 'database', 'script', 'settings'] as const;
const menuKeys = ['workflow', 'history', 'database', 'script', 'api-center', 'settings'] as const;
type MenuKey = (typeof menuKeys)[number];
let brandClickResetTimer: number | undefined;
@@ -182,6 +183,7 @@ const menuOptions: MenuOption[] = [
{ label: '处理历史', key: 'history', icon: renderIcon(FileTrayFullOutline) },
{ label: '数据管理', key: 'database', icon: renderIcon(ServerOutline) },
{ label: '脚本编辑', key: 'script', icon: renderIcon(ConstructOutline) },
{ label: 'API 中心', key: 'api-center', icon: renderIcon(CodeSlashOutline) },
{ label: '系统设置', key: 'settings', icon: renderIcon(SettingsOutline) }
];
+525
View File
@@ -0,0 +1,525 @@
<template>
<div class="api-center-workspace">
<section class="api-token-panel">
<n-card size="small" class="work-card api-card" title="API Token">
<div class="api-token-toolbar">
<n-button size="small" type="primary" @click="openCreateDialog">
<template #icon><n-icon><AddOutline /></n-icon></template>
生成 Token
</n-button>
<n-button size="small" tertiary :loading="loadingTokens" @click="loadTokens">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
刷新
</n-button>
</div>
<n-spin v-if="loadingTokens" class="api-token-loading" />
<n-empty v-else-if="tokens.length === 0" description="暂无 API Token" />
<n-scrollbar v-else class="api-token-list">
<div v-for="token in tokens" :key="token.id" class="api-token-item">
<div class="api-token-main">
<div class="api-token-title-row">
<span class="api-token-name">{{ token.name }}</span>
<n-tag size="small" :type="token.enabled && !token.expired ? 'success' : 'warning'">
{{ tokenStatusText(token) }}
</n-tag>
</div>
<div class="api-token-mask monospace">{{ token.prefix }}...{{ token.suffix }}</div>
<div class="api-token-meta">
<span>到期:{{ formatExpiration(token.expires_at) }}</span>
<span>最近使用:{{ formatDateTime(token.last_used_at) }}</span>
</div>
</div>
<div class="api-token-actions">
<n-button size="tiny" tertiary @click="openEditDialog(token)">编辑</n-button>
<n-button size="tiny" tertiary type="warning" @click="confirmRegenerate(token)">重生成</n-button>
<n-button size="tiny" tertiary type="error" @click="confirmDelete(token)">删除</n-button>
</div>
</div>
</n-scrollbar>
</n-card>
</section>
<section class="api-docs-panel">
<n-card size="small" class="work-card api-docs-card">
<template #header>
<div class="api-docs-header">
<div>
<span class="api-docs-title">API 文档</span>
<p class="api-docs-hint">Token 通过 Authorization: Bearer &lt;token&gt; 传递,也兼容 X-API-Token。</p>
</div>
<n-space size="small">
<n-button size="small" tertiary @click="copyHeaderSample">复制传参示例</n-button>
<n-button size="small" tertiary tag="a" href="/api/openapi.json" target="_blank">OpenAPI JSON</n-button>
</n-space>
</div>
</template>
<div ref="swaggerHost" class="swagger-host" />
</n-card>
</section>
<n-modal
v-model:show="tokenDialogVisible"
preset="card"
:title="editingToken ? '编辑 Token' : '生成 Token'"
:style="{ width: '440px', maxWidth: 'calc(100vw - 32px)' }"
:mask-closable="!savingToken"
>
<n-form label-placement="top">
<n-form-item label="名称">
<n-input v-model:value="tokenForm.name" placeholder="例如:外部系统接入" />
</n-form-item>
<n-form-item label="有效期">
<n-radio-group v-model:value="tokenForm.permanent">
<n-space>
<n-radio :value="true">永久有效</n-radio>
<n-radio :value="false">指定日期</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item v-if="!tokenForm.permanent" label="到期日期">
<n-date-picker
v-model:formatted-value="tokenForm.expires_at"
type="date"
value-format="yyyy-MM-dd"
clearable
class="full-width"
/>
</n-form-item>
<n-form-item label="启用">
<n-switch v-model:value="tokenForm.enabled" />
</n-form-item>
</n-form>
<template #footer>
<div class="api-token-dialog-footer">
<n-button :disabled="savingToken" @click="tokenDialogVisible = false">取消</n-button>
<n-button type="primary" :loading="savingToken" @click="saveToken">
{{ editingToken ? '保存' : '生成' }}
</n-button>
</div>
</template>
</n-modal>
<n-modal
v-model:show="rawTokenVisible"
preset="card"
title="Token 已生成"
:style="{ width: '520px', maxWidth: 'calc(100vw - 32px)' }"
>
<n-alert type="warning" :bordered="false">
这是唯一一次显示完整 Token,请立即复制并妥善保存。
</n-alert>
<n-input
class="raw-token-input"
:value="rawToken"
type="textarea"
readonly
:autosize="{ minRows: 3, maxRows: 6 }"
/>
<template #footer>
<div class="api-token-dialog-footer">
<n-button @click="rawTokenVisible = false">关闭</n-button>
<n-button type="primary" @click="copyRawToken">复制 Token</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { AddOutline, RefreshOutline } from '@vicons/ionicons5';
import SwaggerUIBundle from 'swagger-ui-dist/swagger-ui-bundle.js';
import 'swagger-ui-dist/swagger-ui.css';
import { apiGet, apiPost, getToken } from '../api/client';
import type { ApiMessage, ApiTokenListResponse, ApiTokenMutationResponse, ApiTokenRecord } from '../types';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
type SwaggerSystem = {
getSystem?: () => {
authActions?: {
authorize?: (payload: Record<string, { name: string; schema: unknown; value: string }>) => void;
};
};
};
interface SwaggerRequest {
headers: Record<string, string>;
}
const message = useMessage();
const dialog = useDialog();
const swaggerHost = ref<HTMLDivElement | null>(null);
const tokens = ref<ApiTokenRecord[]>([]);
const loadingTokens = ref(false);
const savingToken = ref(false);
const tokenDialogVisible = ref(false);
const rawTokenVisible = ref(false);
const rawToken = ref('');
const editingToken = ref<ApiTokenRecord | null>(null);
const tokenForm = reactive({
name: '',
permanent: true,
expires_at: '',
enabled: true
});
let swaggerUi: SwaggerSystem | undefined;
onMounted(async () => {
setPageHeader({
actions: [
{ key: 'refresh-api-tokens', label: '刷新 Token', icon: RefreshOutline, loading: loadingTokens, onClick: loadTokens }
]
});
await loadTokens();
await nextTick();
initSwagger();
});
onBeforeUnmount(() => {
resetPageHeader();
});
async function loadTokens() {
loadingTokens.value = true;
try {
const result = await apiGet<ApiTokenListResponse>('/api/tokens');
tokens.value = result.tokens;
} catch (error) {
message.error(error instanceof Error ? error.message : '加载 Token 失败');
} finally {
loadingTokens.value = false;
}
}
function initSwagger() {
if (!swaggerHost.value) return;
swaggerHost.value.innerHTML = '';
swaggerUi = SwaggerUIBundle({
url: '/api/openapi.json',
domNode: swaggerHost.value,
deepLinking: true,
docExpansion: 'none',
persistAuthorization: true,
validatorUrl: null,
showCommonExtensions: true,
showExtensions: true,
requestInterceptor: (request: SwaggerRequest) => {
const token = getToken();
if (token) {
request.headers.Authorization = `Bearer ${token}`;
}
return request;
},
onComplete: () => {
const token = getToken();
if (!token) return;
swaggerUi?.getSystem?.().authActions?.authorize?.({
BearerAuth: {
name: 'BearerAuth',
schema: { type: 'http', scheme: 'bearer' },
value: token
}
});
}
}) as SwaggerSystem;
}
function openCreateDialog() {
editingToken.value = null;
tokenForm.name = '';
tokenForm.permanent = true;
tokenForm.expires_at = '';
tokenForm.enabled = true;
tokenDialogVisible.value = true;
}
function openEditDialog(token: ApiTokenRecord) {
editingToken.value = token;
tokenForm.name = token.name;
tokenForm.permanent = !token.expires_at;
tokenForm.expires_at = token.expires_at?.slice(0, 10) || '';
tokenForm.enabled = token.enabled;
tokenDialogVisible.value = true;
}
async function saveToken() {
const payload = {
id: editingToken.value?.id,
name: tokenForm.name.trim(),
permanent: tokenForm.permanent,
expires_at: tokenForm.permanent ? null : tokenForm.expires_at,
enabled: tokenForm.enabled
};
if (!payload.name) {
message.warning('请输入 Token 名称');
return;
}
if (!payload.permanent && !payload.expires_at) {
message.warning('请选择到期日期');
return;
}
savingToken.value = true;
try {
const result = editingToken.value
? await apiPost<ApiTokenMutationResponse>('/api/tokens/update', payload)
: await apiPost<ApiTokenMutationResponse>('/api/tokens/create', payload);
message.success(result.message || (editingToken.value ? 'Token 已更新' : 'Token 已生成'));
tokenDialogVisible.value = false;
if (result.token) {
rawToken.value = result.token;
rawTokenVisible.value = true;
}
await loadTokens();
} catch (error) {
message.error(error instanceof Error ? error.message : '保存 Token 失败');
} finally {
savingToken.value = false;
}
}
function confirmRegenerate(token: ApiTokenRecord) {
dialog.warning({
title: '重新生成 Token',
content: `确认重新生成「${token.name}」吗?旧 Token 会立即失效。`,
positiveText: '重新生成',
negativeText: '取消',
onPositiveClick: () => regenerateToken(token)
});
}
async function regenerateToken(token: ApiTokenRecord) {
try {
const result = await apiPost<ApiTokenMutationResponse>('/api/tokens/regenerate', { id: token.id });
rawToken.value = result.token || '';
rawTokenVisible.value = Boolean(rawToken.value);
message.success(result.message || 'Token 已重新生成');
await loadTokens();
} catch (error) {
message.error(error instanceof Error ? error.message : '重新生成 Token 失败');
}
}
function confirmDelete(token: ApiTokenRecord) {
dialog.error({
title: '删除 Token',
content: `确认删除「${token.name}」吗?此操作不可恢复。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => deleteToken(token)
});
}
async function deleteToken(token: ApiTokenRecord) {
try {
const result = await apiPost<ApiMessage>('/api/tokens/delete', { id: token.id });
message.success(result.message || 'Token 已删除');
await loadTokens();
} catch (error) {
message.error(error instanceof Error ? error.message : '删除 Token 失败');
}
}
async function copyRawToken() {
await writeClipboard(rawToken.value);
message.success('Token 已复制');
}
async function copyHeaderSample() {
await writeClipboard('Authorization: Bearer <token>\nX-API-Token: <token>');
message.success('传参示例已复制');
}
async function writeClipboard(text: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.top = '-1000px';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
} finally {
document.body.removeChild(textarea);
}
}
function tokenStatusText(token: ApiTokenRecord): string {
if (!token.enabled) return '已停用';
if (token.expired) return '已过期';
return '可用';
}
function formatExpiration(value: string | null): string {
if (!value) return '永久有效';
return value.replace('T', ' ').slice(0, 10);
}
function formatDateTime(value?: string | null): string {
if (!value) return '-';
return value.replace('T', ' ').slice(0, 19);
}
</script>
<style scoped>
.api-center-workspace {
display: grid;
grid-template-columns: minmax(320px, 400px) minmax(0, 1fr);
gap: 20px;
height: 100%;
min-height: 0;
min-width: 0;
padding: 24px 32px;
}
.api-token-panel,
.api-docs-panel,
.api-card,
.api-docs-card {
min-height: 0;
}
.api-card,
.api-docs-card {
display: flex;
height: 100%;
flex-direction: column;
}
.api-token-toolbar {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.api-token-loading {
margin: 48px auto;
}
.api-token-list {
height: calc(100vh - 236px);
min-height: 320px;
}
.api-token-item {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--td-border-color-light);
}
.api-token-main {
min-width: 0;
flex: 1;
}
.api-token-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.api-token-name {
min-width: 0;
overflow: hidden;
color: var(--td-text-color-primary);
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.api-token-mask {
margin-top: 6px;
color: var(--td-text-color-secondary);
font-size: 12px;
}
.api-token-meta {
display: flex;
flex-direction: column;
gap: 3px;
margin-top: 8px;
color: var(--td-text-color-placeholder);
font-size: 12px;
}
.api-token-actions {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 6px;
}
.api-docs-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.api-docs-title {
color: var(--td-text-color-primary);
font-size: 15px;
font-weight: 600;
}
.api-docs-hint {
margin: 4px 0 0;
color: var(--td-text-color-secondary);
font-size: 12px;
}
.swagger-host {
height: calc(100vh - 188px);
min-height: 420px;
overflow: auto;
border: 1px solid var(--td-border-color-light);
border-radius: var(--td-radius-default);
background: #fff;
}
.raw-token-input {
margin-top: 12px;
}
.api-token-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
:deep(.swagger-ui) {
color: #1f2937;
}
:deep(.swagger-ui .scheme-container) {
box-shadow: none;
}
@media (max-width: 1100px) {
.api-center-workspace {
grid-template-columns: 1fr;
height: auto;
padding: 16px;
}
.api-token-list,
.swagger-host {
height: auto;
max-height: 70vh;
}
}
</style>
+2
View File
@@ -6,6 +6,7 @@ import DatabasePanel from './components/DatabasePanel.vue';
import SettingsPanel from './components/SettingsPanel.vue';
const ScriptPanel = () => import('./components/ScriptPanel.vue');
const ApiCenter = () => import('./components/ApiCenter.vue');
export const routes: RouteRecordRaw[] = [
{ path: '/', redirect: { name: 'workflow' } },
@@ -13,6 +14,7 @@ export const routes: RouteRecordRaw[] = [
{ path: '/history', name: 'history', component: HistoryPanel, meta: { title: '处理历史' } },
{ path: '/database', name: 'database', component: DatabasePanel, meta: { title: '数据管理' } },
{ path: '/script', name: 'script', component: ScriptPanel, meta: { title: '脚本编辑' } },
{ path: '/api-center', name: 'api-center', component: ApiCenter, meta: { title: 'API 中心' } },
{ path: '/settings', name: 'settings', component: SettingsPanel, meta: { title: '系统设置' } },
{ path: '/:pathMatch(.*)*', redirect: { name: 'workflow' } }
];
+25
View File
@@ -146,3 +146,28 @@ export interface ApiMessage {
update?: string;
task_id?: string;
}
export interface ApiTokenRecord {
id: string;
name: string;
prefix: string;
suffix: string;
created_at: string;
expires_at: string | null;
enabled: boolean;
expired: boolean;
last_used_at?: string | null;
last_used_from?: string | null;
}
export interface ApiTokenListResponse {
success: boolean;
tokens: ApiTokenRecord[];
}
export interface ApiTokenMutationResponse {
success: boolean;
message?: string;
token?: string;
record: ApiTokenRecord;
}
+5
View File
@@ -1 +1,6 @@
/// <reference types="vite/client" />
declare module 'swagger-ui-dist/swagger-ui-bundle.js' {
const SwaggerUIBundle: (options: Record<string, unknown>) => unknown;
export default SwaggerUIBundle;
}