diff --git a/.gitignore b/.gitignore index 2a25a00..22f0c54 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ CapacityReportData/ .env.local auth.ini license.dat +api_tokens.json # Logs *.log diff --git a/README.md b/README.md index 60b7ca7..2bf73ac 100644 --- a/README.md +++ b/README.md @@ -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 +``` + +也兼容: +```text +X-API-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 连接 diff --git a/app/api/routers/api_tokens.py b/app/api/routers/api_tokens.py new file mode 100644 index 0000000..5c5d944 --- /dev/null +++ b/app/api/routers/api_tokens.py @@ -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 ", + "alt_header": "X-API-Token: ", + "note": "API 文档仅登录后可访问。", + } diff --git a/app/auth.py b/app/auth.py index c2f8686..c1712db 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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("=") diff --git a/app/main.py b/app/main.py index 293eb1e..17e24e8 100644 --- a/app/main.py +++ b/app/main.py @@ -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 传递;API Token 也支持 X-API-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() diff --git a/app/services/api_tokens.py b/app/services/api_tokens.py new file mode 100644 index 0000000..eaa45fd --- /dev/null +++ b/app/services/api_tokens.py @@ -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") diff --git a/docs/project_context.md b/docs/project_context.md index 0610493..bdd1821 100644 --- a/docs/project_context.md +++ b/docs/project_context.md @@ -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 ` 和 `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:统一日志框与桌面端下载路径反馈 - 数据处理页菜单从“数据上传”改为“数据处理”,路由标题同步改为“数据处理”。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3626500..7358708 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 8e320d5..20b1b07 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" }, diff --git a/frontend/src/AppShell.vue b/frontend/src/AppShell.vue index 4afca6f..0c147cb 100644 --- a/frontend/src/AppShell.vue +++ b/frontend/src/AppShell.vue @@ -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) } ]; diff --git a/frontend/src/components/ApiCenter.vue b/frontend/src/components/ApiCenter.vue new file mode 100644 index 0000000..a0473e7 --- /dev/null +++ b/frontend/src/components/ApiCenter.vue @@ -0,0 +1,525 @@ + + + + + diff --git a/frontend/src/router.ts b/frontend/src/router.ts index df77d22..4fd9c68 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -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' } } ]; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d8601d3..c2edcd9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 11f02fe..51b6ab8 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1 +1,6 @@ /// + +declare module 'swagger-ui-dist/swagger-ui-bundle.js' { + const SwaggerUIBundle: (options: Record) => unknown; + export default SwaggerUIBundle; +}