feat: 新增 API Token 和离线 API 文档
This commit is contained in:
@@ -45,6 +45,7 @@ CapacityReportData/
|
|||||||
.env.local
|
.env.local
|
||||||
auth.ini
|
auth.ini
|
||||||
license.dat
|
license.dat
|
||||||
|
api_tokens.json
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ CapacityReport 用于导入每周容量报表数据,按 `Configure.json` 的
|
|||||||
- MySQL 数据表查看、清空、删除、CSV/XLSX 导出。
|
- MySQL 数据表查看、清空、删除、CSV/XLSX 导出。
|
||||||
- SQL 脚本在线查看、保存和执行。
|
- SQL 脚本在线查看、保存和执行。
|
||||||
- 处理历史、日志查看、历史原始数据打包下载。
|
- 处理历史、日志查看、历史原始数据打包下载。
|
||||||
|
- API Token 管理和登录后可见的离线 API 文档,便于内网系统直接调用上传、远程处理、查表和 SQL 执行接口。
|
||||||
- 按 ZIP 文件名数据日期校验本地授权期限,过期后可输入激活码顺延。
|
- 按 ZIP 文件名数据日期校验本地授权期限,过期后可输入激活码顺延。
|
||||||
- 系统设置:数据库、远程数据源、Sheet 过滤、字段映射、历史保留、密码修改。
|
- 系统设置:数据库、远程数据源、Sheet 过滤、字段映射、历史保留、密码修改。
|
||||||
- 发行形态:Server Portable、Tauri 桌面版、Docker 服务端版。
|
- 发行形态:Server Portable、Tauri 桌面版、Docker 服务端版。
|
||||||
@@ -219,12 +220,33 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W
|
|||||||
- `ExtractField`:字段映射配置。
|
- `ExtractField`:字段映射配置。
|
||||||
|
|
||||||
登录密码保存在本地 `auth.ini`,该文件不应提交到版本库。
|
登录密码保存在本地 `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 次,可主动打开授权延期窗口。
|
授权到期日期保存在本地加密文件 `license.dat`,默认到期日由 `app/services/license.py` 中的 `DEFAULT_EXPIRES_ON` 控制,当前为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。登录后连续点击左上角品牌图标 8 次,可主动打开授权延期窗口。
|
||||||
|
|
||||||
## 常用接口
|
## 常用接口
|
||||||
|
|
||||||
- `POST /api/login`:登录
|
- `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/change-password`:修改密码
|
||||||
- `POST /api/upload`:上传文件
|
- `POST /api/upload`:上传文件
|
||||||
- `POST /api/remote/test`:测试 FTP/SFTP 连接
|
- `POST /api/remote/test`:测试 FTP/SFTP 连接
|
||||||
|
|||||||
@@ -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
@@ -4,8 +4,11 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
from app.config import BASE_DIR
|
from app.config import BASE_DIR
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +18,12 @@ DEFAULT_USERNAME = "root"
|
|||||||
DEFAULT_PASSWORD = "Capacity"
|
DEFAULT_PASSWORD = "Capacity"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuthContext:
|
||||||
|
kind: str
|
||||||
|
payload: dict
|
||||||
|
|
||||||
|
|
||||||
def _ensure_auth_ini() -> None:
|
def _ensure_auth_ini() -> None:
|
||||||
if AUTH_INI_PATH.exists():
|
if AUTH_INI_PATH.exists():
|
||||||
return
|
return
|
||||||
@@ -68,6 +77,61 @@ def verify_jwt_token(token: str) -> Optional[dict]:
|
|||||||
return None
|
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:
|
def _encode_json(data: dict) -> str:
|
||||||
raw = json.dumps(data, separators=(",", ":")).encode()
|
raw = json.dumps(data, separators=(",", ":")).encode()
|
||||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
|||||||
+95
-13
@@ -1,21 +1,43 @@
|
|||||||
from pathlib import Path
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 import state
|
||||||
from app.api.routers import auth, cache, config, database, health, history, license, remote, script, tasks, upload
|
from app.api.routers import (
|
||||||
from app.auth import verify_jwt_token
|
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.config import BASE_DIR
|
||||||
|
from app.services.api_tokens import touch_token_usage
|
||||||
|
|
||||||
|
|
||||||
APP_VERSION = "3.0.0"
|
APP_VERSION = "3.0.0"
|
||||||
APP_HOST = "0.0.0.0"
|
APP_HOST = "0.0.0.0"
|
||||||
APP_PORT = 9081
|
APP_PORT = 9081
|
||||||
FRONTEND_DIST_DIR = BASE_DIR / "frontend" / "dist"
|
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:
|
def create_app() -> FastAPI:
|
||||||
@@ -27,6 +49,7 @@ def create_app() -> FastAPI:
|
|||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
openapi_url=None,
|
openapi_url=None,
|
||||||
)
|
)
|
||||||
|
app.openapi = lambda: custom_openapi(app) # type: ignore[method-assign]
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -36,31 +59,43 @@ def create_app() -> FastAPI:
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.middleware("http")(jwt_middleware)
|
app.middleware("http")(auth_middleware)
|
||||||
register_routes(app)
|
register_routes(app)
|
||||||
register_frontend(app)
|
register_frontend(app)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
async def jwt_middleware(request: Request, call_next):
|
async def auth_middleware(request: Request, call_next):
|
||||||
if request.method == "OPTIONS":
|
if request.method == "OPTIONS":
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
if path.startswith("/api/") and path != "/api/login":
|
if path == "/api/login":
|
||||||
auth_header = request.headers.get("Authorization")
|
return await call_next(request)
|
||||||
if not auth_header or not auth_header.startswith("Bearer "):
|
|
||||||
|
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"})
|
return JSONResponse(status_code=401, content={"detail": "未授权,请提供有效的 Token"})
|
||||||
|
|
||||||
token = auth_header.split(" ", 1)[1]
|
request.state.auth_context = access_context
|
||||||
if not verify_jwt_token(token):
|
if access_context.kind == "api_token":
|
||||||
return JSONResponse(status_code=401, content={"detail": "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)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app: FastAPI) -> None:
|
def register_routes(app: FastAPI) -> None:
|
||||||
routers = [
|
routers = [
|
||||||
|
api_tokens.router,
|
||||||
auth.router,
|
auth.router,
|
||||||
health.router,
|
health.router,
|
||||||
upload.router,
|
upload.router,
|
||||||
@@ -78,12 +113,23 @@ def register_routes(app: FastAPI) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def register_frontend(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("/", include_in_schema=False)
|
||||||
@app.get("/{path:path}", include_in_schema=False)
|
@app.get("/{path:path}", include_in_schema=False)
|
||||||
async def serve_frontend(path: str = ""):
|
async def serve_frontend(path: str = ""):
|
||||||
if path.startswith("api/"):
|
if path.startswith("api/"):
|
||||||
raise HTTPException(status_code=404, detail="接口不存在")
|
raise HTTPException(status_code=404, detail="接口不存在")
|
||||||
|
|
||||||
return _serve_frontend_path(path)
|
return _serve_frontend_path(path)
|
||||||
|
|
||||||
|
|
||||||
@@ -118,6 +164,42 @@ def _safe_file(root: Path, path: str) -> Path | None:
|
|||||||
return requested
|
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()
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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:统一日志框与桌面端下载路径反馈
|
## 2026-05-22:统一日志框与桌面端下载路径反馈
|
||||||
|
|
||||||
- 数据处理页菜单从“数据上传”改为“数据处理”,路由标题同步改为“数据处理”。
|
- 数据处理页菜单从“数据上传”改为“数据处理”,路由标题同步改为“数据处理”。
|
||||||
|
|||||||
Generated
+17
@@ -12,6 +12,7 @@
|
|||||||
"@vicons/ionicons5": "^0.13.0",
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"naive-ui": "^2.42.0",
|
"naive-ui": "^2.42.0",
|
||||||
|
"swagger-ui-dist": "^5.32.6",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
"vue-router": "^5.0.7"
|
"vue-router": "^5.0.7"
|
||||||
},
|
},
|
||||||
@@ -1005,6 +1006,13 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.11.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
|
||||||
@@ -1883,6 +1891,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
|
|||||||
@@ -8,11 +8,15 @@
|
|||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview --host 0.0.0.0"
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
},
|
},
|
||||||
|
"scarfSettings": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@vicons/ionicons5": "^0.13.0",
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"naive-ui": "^2.42.0",
|
"naive-ui": "^2.42.0",
|
||||||
|
"swagger-ui-dist": "^5.32.6",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
"vue-router": "^5.0.7"
|
"vue-router": "^5.0.7"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ import { useMessage, type DropdownOption, type MenuOption, NIcon } from 'naive-u
|
|||||||
import {
|
import {
|
||||||
ChevronDownOutline,
|
ChevronDownOutline,
|
||||||
CloudUploadOutline,
|
CloudUploadOutline,
|
||||||
|
CodeSlashOutline,
|
||||||
ConstructOutline,
|
ConstructOutline,
|
||||||
FileTrayFullOutline,
|
FileTrayFullOutline,
|
||||||
LogOutOutline,
|
LogOutOutline,
|
||||||
@@ -173,7 +174,7 @@ const activationLoading = ref(false);
|
|||||||
const activationKeyLabel = ref('2026/06/20');
|
const activationKeyLabel = ref('2026/06/20');
|
||||||
const licenseMessage = ref('输入激活码可将授权到期日期延长 30 天。');
|
const licenseMessage = ref('输入激活码可将授权到期日期延长 30 天。');
|
||||||
const brandClickCount = ref(0);
|
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];
|
type MenuKey = (typeof menuKeys)[number];
|
||||||
let brandClickResetTimer: number | undefined;
|
let brandClickResetTimer: number | undefined;
|
||||||
|
|
||||||
@@ -182,6 +183,7 @@ const menuOptions: MenuOption[] = [
|
|||||||
{ label: '处理历史', key: 'history', icon: renderIcon(FileTrayFullOutline) },
|
{ label: '处理历史', key: 'history', icon: renderIcon(FileTrayFullOutline) },
|
||||||
{ label: '数据管理', key: 'database', icon: renderIcon(ServerOutline) },
|
{ label: '数据管理', key: 'database', icon: renderIcon(ServerOutline) },
|
||||||
{ label: '脚本编辑', key: 'script', icon: renderIcon(ConstructOutline) },
|
{ label: '脚本编辑', key: 'script', icon: renderIcon(ConstructOutline) },
|
||||||
|
{ label: 'API 中心', key: 'api-center', icon: renderIcon(CodeSlashOutline) },
|
||||||
{ label: '系统设置', key: 'settings', icon: renderIcon(SettingsOutline) }
|
{ label: '系统设置', key: 'settings', icon: renderIcon(SettingsOutline) }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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 <token> 传递,也兼容 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>
|
||||||
@@ -6,6 +6,7 @@ import DatabasePanel from './components/DatabasePanel.vue';
|
|||||||
import SettingsPanel from './components/SettingsPanel.vue';
|
import SettingsPanel from './components/SettingsPanel.vue';
|
||||||
|
|
||||||
const ScriptPanel = () => import('./components/ScriptPanel.vue');
|
const ScriptPanel = () => import('./components/ScriptPanel.vue');
|
||||||
|
const ApiCenter = () => import('./components/ApiCenter.vue');
|
||||||
|
|
||||||
export const routes: RouteRecordRaw[] = [
|
export const routes: RouteRecordRaw[] = [
|
||||||
{ path: '/', redirect: { name: 'workflow' } },
|
{ path: '/', redirect: { name: 'workflow' } },
|
||||||
@@ -13,6 +14,7 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
{ path: '/history', name: 'history', component: HistoryPanel, meta: { title: '处理历史' } },
|
{ path: '/history', name: 'history', component: HistoryPanel, meta: { title: '处理历史' } },
|
||||||
{ path: '/database', name: 'database', component: DatabasePanel, meta: { title: '数据管理' } },
|
{ path: '/database', name: 'database', component: DatabasePanel, meta: { title: '数据管理' } },
|
||||||
{ path: '/script', name: 'script', component: ScriptPanel, 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: '/settings', name: 'settings', component: SettingsPanel, meta: { title: '系统设置' } },
|
||||||
{ path: '/:pathMatch(.*)*', redirect: { name: 'workflow' } }
|
{ path: '/:pathMatch(.*)*', redirect: { name: 'workflow' } }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -146,3 +146,28 @@ export interface ApiMessage {
|
|||||||
update?: string;
|
update?: string;
|
||||||
task_id?: 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;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+5
@@ -1 +1,6 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module 'swagger-ui-dist/swagger-ui-bundle.js' {
|
||||||
|
const SwaggerUIBundle: (options: Record<string, unknown>) => unknown;
|
||||||
|
export default SwaggerUIBundle;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user