feat: 新增 API Token 和离线 API 文档
This commit is contained in:
@@ -45,6 +45,7 @@ CapacityReportData/
|
||||
.env.local
|
||||
auth.ini
|
||||
license.dat
|
||||
api_tokens.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -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 连接
|
||||
|
||||
@@ -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 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
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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:统一日志框与桌面端下载路径反馈
|
||||
|
||||
- 数据处理页菜单从“数据上传”改为“数据处理”,路由标题同步改为“数据处理”。
|
||||
|
||||
Generated
+17
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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) }
|
||||
];
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
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' } }
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user