refactor: 拆分 API Token 管理和文档页面

This commit is contained in:
2026-05-25 11:37:10 +08:00
parent 263aa734c3
commit 45ad399141
10 changed files with 653 additions and 208 deletions
+4 -4
View File
@@ -9,9 +9,9 @@ CapacityReport 用于导入每周容量报表数据,按 `Configure.json` 的
- MySQL 数据表查看、清空、删除、CSV/XLSX 导出。
- SQL 脚本在线查看、保存和执行。
- 处理历史、日志查看、历史原始数据打包下载。
- API Token 管理和登录后可见的离线 API 文档,便于内网系统直接调用上传、远程处理、查表和 SQL 执行接口。
- 系统设置内置 API Token 管理,左侧提供登录后可见的离线 API 文档,便于内网系统直接调用上传、远程处理、查表和 SQL 执行接口。
- 按 ZIP 文件名数据日期校验本地授权期限,过期后可输入激活码顺延。
- 系统设置:数据库、远程数据源、Sheet 过滤、字段映射、历史保留、密码修改。
- 系统设置:数据库、远程数据源、Sheet 过滤、字段映射、API Token、历史保留、密码修改。
- 发行形态:Server Portable、Tauri 桌面版、Docker 服务端版。
## 技术栈
@@ -224,7 +224,7 @@ API Token 保存在本地 `api_tokens.json`,只保存 HMAC 哈希和显示用
## API Token 与文档
登录后进入左侧 `API 中心` 可生成、停用、设置永久或指定日期到期的 API Token,并查看内置 API 文档。API 文档基于本地 `swagger-ui-dist` 打包,不依赖外网 CDN。
登录后在 `系统设置 > API Token` 可生成、停用、设置永久或指定日期到期的 API Token;左侧 `API 文档` 只展示内置 Swagger 文档。API 文档基于本地 `swagger-ui-dist` 打包,不依赖外网 CDN。
如果 Token 未设置为永久有效,则必须明确选择到期日期;到期、停用或重生成后的旧 Token 都不能继续调用业务 API。
Token 调用方式:
@@ -238,7 +238,7 @@ X-API-Token: <token>
```
API Token 与登录态一样可访问业务 API,包括文件上传、远程下载并处理、数据库表查询、筛选查询、导出以及 `/api/database/execute` 自定义 SQL 执行。Token 管理、系统配置、授权和 API 文档本身仍要求登录后访问。
桌面端和配置了 `VITE_API_BASE` 的部署中,API 文档会自动使用当前后端基址加载 OpenAPI,并且不会覆盖用户在 Swagger UI 中手动填写的 API Token。
桌面端和配置了 `VITE_API_BASE` 的部署中,API 文档会自动使用当前后端基址加载 OpenAPI,并且不会覆盖用户在 Swagger UI 中手动填写的 API Token;Swagger 默认隐藏底部 Schemas 区域,接口分组、说明和常用请求示例使用中文。
授权到期日期保存在本地加密文件 `license.dat`,默认到期日由 `app/services/license.py` 中的 `DEFAULT_EXPIRES_ON` 控制,当前为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。登录后连续点击左上角品牌图标 8 次,可主动打开授权延期窗口。
+350 -7
View File
@@ -38,6 +38,270 @@ LOGIN_ONLY_API_PREFIXES = (
"/api/tokens",
)
LOGIN_ONLY_API_PATHS = {"/api/openapi.json", "/api/docs-ui", "/api/docs-info"}
TAG_LABELS = {
"auth": "认证",
"upload": "数据处理",
"remote": "远程数据",
"tasks": "任务状态",
"history": "处理历史",
"database": "数据库",
"script": "脚本",
"config": "系统配置",
"cache": "缓存",
"license": "授权",
"api-tokens": "API Token",
"health": "健康检查",
}
OPENAPI_TAGS = [
{"name": "认证", "description": "登录、修改密码等登录态接口。"},
{"name": "数据处理", "description": "上传源数据并启动容量报表处理流程。"},
{"name": "远程数据", "description": "测试 FTP/SFTP 连接,并从远程目录下载后自动处理。"},
{"name": "任务状态", "description": "查看当前任务、处理进度和日志。"},
{"name": "处理历史", "description": "查询、下载和清理历史处理记录及原始数据。"},
{"name": "数据库", "description": "列出表、查询表、导出表和执行 SQL。API Token 可调用这些业务接口。"},
{"name": "脚本", "description": "查看、保存和手动执行报表 SQL 脚本。"},
{"name": "系统配置", "description": "数据库、远程数据源、过滤规则、字段映射等配置。仅登录态可调用。"},
{"name": "缓存", "description": "查看服务端缓存占用。"},
{"name": "授权", "description": "查看和延长程序授权有效期。仅登录态可调用。"},
{"name": "API Token", "description": "生成、编辑、重生成和删除 API Token。仅登录态可调用。"},
{"name": "健康检查", "description": "服务健康状态检查。"},
]
OPENAPI_OPERATION_DOCS = {
("post", "/api/login"): {
"summary": "登录系统",
"description": "使用系统账号密码登录,成功后返回登录 JWT。",
"example": {"username": "root", "password": "capacity"},
},
("post", "/api/change-password"): {
"summary": "修改登录密码",
"description": "修改当前登录用户密码,需要登录 JWT,不支持 API Token 调用。",
"example": {"current_password": "capacity", "new_password": "new-password"},
},
("get", "/health"): {"summary": "健康检查", "description": "返回服务进程是否可用。"},
("post", "/api/upload/create"): {
"summary": "创建上传会话",
"description": "创建一个待上传的数据处理会话,返回 session_id。通常用于分批上传文件。",
},
("post", "/api/upload"): {
"summary": "上传源数据文件",
"description": "上传 ZIP、CSV 或 Excel 数据文件。可传 session_id 追加到已有上传会话;不传则自动创建并锁定任务。",
"request_description": "multipart/form-data,files 为一个或多个文件,session_id 可选。",
},
("post", "/api/upload/complete/{session_id}"): {
"summary": "完成上传会话",
"description": "标记上传会话文件数量,用于前端展示。",
"parameters": {"session_id": "上传会话 ID。"},
},
("post", "/api/process/start"): {
"summary": "开始处理已上传数据",
"description": "对指定上传会话目录执行解压、入库和报表 SQL 脚本。",
"example": {"task_id": "20260519_172457"},
},
("post", "/api/process/status"): {
"summary": "查询处理任务状态",
"description": "返回任务阶段、状态、日志和错误详情。",
"example": {"task_id": "20260519_172457"},
},
("get", "/api/process/active"): {
"summary": "查询当前活跃任务",
"description": "返回当前是否有上传、远程下载、处理或脚本任务正在执行。",
},
("get", "/api/task/status"): {
"summary": "查询全局任务锁",
"description": "返回全局任务锁状态、任务 ID、阶段和最近日志。",
},
("post", "/api/task/lock"): {
"summary": "锁定任务",
"description": "内部接口:手动占用全局任务锁。",
"example": {"task_id": "manual-task"},
},
("post", "/api/task/unlock"): {
"summary": "释放任务锁",
"description": "内部接口:释放全局任务锁。传 task_id 时只释放匹配的任务。",
"example": {"task_id": "manual-task"},
},
("post", "/api/remote/test"): {
"summary": "测试远程数据源",
"description": "测试 FTP/SFTP 连接。请求体为空时使用系统设置中的远程数据源配置。",
"example": {
"protocol": "sftp",
"host": "127.0.0.1",
"port": 22,
"user": "user",
"passwd": "your-password",
"remote_dir": "/CapacityReportData",
"passive": True,
"timeout": 30,
"auto_delete_source": False,
},
},
("post", "/api/remote/start"): {
"summary": "远程下载并处理",
"description": "从已配置的 FTP/SFTP 目录递归下载源数据,然后自动执行完整处理流程。",
},
("post", "/api/history"): {
"summary": "查询处理历史",
"description": "按最近时间返回处理历史记录。",
"example": {"limit": 50},
},
("post", "/api/history/detail"): {
"summary": "查询历史详情",
"description": "返回指定历史记录的基础信息和完整日志。",
"example": {"record_id": "20260519_172457"},
},
("post", "/api/history/download"): {
"summary": "下载历史原始数据",
"description": "将历史记录对应工作目录压缩为 ZIP 后下载,下载响应完成后自动清理临时压缩包。",
"example": {"record_id": "20260519_172457"},
},
("post", "/api/history/size"): {
"summary": "查询历史目录大小",
"description": "统计指定历史记录工作目录的文件数和占用空间。",
"example": {"record_id": "20260519_172457"},
},
("post", "/api/history/delete"): {
"summary": "删除历史记录",
"description": "删除指定处理历史及其本地缓存数据。",
"example": {"record_id": "20260519_172457"},
},
("post", "/api/history/clear"): {"summary": "清空处理历史", "description": "删除全部处理历史及其缓存数据。"},
("get", "/api/database/info"): {
"summary": "查询数据库信息",
"description": "返回 MySQL 版本、LOAD DATA INFILE 可用性等诊断信息。",
},
("post", "/api/database/test"): {"summary": "测试数据库连接", "description": "测试当前数据库配置是否可连接。"},
("get", "/api/database/tables"): {"summary": "列出所有数据表", "description": "返回当前数据库中的全部表名。"},
("post", "/api/database/tables"): {"summary": "列出所有数据表", "description": "返回当前数据库中的全部表名。"},
("post", "/api/database/table/info"): {
"summary": "查询数据表结构",
"description": "返回指定表的字段结构和行数。",
"example": {"table_name": "4G_结果表"},
},
("post", "/api/database/table/data"): {
"summary": "分页查询数据表",
"description": "按页读取指定表数据,可指定排序字段和排序方向。",
"example": {
"table_name": "4G_结果表",
"page": 1,
"page_size": 50,
"order_by": "日均流量(GB)",
"order_dir": "DESC",
},
},
("post", "/api/database/table/query"): {
"summary": "按条件查询数据表",
"description": "支持分页、排序和字段模糊查询。filters 的 key 为字段名,value 为模糊匹配值。",
"example": {
"table_name": "4G_结果表",
"page": 1,
"page_size": 50,
"filters": {"小区名称": "广州"},
"order_by": "日均流量(GB)",
"order_dir": "DESC",
},
},
("post", "/api/database/table/truncate"): {
"summary": "清空数据表",
"description": "保留表结构,删除指定表的全部数据。",
"example": {"table_name": "4G_UD"},
},
("post", "/api/database/table/drop"): {
"summary": "删除数据表",
"description": "删除指定数据表。",
"example": {"table_name": "4G_UD"},
},
("post", "/api/database/table/drop-all"): {"summary": "删除全部数据表", "description": "删除当前数据库中的全部表。"},
("post", "/api/database/execute"): {
"summary": "执行自定义 SQL",
"description": "执行任意 SQL,包括 SELECT、UPDATE、INSERT、DROP 等。请仅在可信内网环境使用。",
"example": {"sql": "SELECT * FROM `4G_结果表` LIMIT 10"},
},
("post", "/api/download"): {
"summary": "导出数据表",
"description": "导出 CSV 或 XLSX。CSV 每次只能导出一张表,XLSX 可选择多张表并按表名分 sheet。",
"example": {"format": "xlsx", "table_names": ["4G_结果表", "5G_结果表"]},
},
("get", "/api/script/content"): {"summary": "读取 SQL 脚本", "description": "读取当前 ReportScript.sql 内容和修改时间。"},
("post", "/api/script/save"): {
"summary": "保存 SQL 脚本",
"description": "覆盖保存 ReportScript.sql 内容。",
"example": {"content": "SELECT 1;"},
},
("post", "/api/script/execute"): {"summary": "手动执行 SQL 脚本", "description": "直接执行当前 ReportScript.sql,并返回脚本任务 ID。"},
("get", "/api/config"): {"summary": "读取基础配置", "description": "读取当前系统基础配置。仅登录态可访问。"},
("get", "/api/config/full"): {"summary": "读取完整配置", "description": "读取数据库、远程数据源、历史保留、过滤规则和字段映射配置。"},
("post", "/api/config/mysql"): {
"summary": "保存数据库配置",
"description": "更新 MySQL 连接配置。",
"example": {"host": "capacity-mysql", "port": 3306, "user": "root", "passwd": "your-password", "dbname": "CapacityReport"},
},
("post", "/api/config/remote"): {
"summary": "保存远程数据源配置",
"description": "更新 FTP/SFTP 自动下载配置。",
"example": {
"enabled": True,
"protocol": "sftp",
"host": "127.0.0.1",
"port": 22,
"user": "user",
"passwd": "your-password",
"remote_dir": "/CapacityReportData",
"passive": True,
"timeout": 30,
"auto_delete_source": False,
},
},
("post", "/api/config/history-retention"): {
"summary": "保存历史保留配置",
"description": "设置处理历史是否自动清理,以及保留最近多少次记录。",
"example": {"enabled": True, "keep_count": 20},
},
("post", "/api/config/sheet-filter"): {
"summary": "保存 Sheet 过滤规则",
"description": "设置需要跳过处理的 Sheet 关键字列表。",
"example": ["指标(计数器)", "Template"],
},
("post", "/api/config/extract-fields"): {
"summary": "保存字段映射配置",
"description": "设置 Excel/CSV 源字段到数据库字段的映射规则。",
"example": [{"Field": "日期时间", "Extract": ["开始时间"], "Type": "datetime"}],
},
("get", "/api/config/download"): {"summary": "下载配置文件", "description": "下载当前 Configure.json。"},
("post", "/api/config/upload"): {
"summary": "上传配置文件",
"description": "上传并应用 Configure.json。仅登录态可访问。",
"request_description": "multipart/form-data,file 为 Configure.json 文件。",
},
("get", "/api/cache/size"): {"summary": "查询缓存大小", "description": "统计当前 cache 目录的大小、文件数和目录数。"},
("get", "/api/license/status"): {"summary": "查询授权状态", "description": "返回当前授权到期日期和激活 key 标签。"},
("post", "/api/license/activate"): {
"summary": "激活授权延期",
"description": "提交激活码,将授权到期日期延长 30 天。",
"example": {"code": "sha256-value"},
},
("get", "/api/tokens"): {"summary": "列出 API Token", "description": "返回已创建 Token 的脱敏列表。仅登录态可访问。"},
("post", "/api/tokens/create"): {
"summary": "生成 API Token",
"description": "创建新的 API Token。完整 Token 只在本次响应中返回一次。",
"example": {"name": "外部系统接入", "permanent": True, "expires_at": None, "enabled": True},
},
("post", "/api/tokens/update"): {
"summary": "编辑 API Token",
"description": "修改 Token 名称、启停状态和有效期。",
"example": {"id": "token-id", "name": "外部系统接入", "permanent": False, "expires_at": "2026-12-31", "enabled": True},
},
("post", "/api/tokens/regenerate"): {
"summary": "重生成 API Token",
"description": "重生成完整 Token,旧 Token 立即失效。",
"example": {"id": "token-id"},
},
("post", "/api/tokens/delete"): {
"summary": "删除 API Token",
"description": "删除指定 Token。",
"example": {"id": "token-id"},
},
("get", "/api/docs-info"): {"summary": "查询 API 文档入口", "description": "返回 API 文档和 OpenAPI JSON 地址。仅登录态可访问。"},
}
def create_app() -> FastAPI:
@@ -123,7 +387,7 @@ def register_frontend(app: FastAPI) -> None:
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)
return RedirectResponse(url="/api-docs", status_code=302)
@app.get("/", include_in_schema=False)
@app.get("/{path:path}", include_in_schema=False)
@@ -172,7 +436,16 @@ 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)
schema = get_openapi(
title="CapacityReport API",
version=app.version,
description=(
"容量报表数据处理系统接口文档。业务接口支持登录 JWT 或 API Token;"
"系统配置、授权、Token 管理和文档本身仅支持登录态访问。"
),
routes=app.routes,
)
schema["tags"] = OPENAPI_TAGS
components = schema.setdefault("components", {})
security_schemes = components.setdefault("securitySchemes", {})
security_schemes["BearerAuth"] = {
@@ -189,17 +462,87 @@ def custom_openapi(app: FastAPI) -> dict:
}
for path, methods in schema.get("paths", {}).items():
if not path.startswith("/api/") or path in {"/api/login"}:
for method, operation in methods.items():
if not isinstance(operation, dict):
continue
security = [{"BearerAuth": []}] if _is_login_only_api(path) else [{"BearerAuth": []}, {"ApiTokenHeader": []}]
for operation in methods.values():
if isinstance(operation, dict):
operation["security"] = security
operation["tags"] = [TAG_LABELS.get(tag, tag) for tag in operation.get("tags", [])]
operation["operationId"] = _make_operation_id(method, path)
if path.startswith("/api/") and path not in {"/api/login"}:
operation["security"] = (
[{"BearerAuth": []}]
if _is_login_only_api(path)
else [{"BearerAuth": []}, {"ApiTokenHeader": []}]
)
_apply_operation_doc(operation, OPENAPI_OPERATION_DOCS.get((method.lower(), path), {}))
app.openapi_schema = schema
return schema
def _make_operation_id(method: str, path: str) -> str:
normalized_path = (
path.strip("/")
.replace("/", "_")
.replace("-", "_")
.replace("{", "")
.replace("}", "")
)
return f"{method.lower()}_{normalized_path or 'root'}"
def _apply_operation_doc(operation: dict, doc: dict) -> None:
if not doc:
return
for key in ("summary", "description"):
value = doc.get(key)
if value:
operation[key] = value
request_description = doc.get("request_description")
if request_description and isinstance(operation.get("requestBody"), dict):
operation["requestBody"]["description"] = request_description
if "example" in doc:
_set_request_example(operation, doc["example"])
parameter_descriptions = doc.get("parameters")
if isinstance(parameter_descriptions, dict):
_set_parameter_descriptions(operation, parameter_descriptions)
def _set_request_example(operation: dict, example: object) -> None:
request_body = operation.get("requestBody")
if not isinstance(request_body, dict):
return
content = request_body.get("content")
if not isinstance(content, dict):
return
media = content.get("application/json")
if not isinstance(media, dict):
media = next((value for value in content.values() if isinstance(value, dict)), None)
if isinstance(media, dict):
media["example"] = example
def _set_parameter_descriptions(operation: dict, descriptions: dict[str, str]) -> None:
parameters = operation.get("parameters")
if not isinstance(parameters, list):
return
for parameter in parameters:
if not isinstance(parameter, dict):
continue
name = parameter.get("name")
if isinstance(name, str) and name in descriptions:
parameter["description"] = descriptions[name]
app = create_app()
+8
View File
@@ -1,5 +1,13 @@
# 项目上下文记录
## 2026-05-25:拆分 API Token 管理和 API 文档
- 前端删除旧 `frontend/src/components/ApiCenter.vue`,拆为 `ApiTokenManager.vue` 和 `ApiDocs.vue`:API Token 管理迁入 `系统设置 > API Token` 独立分页,左侧菜单“API 中心”改为“API 文档”,只展示 Swagger 文档。
- API 文档正式路由为 `/api-docs`,旧 `/api-center` 保留为前端兼容别名;后端 `/api/docs-ui` 改为跳转 `/api-docs`。Swagger UI 仍从登录后可见的 `/api/openapi.json` 加载,Token 传递示例指向系统设置中的 API Token 分页。
- `app/main.py` 的 OpenAPI 后处理增加中文 tag、接口 summary/description、常用请求示例和稳定 operationId;业务 API 声明登录 JWT 或 API Token 鉴权,配置/授权/Token 管理/文档接口仍只声明登录 JWT。Swagger 前端隐藏底部 Schemas 区域,减少噪音。
- 修复登录页按钮点击不触发登录的问题:登录按钮改为显式调用 `submit()`,保留密码框回车提交,并在 loading 时防止重复提交。
- 已验证:`.venv\Scripts\python.exe -m compileall app`、`npm run build` 通过;真实 HTTP 检查未登录 `/api/openapi.json` 返回 401、登录后返回 `CapacityReport API`;浏览器实测点击登录按钮可登录,左侧显示 `API 文档`,系统设置显示 `API Token` 分页,API 文档页可见中文 Swagger 分组。
## 2026-05-23:新增 API Token 和离线 API 文档
- API Token 验收补强:非永久 Token 必须明确传入到期日期,非法或缺失到期日期返回 400;到期、停用、重生成旧 Token 均会拒绝业务 API。已实测 API Token 可执行 `/api/database/execute` 和 `/api/database/table/query`,但不能访问 `/api/tokens` 管理接口。
- `frontend/src/components/ApiCenter.vue` 的 Swagger UI 改为通过 `apiUrl('/api/openapi.json')` 加载文档,并在请求拦截器中只在未手动填写 Authorization 时补登录 JWT;桌面端或配置 `VITE_API_BASE` 时,Try it out 请求会自动补全后端基址,避免相对 `/api/*` 请求打到错误 origin。
+1 -1
View File
@@ -183,7 +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: 'API 文档', key: 'api-center', icon: renderIcon(CodeSlashOutline) },
{ label: '系统设置', key: 'settings', icon: renderIcon(SettingsOutline) }
];
+202
View File
@@ -0,0 +1,202 @@
<template>
<div class="api-docs-workspace">
<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 在系统设置的 API Token 分页生成,通过 Authorization: Bearer &lt;token&gt; 传递,也兼容 X-API-Token。</p>
</div>
<n-space size="small">
<n-button size="small" tertiary @click="copyHeaderSample">复制传参示例</n-button>
<n-button size="small" tertiary tag="a" :href="openApiUrl" target="_blank">OpenAPI JSON</n-button>
</n-space>
</div>
</template>
<div ref="swaggerHost" class="swagger-host" />
</n-card>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { useMessage } from 'naive-ui';
import SwaggerUIBundle from 'swagger-ui-dist/swagger-ui-bundle.js';
import 'swagger-ui-dist/swagger-ui.css';
import { apiUrl, getApiBaseUrl, getToken } from '../api/client';
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>;
url?: string;
}
const message = useMessage();
const swaggerHost = ref<HTMLDivElement | null>(null);
const openApiUrl = apiUrl('/api/openapi.json');
let swaggerUi: SwaggerSystem | undefined;
onMounted(async () => {
setPageHeader({
actions: [
{ key: 'copy-api-token-header', label: '复制传参示例', onClick: copyHeaderSample }
]
});
await nextTick();
initSwagger();
});
onBeforeUnmount(() => {
resetPageHeader();
});
function initSwagger() {
if (!swaggerHost.value) return;
swaggerHost.value.innerHTML = '';
swaggerUi = SwaggerUIBundle({
url: openApiUrl,
domNode: swaggerHost.value,
requestSnippetsEnabled: true,
deepLinking: true,
docExpansion: 'list',
defaultModelsExpandDepth: -1,
displayRequestDuration: true,
persistAuthorization: true,
filter: true,
validatorUrl: null,
showCommonExtensions: true,
showExtensions: false,
requestInterceptor: (request: SwaggerRequest) => {
const token = getToken();
if (token && !request.headers.Authorization && !request.headers.authorization) {
request.headers.Authorization = `Bearer ${token}`;
}
if (request.url?.startsWith('/')) {
request.url = `${getApiBaseUrl()}${request.url}`;
}
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;
}
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);
}
}
</script>
<style scoped>
.api-docs-workspace {
display: flex;
height: 100%;
min-height: 0;
min-width: 0;
padding: 24px 32px;
}
.api-docs-card {
display: flex;
width: 100%;
min-height: 0;
flex-direction: column;
}
.api-docs-card > :deep(.n-card__content),
.api-docs-card > :deep(.n-card-content) {
flex: 1;
min-height: 0;
}
.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: 100%;
min-height: 0;
overflow: auto;
border: 1px solid var(--td-border-color-light);
border-radius: var(--td-radius-default);
background: #fff;
}
:deep(.swagger-ui) {
color: #1f2937;
}
:deep(.swagger-ui .scheme-container) {
box-shadow: none;
}
:deep(.swagger-ui .models),
:deep(.swagger-ui section.models) {
display: none !important;
}
@media (max-width: 900px) {
.api-docs-workspace {
height: auto;
padding: 16px;
}
.swagger-host {
min-height: 70vh;
}
}
</style>
@@ -1,8 +1,8 @@
<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">
<div class="api-token-manager" :class="{ embedded }">
<n-card size="small" class="work-card api-token-card" title="API Token">
<template #header-extra>
<n-space size="small">
<n-button size="small" type="primary" @click="openCreateDialog">
<template #icon><n-icon><AddOutline /></n-icon></template>
生成 Token
@@ -11,6 +11,11 @@
<template #icon><n-icon><RefreshOutline /></n-icon></template>
刷新
</n-button>
</n-space>
</template>
<div class="api-token-intro">
API Token 用于内网程序直接调用业务接口。完整 Token 只会在生成或重生成时显示一次,请立即复制保存。
</div>
<n-spin v-if="loadingTokens" class="api-token-loading" />
@@ -28,6 +33,7 @@
<div class="api-token-meta">
<span>到期:{{ formatExpiration(token.expires_at) }}</span>
<span>最近使用:{{ formatDateTime(token.last_used_at) }}</span>
<span v-if="token.last_used_from">来源:{{ token.last_used_from }}</span>
</div>
</div>
<div class="api-token-actions">
@@ -38,26 +44,6 @@
</div>
</n-scrollbar>
</n-card>
</section>
<section class="api-docs-panel">
<n-card size="small" class="work-card api-docs-card">
<template #header>
<div class="api-docs-header">
<div>
<span class="api-docs-title">API 文档</span>
<p class="api-docs-hint">Token 通过 Authorization: Bearer &lt;token&gt; 传递,也兼容 X-API-Token。</p>
</div>
<n-space size="small">
<n-button size="small" tertiary @click="copyHeaderSample">复制传参示例</n-button>
<n-button size="small" tertiary tag="a" :href="openApiUrl" 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"
@@ -129,32 +115,19 @@
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { 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, apiUrl, getApiBaseUrl, getToken } from '../api/client';
import { apiGet, apiPost } 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>;
url?: string;
}
defineProps<{
embedded?: boolean;
}>();
const message = useMessage();
const dialog = useDialog();
const swaggerHost = ref<HTMLDivElement | null>(null);
const tokens = ref<ApiTokenRecord[]>([]);
const loadingTokens = ref(false);
const savingToken = ref(false);
@@ -168,22 +141,9 @@ const tokenForm = reactive({
expires_at: '',
enabled: true
});
const openApiUrl = apiUrl('/api/openapi.json');
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();
onMounted(() => {
void loadTokens();
});
async function loadTokens() {
@@ -198,44 +158,6 @@ async function loadTokens() {
}
}
function initSwagger() {
if (!swaggerHost.value) return;
swaggerHost.value.innerHTML = '';
swaggerUi = SwaggerUIBundle({
url: openApiUrl,
domNode: swaggerHost.value,
requestSnippetsEnabled: true,
deepLinking: true,
docExpansion: 'none',
persistAuthorization: true,
validatorUrl: null,
showCommonExtensions: true,
showExtensions: true,
requestInterceptor: (request: SwaggerRequest) => {
const token = getToken();
if (token && !request.headers.Authorization && !request.headers.authorization) {
request.headers.Authorization = `Bearer ${token}`;
}
if (request.url?.startsWith('/')) {
request.url = `${getApiBaseUrl()}${request.url}`;
}
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 = '';
@@ -337,11 +259,6 @@ async function copyRawToken() {
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);
@@ -379,35 +296,35 @@ function formatDateTime(value?: string | null): string {
</script>
<style scoped>
.api-center-workspace {
display: grid;
grid-template-columns: minmax(320px, 400px) minmax(0, 1fr);
gap: 20px;
.api-token-manager {
min-height: 0;
}
.api-token-manager.embedded {
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 {
.api-token-card {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
}
.api-token-toolbar {
.api-token-card > :deep(.n-card__content),
.api-token-card > :deep(.n-card-content) {
display: flex;
justify-content: space-between;
gap: 8px;
flex: 1;
min-height: 0;
flex-direction: column;
}
.api-token-intro {
margin-bottom: 12px;
color: var(--td-text-color-secondary);
font-size: 13px;
line-height: 1.6;
}
.api-token-loading {
@@ -415,8 +332,8 @@ function formatDateTime(value?: string | null): string {
}
.api-token-list {
height: calc(100vh - 236px);
min-height: 320px;
flex: 1;
min-height: 220px;
}
.api-token-item {
@@ -450,6 +367,7 @@ function formatDateTime(value?: string | null): string {
.api-token-mask {
margin-top: 6px;
color: var(--td-text-color-secondary);
font-family: var(--td-font-family-mono);
font-size: 12px;
}
@@ -469,34 +387,6 @@ function formatDateTime(value?: string | null): string {
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;
}
@@ -507,25 +397,13 @@ function formatDateTime(value?: string | null): string {
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;
@media (max-width: 720px) {
.api-token-item {
flex-direction: column;
}
.api-token-list,
.swagger-host {
height: auto;
max-height: 70vh;
.api-token-actions {
flex-direction: row;
}
}
</style>
+3 -2
View File
@@ -16,7 +16,7 @@
<n-form-item label="密码" path="password">
<n-input v-model:value="form.password" type="password" show-password-on="click" @keydown.enter.prevent="submit" />
</n-form-item>
<n-button type="primary" block native-type="submit" :loading="loading">登录</n-button>
<n-button type="primary" block :loading="loading" @click="submit">登录</n-button>
</n-form>
</n-card>
</div>
@@ -26,7 +26,7 @@
import { reactive, ref } from 'vue';
import type { FormInst, FormRules } from 'naive-ui';
defineProps<{ loading: boolean }>();
const props = defineProps<{ loading: boolean }>();
const emit = defineEmits<{ login: [{ username: string; password: string }] }>();
const formRef = ref<FormInst | null>(null);
@@ -37,6 +37,7 @@ const rules: FormRules = {
};
async function submit() {
if (props.loading) return;
await formRef.value?.validate();
emit('login', { ...form });
}
@@ -298,6 +298,12 @@
</n-card>
</div>
</n-tab-pane>
<n-tab-pane name="api-token" tab="API Token">
<div class="settings-token-panel">
<ApiTokenManager embedded />
</div>
</n-tab-pane>
</n-tabs>
</n-card>
</div>
@@ -312,6 +318,7 @@ import { apiGet, apiPost, downloadGet, upload } from '../api/client';
import type { ApiMessage, AppConfig, HistoryRetentionConfig, RemoteDataConfig } from '../types';
import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
import ApiTokenManager from './ApiTokenManager.vue';
interface ExtractFieldConfig {
Field: string;
+2 -2
View File
@@ -6,7 +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');
const ApiDocs = () => import('./components/ApiDocs.vue');
export const routes: RouteRecordRaw[] = [
{ path: '/', redirect: { name: 'workflow' } },
@@ -14,7 +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: '/api-docs', alias: '/api-center', name: 'api-center', component: ApiDocs, meta: { title: 'API 文档' } },
{ path: '/settings', name: 'settings', component: SettingsPanel, meta: { title: '系统设置' } },
{ path: '/:pathMatch(.*)*', redirect: { name: 'workflow' } }
];
+6
View File
@@ -1318,6 +1318,12 @@ select {
max-width: 560px;
}
.settings-token-panel {
height: 100%;
min-height: 0;
overflow: hidden;
}
.card-actions {
display: flex;
align-items: center;