docs: 完成 MVP-2 技术设计与原型 (#36 #37)

This commit is contained in:
ila
2026-08-24 01:09:22 +08:00
parent da232bc68b
commit 875f5b0609
6 changed files with 236 additions and 8 deletions
+82 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Architecture-and-Code-Map.-
wiki_revision: abef747ecc01c1538c376d9c35a299d3bc4fb3c5
synchronized_at: 2026-08-22T04:05:56Z
wiki_revision: 2805f53ecd5f98fcf2a7602d47f4c89d55e40d7e
synchronized_at: 2026-08-23T17:03:07Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -308,3 +308,83 @@ go-admin 的 AutoMigrate 只能在隔离、可丢弃数据库中用于研究固
- `migrations/000005_provider_plaintext_credentials` 切换明文凭据、菜单和权限;遇到已有加密 credential 时 fail closed,要求人工受控转换。
- Casbin adapter 绑定 `sys_casbin_rule` 并关闭 AutoMigrate;管理端启动不会创建或修改表结构。
- 列表与普通 Provider 详情不返回完整 Key;只有单条 credential 接口回显并设置 `Cache-Control: no-store`、`Pragma: no-cache`。真实值仍禁止进入日志、审计、错误、文档、工单和截图。
## MVP-2 API 开放与治理技术设计草案(#36,待验收)
本节对应 [#35](https://git.ilapage.cn/OPC/chorus/issues/35) 和技术设计 [#36](https://git.ilapage.cn/OPC/chorus/issues/36)。它用于审核和拆分实施,**当前不是已放行的生产实现依据**;只有 #36 与原型 #37 同时经用户明确确认后,才能建立和执行生产单元工单。
### 范围和运行边界
- 面向现有受控 `users` 开放 API Key 和版本化生成接口;不开放注册、找回密码或匿名访问。
- 保持浏览器 Cookie/CSRF、管理员 JWT/Casbin、终端用户 API Key 三条认证链相互独立,不能相互回退或混用。
- 限流只控制请求速率和上游调用速度,不读取或写入点数,不形成计费、每日配额或额度余额。
- 当前支持单 portal 进程内嵌单 worker 的部署形态;限流器在同一进程和所有 worker goroutine 之间共享。MVP-2 不宣称支持多 portal 实例;未来横向扩容前必须另行设计共享限流存储。
- 不实施生成物、审计或 API Key 的自动删除;API Key 只撤销,审计追加写。
### 模块边界
```text
portal 浏览器路由 /api/*
-> session + CSRF -> 既有 generation service
程序路由 /openapi/v1/*
-> Authorization: Bearer <API Key>
-> API Key 认证 -> user/key 限流 -> 既有 generation service
-> 立即返回 202 或幂等重放 200,不调用上游
worker
-> 取得 generation lease
-> 路由和 circuit reservation
-> provider 限流
放行 -> BeginProviderAttempt -> 安全 HTTP Client -> 既有结果/失败协议
拒绝 -> 不调用上游、不写 Provider attempt、不改变 circuit,释放租约并延后领取
```
API Key 记录和 repository 接口属于 `internal/core`,只依赖 GORM/标准库;随机令牌生成、SHA-256/常量时间校验和限流器位于 platform/portal 外层;Gin middleware 只在 `portal`。OpenAPI handler 复用现有 `portal/service` 的提交、历史、详情和文件归属校验,不复制生成业务规则。
### API Key 数据和安全协议
新增 `api_keys`:`id BIGINT UNSIGNED`、`user_id`、`name VARCHAR(80)`、唯一 `public_id VARCHAR(24)`、`key_prefix VARCHAR(32)`、`secret_hash BINARY(32)`、可空 `expires_at`、`last_used_at`、`revoked_at`、`created_at/updated_at`。`user_id` 对 `users` 使用 RESTRICT;索引覆盖 `(user_id,revoked_at,created_at)`、`public_id` 唯一和 `last_used_at`。不存原始 Key,也不提供恢复字段。
令牌固定为 `chorus_<public_id>_<secret>`:`public_id` 和 `secret` 均由 `crypto/rand` 生成,secret 至少 256 bit,使用无填充 base64url;数据库只保存 secret 的 SHA-256。认证先按 public_id 定位候选,再对请求 secret 计算哈希并用常量时间比较;随机 secret 具有足够熵,不能使用用户密码式慢哈希。错误响应统一为 `invalid_api_key`,不区分不存在、已撤销、已到期或用户停用。
用户端接口位于 session/CSRF 下:`GET /api/api-keys`、`POST /api/api-keys`、`PATCH /api/api-keys/:id` 和幂等撤销 `DELETE /api/api-keys/:id`。创建响应设置 `Cache-Control: no-store` 与 `Pragma: no-cache`,完整 Key 只在该次 201 响应中出现;列表、详情、后续读取、日志、审计和管理端永远只返回名称、前缀、状态和时间。轮换流程为先创建新 Key,再撤销旧 Key,不增加会泄露旧值的“重新显示”或“旋转并返回旧值”接口。
管理员 API 位于既有 JWT/Casbin 边界:`GET /api/v1/chorus/api-keys` 查看用户和 Key 元数据,`POST /api/v1/chorus/api-keys/:id/revoke` 幂等撤销。管理员不能创建用户 Key、不能代用户读取完整 Key。用户自行撤销写 API 安全审计;管理员撤销同时写 `admin_audit_events` 和 API 安全审计。
### OpenAPI v1 契约
程序接口仅接受 `Authorization: Bearer`,拒绝 query 参数、Cookie 和管理员 JWT。认证成功后把 `user_id` 与 `api_key_id` 放入请求 context。响应使用既有 `{error:{code,message}}` 错误信封,时间统一为 UTC RFC3339,返回 `X-Request-ID`,认证和限流响应禁止缓存。
| 方法与路径 | 内容类型 | 行为 |
|---|---|---|
| `POST /openapi/v1/generations/text` | JSON | 提交文本生成,202;幂等重放 200 |
| `POST /openapi/v1/generations/image` | multipart | 复用已确认 metadata/files 契约,提交图片生成或编辑 |
| `GET /openapi/v1/generations` | query | 复用 user 作用域 HMAC cursor 历史 |
| `GET /openapi/v1/generations/:id` | - | 查询本人任务详情 |
| `GET /openapi/v1/generations/:id/inputs/:inputID` | - | 读取本人输入原图 |
| `GET /openapi/v1/generations/:id/outputs/:outputID` | - | 读取本人输出原图 |
| `GET /openapi/v1/generations/:id/outputs/:outputID/thumbnail` | - | 读取本人缩略图 |
| `GET /openapi/v1/openapi.json` | - | 返回与实现同版本的 OpenAPI 3.1 描述 |
OpenAPI 提交只从必填 `Idempotency-Key` 请求头读取幂等键,规则为去空格后 8 至 128 个可打印 ASCII 字符;不同时接受 body 同名字段。已有 `(user_id,idempotency_key)` 唯一约束继续保证浏览器和 API Key 跨渠道重放属于同一用户。下载 URL 必须指向 `/openapi/v1`,不能要求 API 客户端持有浏览器 Cookie。
关键状态码:参数/图片错误 400,缺失或无效 Key 401,跨用户对象统一 404,幂等冲突 409,速率限制 429 并带整数秒 `Retry-After`,路由未配置或服务暂不可用 503。接口规范以仓库中的 OpenAPI 3.1 文件为源,并用 handler 合约测试防止路径、状态码和 schema 漂移;MVP-2 不交付 Swagger UI、SDK、webhook、批量、取消或流式接口。
### 三维限流和异步延后
采用固定窗口限流器,配置必须分别给出正整数容量和窗口:用户提交维度、单 API Key 总请求维度、单 Provider 上游 attempt 维度。生产环境缺少任一配置时启动失败;开发和测试必须在 fixture 中显式设置,技术设计不臆造生产阈值。用户和 Key 同时检查,任一拒绝即返回 429;Provider 桶按 Provider ID 分开,但共享同一组策略值。
限流状态只保存在当前 portal 进程内,重启会重置窗口;这是流量保护而不是配额或计费账本。日志和审计不记录原始 Key。HTTP 429 包含 `Retry-After`,错误正文不披露 Provider、内部路由或其他用户的桶状态。
Provider 限流检查发生在真实上游调用前。放行后才执行 `BeginProviderAttempt`,因此本地限流不计入 `provider_attempt_count`、`attempts`、熔断或故障转移。若当前候选受限,可继续检查尚未调用的其他候选;全部候选都只因本地 Provider 限流不可用时,使用最早 retry 时间重新排队,而不是写失败终态。
为此 `generations` 增加非空 `available_at DATETIME(6)`,默认创建时间;队列只领取 `pending AND available_at <= NOW(6)` 或租约过期的 running。新增带 lease token 的 `Defer` CAS:把当前 running 任务改回 pending,清空 lease,设置 `available_at`,但不增加 Provider attempt、错误或 circuit;记录一条脱敏 `local_rate_limit` 调度事件。该事件不能被 `usedRouteMembers` 当作已调用候选。延后时间取最早允许时间并设置上限,避免异常配置长期占用任务。已有外部 429 仍按既有红线完成真实 Provider attempt、计入熔断并换下一家,两者不得合并。
### 审计、迁移和验证
新增 `api_audit_events` 作为追加写安全审计:`id`、可空 `user_id/api_key_id/generation_id`、`action`、`result`、`request_id`、可空 `status_code/error_code`、脱敏 `summary JSON`、`created_at`。记录 Key 创建/改名/用户撤销/管理员撤销、程序提交成功或拒绝、认证成功后的限流拒绝;普通成功查询不逐条入库,`last_used_at` 用节流更新。未知 Key 的认证失败只写受控安全日志和指标,避免攻击者制造无限审计行。summary 禁止 Prompt、文件内容、完整 Key、Authorization、Cookie、响应正文和原始 IP。
`000006_mvp2_openapi_governance` 创建两张表、`available_at`/队列索引和管理端菜单/API/Casbin seed;down 按稳定键删除 seed、索引、列和新表。必须在固定隔离 MySQL 8 上完成空库及含 MVP-1 数据的 up/down/up,并验证现有 generation 仍可领取。回退前停止 portal/worker;回退会删除 API Key 和新增审计,属于需人工确认的数据丢失操作,不能在生产自动执行。
必测矩阵包括:随机格式/哈希/常量比较、完整 Key 只出现一次、撤销/到期/用户停用、三条认证链不混用、CSRF、跨用户 404、幂等重放/冲突、JSON 与 multipart 边界、历史 cursor、文件归属、429 与 Retry-After、user/key/provider 三维桶、重启边界、Provider 本地限流不调用上游且不改变 retryable/circuit/attempt、Defer CAS 和旧 lease、审计脱敏、管理员权限、OpenAPI schema 合约、MySQL up/down/up,以及 mock 上游完整链路。不得使用真实 Provider 额度做回归。
+16 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Business-Rules-and-Glossary.-
wiki_revision: 88e750129170ef7fcce9d2194cc96bb74d9e462e
synchronized_at: 2026-08-22T03:42:45Z
wiki_revision: 5ba3d903cb98df6651906761b8233032c810fca6
synchronized_at: 2026-08-23T17:03:09Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -125,3 +125,17 @@ synchronized_at: 2026-08-22T03:42:45Z
## 已交付:Provider 凭据明文与单条回显
2026-08-22 用户明确接受风险并确认 [#30](https://git.ilapage.cn/OPC/chorus/issues/30):Provider API Key 目标状态改为明文落库,管理员可在单个 Provider 页面显式读取和再次隐藏。列表接口不得批量返回完整值,读取响应必须禁止缓存;日志、审计摘要、错误、工单、Wiki、原型和截图继续禁止记录真实密钥。#24 已完成明文迁移、单条回显和运行时主密钥移除,并通过隔离 MySQL 8、真实管理 API 联调及用户验收。
## MVP-2 已确认范围与待确认技术规则(2026-08-24)
以下产品范围已经用户确认:只服务现有受控用户;交付 API Key、版本化程序生成接口、用户/API Key/Provider 维度限流、安全审计和必要管理页面;不开放注册,不自动删除数据,不增加计费、点数或每日配额。
以下是 #36 待验收的技术规则,在用户明确确认前不得作为生产编码放行依据:
1. 完整 API Key 只在创建成功响应显示一次,数据库只保存随机 secret 的 SHA-256、public_id、前缀和元数据;用户和管理员后续都不能回显完整值。
2. API Key 只代表 `users`,不得认证 `sys_user`;Cookie/CSRF、管理员 JWT/Casbin 和 Bearer API Key 三条认证链不得混用或回退。
3. OpenAPI 同步链路只认证、限流、校验和落库,不调用上游;worker 是唯一调用上游的位置。
4. user/key/provider 限流是进程内固定窗口流量保护,不是计费或每日配额;当前只支持单 portal 实例,多实例部署需另行设计共享存储。
5. Provider 本地限流不得记作上游 429、不得增加 Provider attempt、不得触发熔断或消耗故障转移;所有候选受限时只延后 generation。
6. API 安全审计禁止保存 Prompt、文件、完整 Key、Authorization、Cookie、响应正文或原始 IP;未知 Key 失败不允许制造无限数据库审计行。
7. API Key 只撤销不物理删除;本 MVP 不自动删除 Key、审计、生成任务或生成物。
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Local-Development-and-Verification.-
wiki_revision: f4654349f06e495e499742354d3fb834ee08c4fd
synchronized_at: 2026-08-22T03:08:27Z
wiki_revision: b6d4960d79265a7823111989b93a8947f71c094e
synchronized_at: 2026-08-23T17:03:12Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -343,3 +343,9 @@ pnpm --dir admin-ui dev
```
管理端不会创建数据库、运行 migration、seed 或 AutoMigrate。首次本地运行先人工创建空库并执行 `migrate -path migrations ... up`,再通过 `chorus-admin-bootstrap` 建立管理员。已验证纯账号密码登录、动态 Chorus 菜单和 Provider 列表 API;Casbin 使用 `sys_casbin_rule`。前端固定基线默认端口为 9527,端口占用时 Vue CLI 会选择下一可用端口。
## MVP-2 设计中的配置门禁(#36,尚未实现)
生产实现将增加三组显式正整数配置,分别控制用户提交、单 API Key 总请求和单 Provider 上游 attempt 的容量与窗口。生产环境缺少任何一项都必须启动失败;开发/测试 fixture 显式给值,不把测试值冒充生产阈值。具体环境变量名在实现工单中按现有 `CHORUS_*` 命名固定,并同步本页和部署页。
当前部署边界仍为单 portal 进程内嵌 worker。MVP-2 限流状态在进程内共享,重启会重置窗口;在共享限流存储设计和验证完成前,不得把多个 portal 实例当作受支持部署。Provider 限流与延后队列只允许用 mock 上游验证。
+8 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Product-Requirements-Overview.-
wiki_revision: d19c38a02f3070a37e83d79a940098792fa7dbc5
synchronized_at: 2026-08-23T16:31:59Z
wiki_revision: aa3543138722e5653fbc9df5764e9e08298d15c1
synchronized_at: 2026-08-23T17:03:26Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -284,3 +284,9 @@ portal 已按 #18 v1 原型实现文生图与图片编辑切换、role_rule、
## #19 MVP-1 汇总验收状态(2026-08-24,已验收)
MVP-1 的设计、生产实现、变更工单和跨模块集成验收均已完成;用户于 2026-08-24 明确确认 #19 验收通过。交付范围包括多 Provider 路由/熔断/故障转移、多协议 mock 链路、运营管理端、portal 图片角色编辑与历史体验。真实 Provider 连通性仍未执行,后续如需验证必须单独授权。
## MVP-2 API 开放与治理(2026-08-24,设计中)
用户已确认 MVP-2 面向现有受控用户交付安全的 API Key、OpenAPI 生成接口、用户/API Key/Provider 限流、审计和必要的用户端/管理端页面;不开放注册,不实施自动数据删除,不增加计费、点数或每日配额。汇总工单为 [#35](https://git.ilapage.cn/OPC/chorus/issues/35),技术设计 [#36](https://git.ilapage.cn/OPC/chorus/issues/36) 正在审核准备,界面原型 [#37](https://git.ilapage.cn/OPC/chorus/issues/37) 的待审核快照为 `prototypes/37/v1/index.html`。
生产实现尚未放行。必须先由用户明确确认 #36 的认证、API、数据、限流、审计和迁移设计,并确认 #37 v1 覆盖的用户端创建/一次显示/撤销及管理端元数据检索/撤销、响应式和异常状态;两项均通过后才拆分生产实现单元工单。
+36
View File
@@ -0,0 +1,36 @@
(()=>{var u={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};var L=([e,a,t])=>{let r=document.createElementNS("http://www.w3.org/2000/svg",e);return Object.keys(a).forEach(o=>{r.setAttribute(o,String(a[o]))}),t?.length&&t.forEach(o=>{let s=L(o);r.appendChild(s)}),r},y=(e,a={})=>{let r={...u,...a};return L(["svg",r,e])};var R=e=>{for(let a in e)if(a.startsWith("aria-")||a==="role"||a==="title")return!0;return!1};var T=(...e)=>e.filter((a,t,r)=>!!a&&a.trim()!==""&&r.indexOf(a)===t).join(" ").trim();var q=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(a,t,r)=>r?r.toUpperCase():t.toLowerCase());var b=e=>{let a=q(e);return a.charAt(0).toUpperCase()+a.slice(1)};var G=e=>Array.from(e.attributes).reduce((a,t)=>(a[t.name]=t.value,a),{}),U=e=>typeof e=="string"?e:!e||!e.class?"":e.class&&typeof e.class=="string"?e.class.split(" "):e.class&&Array.isArray(e.class)?e.class:"",p=(e,{nameAttr:a,icons:t,attrs:r})=>{let o=e.getAttribute(a);if(o==null)return;let s=b(o),f=t[s];if(!f)return console.warn(`${e.outerHTML} icon name was not found in the provided icons object.`);let l=G(e),O=R(l)?{}:{"aria-hidden":"true"},F={...u,"data-lucide":o,...O,...r,...l},v=U(l),H=U(r),D=T("lucide",`lucide-${o}`,...v,...H);D&&Object.assign(F,{class:D});let E=y(f,F);return e.parentNode?.replaceChild(E,e)};var m=[["path",{d:"M20 6 9 17l-5-5"}]];var d=[["circle",{cx:"12",cy:"12",r:"10"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16"}]];var x=[["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"M12 6v6h4"}]];var i=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"}]];var n=[["path",{d:"M12 15V3"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["path",{d:"m7 10 5 5 5-5"}]];var c=[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"}],["path",{d:"m2 2 20 20"}]];var C=[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"}],["circle",{cx:"12",cy:"12",r:"3"}]];var h=[["path",{d:"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"}],["path",{d:"M14 2v5a1 1 0 0 0 1 1h5"}],["path",{d:"M10 9H8"}],["path",{d:"M16 13H8"}],["path",{d:"M16 17H8"}]];var S=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2"}],["circle",{cx:"9",cy:"9",r:"2"}],["path",{d:"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"}]];var g=[["path",{d:"m16 17 5-5-5-5"}],["path",{d:"M21 12H9"}],["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"}]];var w=[["path",{d:"M5 12h14"}],["path",{d:"M12 5v14"}]];var k=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"}],["path",{d:"M21 3v5h-5"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"}],["path",{d:"M8 16H3v5"}]];var P=[["path",{d:"m21 21-4.34-4.34"}],["circle",{cx:"11",cy:"11",r:"8"}]];var A=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"}],["path",{d:"m9 12 2 2 4-4"}]];var M=[["path",{d:"M18 6 6 18"}],["path",{d:"m6 6 12 12"}]];var B=({icons:e={},nameAttr:a="data-lucide",attrs:t={},root:r=document,inTemplates:o}={})=>{if(!Object.values(e).length)throw new Error(`Please provide an icons object.
If you want to use all the icons you can import it like:
\`import { createIcons, icons } from 'lucide';
lucide.createIcons({icons});\``);if(typeof r>"u")throw new Error("`createIcons()` only works in a browser environment.");if(Array.from(r.querySelectorAll(`[${a}]`)).forEach(f=>p(f,{nameAttr:a,icons:e,attrs:t})),o&&Array.from(r.querySelectorAll("template")).forEach(l=>B({icons:e,nameAttr:a,attrs:t,root:l.content,inTemplates:o})),a==="data-lucide"){let f=r.querySelectorAll("[icon-name]");f.length>0&&(console.warn("[Lucide] Some icons were found with the now deprecated icon-name attribute. These will still be replaced for backwards compatibility, but will no longer be supported in v1.0 and you should switch to data-lucide"),Array.from(f).forEach(l=>p(l,{nameAttr:"icon-name",icons:e,attrs:t})))}};var V={AlertCircle:d,Check:m,Clock3:x,Copy:i,Download:n,Eye:C,EyeOff:c,FileText:h,Image:S,LogOut:g,Plus:w,RefreshCw:k,Search:P,ShieldCheck:A,X:M};window.renderChorusIcons=(e=document)=>B({icons:V,attrs:{"aria-hidden":"true"},root:e});document.addEventListener("DOMContentLoaded",()=>window.renderChorusIcons());})();
/*! Bundled license information:
lucide/dist/esm/defaultAttributes.mjs:
lucide/dist/esm/createElement.mjs:
lucide/dist/esm/shared/src/utils/hasA11yProp.mjs:
lucide/dist/esm/shared/src/utils/mergeClasses.mjs:
lucide/dist/esm/shared/src/utils/toCamelCase.mjs:
lucide/dist/esm/shared/src/utils/toPascalCase.mjs:
lucide/dist/esm/replaceElement.mjs:
lucide/dist/esm/icons/check.mjs:
lucide/dist/esm/icons/circle-alert.mjs:
lucide/dist/esm/icons/clock-3.mjs:
lucide/dist/esm/icons/copy.mjs:
lucide/dist/esm/icons/download.mjs:
lucide/dist/esm/icons/eye-off.mjs:
lucide/dist/esm/icons/eye.mjs:
lucide/dist/esm/icons/file-text.mjs:
lucide/dist/esm/icons/image.mjs:
lucide/dist/esm/icons/log-out.mjs:
lucide/dist/esm/icons/plus.mjs:
lucide/dist/esm/icons/refresh-cw.mjs:
lucide/dist/esm/icons/search.mjs:
lucide/dist/esm/icons/shield-check.mjs:
lucide/dist/esm/icons/x.mjs:
lucide/dist/esm/lucide.mjs:
(**
* @license lucide v1.33.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*)
*/
+86
View File
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Chorus - MVP-2 API Key 原型 v1</title>
<script defer src="./icons.min.js"></script>
<style>
:root{--bg:#f3f6f6;--surface:#fff;--subtle:#f8faf9;--strong:#edf3f2;--text:#172126;--muted:#526169;--soft:#6a777e;--border:#d6dfe1;--border-strong:#b8c6c9;--primary:#006b66;--primary-hover:#005853;--primary-soft:#e4f2f0;--info:#245f87;--info-soft:#e8f1f7;--warning:#81501f;--warning-soft:#fff0df;--danger:#a63630;--danger-soft:#fcebea;--success:#2d6b42;--success-soft:#e7f3eb;--focus:#147bba;--radius:6px;font-family:Inter,"Segoe UI","Microsoft YaHei",Arial,sans-serif;color:var(--text);background:var(--bg)}
*{box-sizing:border-box;letter-spacing:0}html{min-width:320px}body{min-width:320px;min-height:100dvh;margin:0;background:var(--bg);font-size:15px;line-height:1.5;overflow-x:hidden}button,input,select{font:inherit;letter-spacing:0}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.55}button,a{touch-action:manipulation}svg{width:19px;height:19px;stroke-width:2;pointer-events:none;flex:0 0 19px}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}.hidden{display:none!important}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:20px;min-height:64px;padding:10px 22px;background:#fffffff5;border-bottom:1px solid var(--border)}.brand{display:flex;align-items:center;gap:10px;min-width:210px;color:var(--text);font-size:20px;font-weight:750;text-decoration:none}.brand-mark{display:grid;place-items:center;width:34px;height:34px;color:#fff;background:var(--primary);border-radius:6px;font-size:17px;font-weight:800}.app-nav{display:flex;align-items:center;gap:4px;min-width:0;overflow-x:auto}.nav-item{display:inline-flex;align-items:center;gap:8px;min-height:44px;padding:0 13px;color:var(--muted);font-weight:650;text-decoration:none;border-bottom:3px solid transparent;white-space:nowrap}.nav-item.active{color:var(--primary);border-bottom-color:var(--primary)}.account{margin-left:auto;color:var(--muted);white-space:nowrap}.prototype-switch{display:flex;gap:4px;padding:4px;background:var(--strong);border-radius:6px}.prototype-switch button{min-height:36px;padding:6px 11px;color:var(--muted);background:transparent;border:0;border-radius:4px}.prototype-switch button[aria-pressed=true]{color:var(--text);background:var(--surface);box-shadow:0 1px 3px #17212618}
.button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;min-width:44px;padding:9px 15px;font-weight:650;border:1px solid transparent;border-radius:var(--radius)}.button-primary{color:#fff;background:var(--primary);border-color:var(--primary)}.button-primary:hover{background:var(--primary-hover)}.button-secondary{color:var(--text);background:var(--surface);border-color:var(--border-strong)}.button-secondary:hover{background:var(--strong)}.button-danger{color:var(--danger);background:var(--surface);border-color:#dba6a3}.button-danger:hover{background:var(--danger-soft)}.icon-button{display:inline-grid;place-items:center;width:44px;height:44px;padding:0;color:var(--muted);background:transparent;border:1px solid transparent;border-radius:var(--radius)}.icon-button:hover{background:var(--strong);border-color:var(--border)}
main{width:min(1240px,100%);margin:0 auto;padding:28px clamp(16px,3vw,40px) 56px}.page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:20px}.page-heading h1{margin:0;font-size:30px;line-height:1.25}.page-heading p{max-width:720px;margin:7px 0 0;color:var(--muted)}.heading-actions{display:flex;align-items:center;gap:8px}.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.panel-header{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:18px 20px;border-bottom:1px solid var(--border)}.panel-header h2{margin:0;font-size:18px}.panel-header p{margin:3px 0 0;color:var(--muted);font-size:14px}.toolbar{display:flex;align-items:flex-end;gap:10px;padding:14px 16px;border-bottom:1px solid var(--border);background:var(--subtle)}.field{display:grid;gap:6px}.field label{font-size:13px;font-weight:650}.input,.select{min-height:44px;padding:9px 11px;color:var(--text);background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius)}.input{width:260px}.select{min-width:140px}.toolbar .spacer{flex:1}.result-count{padding-bottom:11px;color:var(--muted);white-space:nowrap}
.state-bar{display:flex;align-items:center;gap:6px;margin-bottom:14px;padding:8px 10px;background:var(--warning-soft);border:1px solid #ebc99f;border-radius:var(--radius);overflow-x:auto}.state-bar strong{margin-right:4px;white-space:nowrap}.state-button{min-height:34px;padding:5px 9px;color:var(--muted);white-space:nowrap;background:var(--surface);border:1px solid var(--border);border-radius:4px}.state-button.active{color:var(--primary);background:var(--primary-soft);border-color:#91c1bd}.table-wrap{max-width:100%;overflow:auto}.data-table{width:100%;min-width:900px;border-collapse:collapse}.data-table th,.data-table td{padding:13px 15px;text-align:left;border-bottom:1px solid var(--border);vertical-align:middle}.data-table th{color:var(--muted);font-size:13px;font-weight:700;background:var(--subtle);white-space:nowrap}.data-table tbody tr:hover{background:#fbfdfc}.mono{font-family:"Cascadia Code",Consolas,monospace}.key-name{font-weight:700}.secondary{display:block;margin-top:2px;color:var(--soft);font-size:12px}.badge{display:inline-flex;align-items:center;min-height:26px;padding:2px 8px;font-size:12px;font-weight:700;border-radius:999px}.badge.active{color:var(--success);background:var(--success-soft)}.badge.revoked{color:var(--soft);background:var(--strong)}.badge.expiring{color:var(--warning);background:var(--warning-soft)}.row-actions{display:flex;align-items:center;gap:6px}.link-button{min-height:40px;padding:7px 8px;color:var(--primary);font-weight:650;background:transparent;border:0;border-radius:4px}.link-button.danger{color:var(--danger)}.link-button:hover{background:var(--strong)}
.empty,.message-state{display:grid;place-items:center;min-height:330px;padding:36px;text-align:center}.empty-icon,.message-icon{display:grid;place-items:center;width:52px;height:52px;margin-bottom:12px;color:var(--primary);background:var(--primary-soft);border-radius:6px}.empty h3,.message-state h3{margin:0;font-size:18px}.empty p,.message-state p{max-width:520px;margin:7px 0 18px;color:var(--muted)}.message-state.error .message-icon{color:var(--danger);background:var(--danger-soft)}.skeleton{height:18px;background:linear-gradient(90deg,#edf2f1 20%,#f7f9f8 50%,#edf2f1 80%);background-size:220% 100%;border-radius:4px;animation:shine 1.2s linear infinite}@keyframes shine{to{background-position:-220% 0}}
.notice{display:flex;align-items:flex-start;gap:11px;margin-bottom:16px;padding:13px 15px;color:var(--info);background:var(--info-soft);border:1px solid #bfd5e5;border-radius:var(--radius)}.notice p{margin:0}.notice strong{display:block;margin-bottom:2px}.admin-shell{display:grid;grid-template-columns:210px minmax(0,1fr);min-height:calc(100dvh - 64px)}.admin-sidebar{padding:18px 12px;background:#263238;color:#dce5e7}.admin-sidebar h2{margin:0 10px 14px;font-size:14px;color:#91a4a9}.admin-link{display:flex;align-items:center;gap:9px;min-height:44px;padding:9px 11px;color:#dce5e7;text-decoration:none;border-radius:4px}.admin-link.active{color:#fff;background:#006b66}.admin-content{min-width:0}.admin-content main{padding-top:22px}.admin-table .data-table{min-width:1060px}.risk-cell{max-width:240px;color:var(--muted);font-size:13px}
.modal-backdrop{position:fixed;inset:0;z-index:40;display:grid;place-items:center;padding:16px;background:#17212680}.dialog{width:min(570px,100%);max-height:calc(100dvh - 32px);overflow:auto;background:var(--surface);border:1px solid var(--border-strong);border-radius:8px;box-shadow:0 18px 55px #1721263b}.dialog-header{display:flex;align-items:center;justify-content:space-between;padding:18px 20px;border-bottom:1px solid var(--border)}.dialog-header h2{margin:0;font-size:20px}.dialog-body{padding:20px}.dialog-body .field{margin-bottom:15px}.dialog-body .input,.dialog-body .select{width:100%}.help{margin:0;color:var(--muted);font-size:13px}.dialog-footer{display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border);background:var(--subtle)}.secret-box{padding:14px;background:#172126;color:#f2f8f7;border-radius:6px;overflow-wrap:anywhere}.secret-box code{font-size:14px}.one-time{display:flex;gap:10px;margin-bottom:16px;padding:12px;color:var(--warning);background:var(--warning-soft);border:1px solid #ebc99f;border-radius:6px}.confirm-name{padding:9px 11px;background:var(--strong);border-radius:4px;font-weight:700}.toast{position:fixed;right:18px;bottom:18px;z-index:60;display:flex;align-items:center;gap:9px;max-width:min(420px,calc(100vw - 36px));padding:12px 15px;color:#fff;background:#245d38;border-radius:6px;box-shadow:0 8px 28px #17212635;transform:translateY(130%);transition:transform .2s}.toast.show{transform:translateY(0)}.toast.error{background:#8d302b}
@media(max-width:900px){.topbar{flex-wrap:wrap;gap:8px}.brand{min-width:0}.app-nav{order:3;width:100%}.account{display:none}.admin-shell{grid-template-columns:64px minmax(0,1fr)}.admin-sidebar{padding:12px 8px}.admin-sidebar h2,.admin-link span{display:none}.admin-link{justify-content:center}.page-heading{align-items:stretch;flex-direction:column}.heading-actions{justify-content:flex-start}.toolbar{align-items:stretch;flex-wrap:wrap}.toolbar .field{flex:1 1 180px}.toolbar .input,.toolbar .select{width:100%}.toolbar .spacer{display:none}.result-count{width:100%;padding:0}}
@media(max-width:560px){.topbar{padding:8px 12px}.brand{font-size:18px}.prototype-switch{margin-left:auto}.prototype-switch button{padding:5px 8px}.nav-item{padding:0 9px}.page-heading h1{font-size:25px}.heading-actions{display:grid;grid-template-columns:1fr}.heading-actions .button{width:100%}.panel-header{align-items:flex-start;flex-direction:column}.toolbar .button{flex:1}.dialog-footer{display:grid;grid-template-columns:1fr}.dialog-footer .button{width:100%}.admin-shell{display:block}.admin-sidebar{display:flex;gap:4px;overflow-x:auto}.admin-link{min-width:44px}.admin-content main{padding-top:18px}}
@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;animation:none!important;transition:none!important}}
</style>
</head>
<body>
<header class="topbar">
<a class="brand" href="#"><span class="brand-mark">C</span><span>Chorus</span></a>
<nav class="app-nav" id="portal-nav" aria-label="用户端导航"><a class="nav-item" href="#"><i data-lucide="sparkles"></i>生成</a><a class="nav-item" href="#"><i data-lucide="history"></i>历史</a><a class="nav-item active" href="#"><i data-lucide="key-round"></i>API Key</a></nav>
<div class="prototype-switch" role="group" aria-label="原型页面"><button type="button" data-view="portal" aria-pressed="true">用户端</button><button type="button" data-view="admin" aria-pressed="false">管理端</button></div>
<span class="account">受控用户</span>
</header>
<div id="portal-view">
<main id="portal-main" tabindex="-1">
<div class="state-bar" aria-label="用户端原型状态"><strong>原型状态</strong><button class="state-button active" data-scope="portal" data-state="normal">正常</button><button class="state-button" data-scope="portal" data-state="loading">加载</button><button class="state-button" data-scope="portal" data-state="empty">空记录</button><button class="state-button" data-scope="portal" data-state="error">加载失败</button><button class="state-button" data-scope="portal" data-state="forbidden">无权限</button><button class="state-button" data-scope="portal" data-state="limited">限流</button></div>
<header class="page-heading"><div><h1>API Key</h1><p>为当前账户创建程序调用凭据。完整 Key 只在创建成功后显示一次,之后只能查看名称、前缀和使用状态。</p></div><div class="heading-actions"><button class="button button-secondary" id="open-api-docs"><i data-lucide="book-open"></i>接口文档</button><button class="button button-primary" id="create-key"><i data-lucide="plus"></i>创建 API Key</button></div></header>
<div class="notice"><i data-lucide="shield-check"></i><p><strong>每个 Key 都代表当前账户</strong>只通过 Authorization Bearer 请求头发送,不要放进网址、浏览器脚本、工单或日志。</p></div>
<section class="panel" aria-labelledby="portal-list-title"><div class="panel-header"><div><h2 id="portal-list-title">我的 API Key</h2><p>撤销后立即失效,不能恢复;需要轮换时先创建新 Key。</p></div><span class="result-count" id="portal-count">共 4 个</span></div><div id="portal-content"></div></section>
</main>
</div>
<div id="admin-view" class="hidden">
<div class="admin-shell">
<aside class="admin-sidebar" aria-label="管理端导航"><h2>运营管理</h2><a class="admin-link" href="#" title="用户管理"><i data-lucide="users"></i><span>用户管理</span></a><a class="admin-link active" href="#" title="API Key 管理"><i data-lucide="key-round"></i><span>API Key 管理</span></a><a class="admin-link" href="#" title="审计记录"><i data-lucide="scroll-text"></i><span>审计记录</span></a></aside>
<div class="admin-content"><main tabindex="-1">
<div class="state-bar" aria-label="管理端原型状态"><strong>原型状态</strong><button class="state-button active" data-scope="admin" data-state="normal">正常</button><button class="state-button" data-scope="admin" data-state="loading">加载</button><button class="state-button" data-scope="admin" data-state="empty">空记录</button><button class="state-button" data-scope="admin" data-state="error">加载失败</button><button class="state-button" data-scope="admin" data-state="forbidden">无权限</button></div>
<header class="page-heading"><div><h1>API Key 管理</h1><p>按用户和状态检索 Key 元数据,并在账户异常或凭据泄露时执行撤销。管理端不显示完整 Key。</p></div><div class="heading-actions"><button class="button button-secondary" id="refresh-admin"><i data-lucide="refresh-cw"></i>刷新</button></div></header>
<section class="panel admin-table" aria-labelledby="admin-list-title"><div class="panel-header"><div><h2 id="admin-list-title">API Key 列表</h2><p>撤销操作会写入管理员审计与 API 安全审计。</p></div></div><div class="toolbar"><div class="field"><label for="keyword">用户编号 / Key 名称 / 前缀</label><input class="input" id="keyword" type="search" placeholder="输入关键词"></div><div class="field"><label for="key-status">状态</label><select class="select" id="key-status"><option value="">全部状态</option><option value="active">有效</option><option value="expired">已过期</option><option value="revoked">已撤销</option></select></div><button class="button button-secondary" id="reset-filter">重置</button><span class="spacer"></span><span class="result-count" id="admin-count">共 5 条</span></div><div id="admin-content"></div></section>
</main></div>
</div>
</div>
<div class="modal-backdrop hidden" id="create-dialog" role="presentation"><section class="dialog" role="dialog" aria-modal="true" aria-labelledby="create-title"><header class="dialog-header"><h2 id="create-title">创建 API Key</h2><button class="icon-button close-dialog" type="button" aria-label="关闭"><i data-lucide="x"></i></button></header><form id="create-form"><div class="dialog-body"><div class="field"><label for="key-name">名称</label><input class="input" id="key-name" maxlength="80" required value="自动化脚本" placeholder="例如:自动化脚本"><p class="help">使用能区分调用方和用途的名称。</p></div><div class="field"><label for="expires">有效期</label><select class="select" id="expires"><option>30 天</option><option selected>90 天</option><option>180 天</option><option>不设置到期时间</option></select><p class="help">到期后自动拒绝认证;可在到期前创建新 Key。</p></div></div><footer class="dialog-footer"><button class="button button-secondary close-dialog" type="button">取消</button><button class="button button-primary" type="submit"><i data-lucide="key-round"></i>创建</button></footer></form></section></div>
<div class="modal-backdrop hidden" id="secret-dialog" role="presentation"><section class="dialog" role="dialog" aria-modal="true" aria-labelledby="secret-title"><header class="dialog-header"><h2 id="secret-title">API Key 已创建</h2></header><div class="dialog-body"><div class="one-time"><i data-lucide="triangle-alert"></i><div><strong>这是唯一一次显示完整 Key</strong><div>关闭后无法再次查看;遗失时请创建新 Key 并撤销旧 Key。</div></div></div><div class="secret-box"><code id="secret-value">chorus_demo_THIS_IS_NOT_A_REAL_KEY_7x3K9m</code></div><button class="button button-secondary" id="copy-secret" type="button" style="margin-top:12px"><i data-lucide="copy"></i>复制示例 Key</button></div><footer class="dialog-footer"><button class="button button-primary" id="confirm-secret" type="button">我已妥善保存</button></footer></section></div>
<div class="modal-backdrop hidden" id="revoke-dialog" role="presentation"><section class="dialog" role="dialog" aria-modal="true" aria-labelledby="revoke-title"><header class="dialog-header"><h2 id="revoke-title">撤销 API Key</h2><button class="icon-button close-revoke" type="button" aria-label="关闭"><i data-lucide="x"></i></button></header><div class="dialog-body"><p>撤销后,使用此 Key 的请求会立即认证失败,且无法恢复。</p><div class="confirm-name" id="revoke-name">自动化脚本 · chorus_7x3K</div></div><footer class="dialog-footer"><button class="button button-secondary close-revoke" type="button">取消</button><button class="button button-danger" id="confirm-revoke" type="button"><i data-lucide="ban"></i>确认撤销</button></footer></section></div>
<div class="modal-backdrop hidden" id="detail-dialog" role="presentation"><section class="dialog" role="dialog" aria-modal="true" aria-labelledby="detail-title"><header class="dialog-header"><h2 id="detail-title">API Key 详情</h2><button class="icon-button close-detail" type="button" aria-label="关闭"><i data-lucide="x"></i></button></header><div class="dialog-body"><div id="detail-metadata"></div><h3 style="margin:20px 0 8px">最近安全事件</h3><div class="notice" style="margin:0"><i data-lucide="scroll-text"></i><p><strong>程序提交成功</strong>请求编号 req_demo_0188 · 3 分钟前<br><span class="secondary">未保存 Prompt、文件、完整 Key 或原始 IP。</span></p></div></div><footer class="dialog-footer"><button class="button button-primary close-detail" type="button">关闭</button></footer></section></div>
<div class="toast" id="toast" role="status" aria-live="polite"><i data-lucide="check"></i><span id="toast-text"></span></div>
<script>
const $=(s,r=document)=>r.querySelector(s), $$=(s,r=document)=>[...r.querySelectorAll(s)];
const keys=[
{name:'自动化脚本',prefix:'chorus_7x3K',created:'2026-08-20 14:32',used:'3 分钟前',expires:'2026-11-18',status:'active',user:'#1024 · 受控用户'},
{name:'内容工作台',prefix:'chorus_B2pQ',created:'2026-08-12 09:18',used:'昨天 18:05',expires:'不设置',status:'active',user:'#1024 · 受控用户'},
{name:'旧版接入',prefix:'chorus_M8nR',created:'2026-07-02 11:06',used:'2026-08-01',expires:'-',status:'revoked',user:'#1024 · 受控用户'},
{name:'临时接入',prefix:'chorus_T4vN',created:'2026-05-20 10:16',used:'2026-06-15',expires:'2026-06-19',status:'expired',user:'#1024 · 受控用户'},
{name:'批处理工具',prefix:'chorus_K9dL',created:'2026-08-22 16:40',used:'从未使用',expires:'2026-09-21',status:'active',user:'#1041 · 受控用户'}
];
let portalState='normal',adminState='normal',revokeTarget=null;
function icons(){window.renderChorusIcons?.()}
function statusBadge(row){if(row.status==='active')return '<span class="badge active">有效</span>';if(row.status==='expired')return '<span class="badge expiring">已过期</span>';return '<span class="badge revoked">已撤销</span>'}
function stateMarkup(state,scope){if(state==='loading')return `<div class="table-wrap"><table class="data-table"><tbody>${[1,2,3].map(()=>'<tr>'+[1,2,3,4,5].map(()=>'<td><div class="skeleton"></div></td>').join('')+'</tr>').join('')}</tbody></table></div>`;if(state==='empty')return '<div class="empty"><div><span class="empty-icon"><i data-lucide="key-round"></i></span><h3>还没有 API Key</h3><p>创建后可用于 OpenAPI 请求;完整 Key 只显示一次。</p><button class="button button-primary" data-create-empty><i data-lucide="plus"></i>创建 API Key</button></div></div>';if(state==='error')return '<div class="message-state error"><div><span class="message-icon"><i data-lucide="circle-alert"></i></span><h3>API Key 加载失败</h3><p>已有数据不会被修改。请检查网络后重试。</p><button class="button button-secondary" data-retry><i data-lucide="refresh-cw"></i>重试</button></div></div>';if(state==='forbidden')return '<div class="message-state error"><div><span class="message-icon"><i data-lucide="shield-x"></i></span><h3>没有访问权限</h3><p>'+(scope==='admin'?'当前管理员角色没有 API Key 查看权限。':'当前账户已被停用或会话已失效。')+'</p></div></div>';if(state==='limited')return '<div class="message-state error"><div><span class="message-icon"><i data-lucide="clock"></i></span><h3>请求过于频繁</h3><p>请在 28 秒后重试。已创建的 Key 和任务不会受到影响。</p><button class="button button-secondary" data-retry>稍后重试</button></div></div>';return ''}
function portalTable(){if(portalState!=='normal')return stateMarkup(portalState,'portal');return `<div class="table-wrap"><table class="data-table"><thead><tr><th>名称</th><th>Key 前缀</th><th>状态</th><th>最近使用</th><th>到期时间</th><th>创建时间</th><th>操作</th></tr></thead><tbody>${keys.slice(0,4).map((row,i)=>`<tr><td><span class="key-name">${row.name}</span></td><td class="mono">${row.prefix}</td><td>${statusBadge(row)}</td><td>${row.used}</td><td>${row.expires}</td><td>${row.created}</td><td><div class="row-actions">${row.status==='active'?`<button class="link-button danger" data-revoke="${i}">撤销</button>`:'<span class="secondary">不可恢复</span>'}</div></td></tr>`).join('')}</tbody></table></div>`}
function adminTable(){if(adminState!=='normal')return stateMarkup(adminState,'admin');const keyword=$('#keyword')?.value.trim().toLowerCase()||'',status=$('#key-status')?.value||'';const rows=keys.filter(row=>(!keyword||[row.name,row.prefix,row.user].join(' ').toLowerCase().includes(keyword))&&(!status||row.status===status));$('#admin-count').textContent=`共 ${rows.length} 条`;return `<div class="table-wrap"><table class="data-table"><thead><tr><th>用户</th><th>名称 / 前缀</th><th>状态</th><th>最近使用</th><th>到期时间</th><th>创建时间</th><th>安全提示</th><th>操作</th></tr></thead><tbody>${rows.map(row=>`<tr><td>${row.user}</td><td><span class="key-name">${row.name}</span><span class="secondary mono">${row.prefix}</span></td><td>${statusBadge(row)}</td><td>${row.used}</td><td>${row.expires}</td><td>${row.created}</td><td class="risk-cell">${row.used==='从未使用'?'尚无调用记录':'未发现异常速率'}</td><td><button class="link-button" data-detail="${keys.indexOf(row)}">详情</button>${row.status==='active'?`<button class="link-button danger" data-admin-revoke="${keys.indexOf(row)}">撤销</button>`:''}</td></tr>`).join('')||'<tr><td colspan="8"><div class="empty" style="min-height:220px"><div><h3>没有匹配结果</h3><p>调整关键词或状态后重试。</p></div></div></td></tr>'}</tbody></table></div>`}
function renderPortal(){$('#portal-content').innerHTML=portalTable();$('#portal-count').textContent=portalState==='normal'?'共 4 个':'';bindActions();icons()}
function renderAdmin(){$('#admin-content').innerHTML=adminTable();bindActions();icons()}
function bindActions(){$$('[data-create-empty]').forEach(b=>b.onclick=openCreate);$$('[data-retry]').forEach(b=>b.onclick=()=>{if($('#portal-view').classList.contains('hidden'))setState('admin','normal');else setState('portal','normal')});$$('[data-revoke]').forEach(b=>b.onclick=()=>openRevoke(Number(b.dataset.revoke),false));$$('[data-admin-revoke]').forEach(b=>b.onclick=()=>openRevoke(Number(b.dataset.adminRevoke),true));$$('[data-detail]').forEach(b=>b.onclick=()=>openDetail(Number(b.dataset.detail)))}
function setState(scope,state){if(scope==='portal'){portalState=state;renderPortal()}else{adminState=state;renderAdmin()}$$(`[data-scope="${scope}"]`).forEach(b=>b.classList.toggle('active',b.dataset.state===state))}
function switchView(view){const portal=view==='portal';$('#portal-view').classList.toggle('hidden',!portal);$('#admin-view').classList.toggle('hidden',portal);$('#portal-nav').classList.toggle('hidden',!portal);$$('[data-view]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.view===view)));if(!portal)renderAdmin();document.querySelector(portal?'#portal-main':'#admin-view main').focus()}
function openCreate(){$('#create-dialog').classList.remove('hidden');$('#key-name').focus()}
function closeCreate(){$('#create-dialog').classList.add('hidden');$('#create-key').focus()}
function openRevoke(index,isAdmin){revokeTarget={index,isAdmin};const row=keys[index];$('#revoke-name').textContent=`${row.name} · ${row.prefix}${isAdmin?' · '+row.user:''}`;$('#revoke-dialog').classList.remove('hidden');$('#confirm-revoke').focus()}
function openDetail(index){const row=keys[index];$('#detail-metadata').innerHTML=`<div class="confirm-name">${row.name} · ${row.prefix}</div><p><strong>所属用户:</strong>${row.user}</p><p><strong>状态:</strong>${statusBadge(row)}</p><p><strong>最近使用:</strong>${row.used}</p><p><strong>到期时间:</strong>${row.expires}</p>`;$('#detail-dialog').classList.remove('hidden');$('.close-detail').focus();icons()}
function showToast(message,error=false){const toast=$('#toast');$('#toast-text').textContent=message;toast.classList.toggle('error',error);toast.classList.add('show');clearTimeout(toast.timer);toast.timer=setTimeout(()=>toast.classList.remove('show'),3000);icons()}
document.addEventListener('DOMContentLoaded',()=>{renderPortal();icons();$$('[data-view]').forEach(b=>b.onclick=()=>switchView(b.dataset.view));$$('[data-state]').forEach(b=>b.onclick=()=>setState(b.dataset.scope,b.dataset.state));$('#create-key').onclick=openCreate;$('#open-api-docs').onclick=()=>showToast('将打开 OpenAPI v1 接口文档(原型演示)');$$('.close-dialog').forEach(b=>b.onclick=closeCreate);$('#create-form').onsubmit=e=>{e.preventDefault();closeCreate();$('#secret-dialog').classList.remove('hidden');$('#confirm-secret').focus()};$('#copy-secret').onclick=async()=>{try{await navigator.clipboard.writeText($('#secret-value').textContent);showToast('示例 Key 已复制')}catch{showToast('浏览器未允许复制,请手动选择示例文本',true)}};$('#confirm-secret').onclick=()=>{$('#secret-dialog').classList.add('hidden');showToast('API Key 已创建(原型演示)')};$$('.close-revoke').forEach(b=>b.onclick=()=>$('#revoke-dialog').classList.add('hidden'));$$('.close-detail').forEach(b=>b.onclick=()=>$('#detail-dialog').classList.add('hidden'));$('#confirm-revoke').onclick=()=>{$('#revoke-dialog').classList.add('hidden');showToast(revokeTarget?.isAdmin?'API Key 已由管理员撤销(原型演示)':'API Key 已撤销(原型演示)')};$('#keyword').oninput=renderAdmin;$('#key-status').onchange=renderAdmin;$('#reset-filter').onclick=()=>{$('#keyword').value='';$('#key-status').value='';renderAdmin()};$('#refresh-admin').onclick=()=>{setState('admin','loading');setTimeout(()=>{setState('admin','normal');showToast('列表已刷新')},600)};document.addEventListener('keydown',e=>{if(e.key==='Escape'){$$('.modal-backdrop:not(.hidden)').forEach(m=>m.classList.add('hidden'))}})});
</script>
</body>
</html>