diff --git a/admin-ui/src/api/chorus/index.js b/admin-ui/src/api/chorus/index.js
index 0acfb49..b76ba50 100644
--- a/admin-ui/src/api/chorus/index.js
+++ b/admin-ui/src/api/chorus/index.js
@@ -25,3 +25,7 @@ export const updateRoutePool = (id, data) => request({ url: `${base}/route-pools
export const listPortalUsers = () => request({ url: `${base}/users`, method: 'get' })
export const updatePortalUserStatus = (id, status) => request({ url: `${base}/users/${id}`, method: 'put', data: { status }})
export const listGenerations = () => request({ url: `${base}/generations`, method: 'get' })
+
+export const listAPIKeys = params => request({ url: `${base}/api-keys`, method: 'get', params })
+export const getAPIKey = id => request({ url: `${base}/api-keys/${id}`, method: 'get' })
+export const revokeAPIKey = id => request({ url: `${base}/api-keys/${id}/revoke`, method: 'post' })
diff --git a/admin-ui/src/views/chorus/api-keys/helpers.js b/admin-ui/src/views/chorus/api-keys/helpers.js
new file mode 100644
index 0000000..cc0339f
--- /dev/null
+++ b/admin-ui/src/views/chorus/api-keys/helpers.js
@@ -0,0 +1,39 @@
+const statusLabels = { active: '有效', expired: '已过期', revoked: '已撤销' }
+const statusTypes = { active: 'success', expired: 'warning', revoked: 'info' }
+const actionLabels = {
+ 'api_key.created': '用户创建密钥',
+ 'api_key.renamed': '用户修改名称',
+ 'api_key.revoked': '用户撤销密钥',
+ 'api_key.admin_revoked': '管理员撤销密钥',
+ 'openapi.generation.submit': '程序提交生成任务'
+}
+const resultLabels = { succeeded: '成功', failed: '失败', denied: '已拒绝' }
+const resultTypes = { succeeded: 'success', failed: 'danger', denied: 'warning' }
+
+export function apiKeyPageOf(response) {
+ const page = response && response.data
+ if (!page || !Array.isArray(page.items)) return { items: [], total: 0, page: 1, pageSize: 20 }
+ return {
+ items: page.items,
+ total: Number(page.total) || 0,
+ page: Number(page.page) || 1,
+ pageSize: Number(page.page_size) || 20
+ }
+}
+
+export function statusText(value) { return statusLabels[value] || value || '-' }
+export function statusType(value) { return statusTypes[value] || 'info' }
+export function actionText(value) { return actionLabels[value] || value || '-' }
+export function resultText(value) { return resultLabels[value] || value || '-' }
+export function resultType(value) { return resultTypes[value] || 'info' }
+
+export function safeAuditSummary(summary) {
+ if (!summary || typeof summary !== 'object' || Array.isArray(summary)) return []
+ const rows = []
+ if (summary.source) rows.push(`来源:${summary.source}`)
+ if (summary.kind) rows.push(`类型:${summary.kind}`)
+ if (typeof summary.created === 'boolean') rows.push(summary.created ? '新建任务' : '幂等复用')
+ if (typeof summary.already_revoked === 'boolean') rows.push(summary.already_revoked ? '此前已撤销' : '本次撤销')
+ if (summary.operator_id) rows.push(`管理员 #${summary.operator_id}`)
+ return rows
+}
diff --git a/admin-ui/src/views/chorus/api-keys/index.vue b/admin-ui/src/views/chorus/api-keys/index.vue
new file mode 100644
index 0000000..fdc1d53
--- /dev/null
+++ b/admin-ui/src/views/chorus/api-keys/index.vue
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+
API 密钥
+
按用户、名称、前缀和状态检索密钥元数据;管理端不会显示完整密钥。
+
+ 刷新
+
+
+
+
+
+
+
+
+
+ 重置
+
+ 共 {{ total }} 条
+
+
+
+
+
+ 重试
+
+
+
+
+
+ {{ row.display_name || row.username || `用户 #${row.user_id}` }}
+ {{ row.username || '-' }} · #{{ row.user_id }}
+
+
+
+ {{ row.name }}{{ row.key_prefix }}
+
+ {{ statusText(row.status) }}
+ {{ formatTime(row.last_used_at) }}
+ {{ formatTime(row.expires_at) }}
+ {{ formatTime(row.created_at) }}
+
+
+ 详情
+ 撤销
+
+
+
+
+
+
+
+
+
+
+ {{ detail.name }}{{ detail.key_prefix }}
{{ statusText(detail.status) }}
+
+ - 所属用户
- {{ detail.display_name || detail.username || '-' }}({{ detail.username || '-' }} · #{{ detail.user_id }})
+ - 最近使用
- {{ formatTime(detail.last_used_at) }}
+ - 到期时间
- {{ formatTime(detail.expires_at) }}
+ - 撤销时间
- {{ formatTime(detail.revoked_at) }}
+ - 创建时间
- {{ formatTime(detail.created_at) }}
+
+ 最近安全事件
+
+
+ {{ actionText(row.action) }}
+ {{ resultText(row.result) }}
+ {{ safeAuditSummary(row.summary).join(' · ') || '-' }}{{ row.error_code }}
+ {{ row.request_id }}
+ {{ formatTime(row.created_at) }}
+
+
+
+
+ 关闭
+
+
+
+
+
+
+
+
+
diff --git a/admin-ui/tests/unit/chorus/api-keys.spec.js b/admin-ui/tests/unit/chorus/api-keys.spec.js
new file mode 100644
index 0000000..8cd482a
--- /dev/null
+++ b/admin-ui/tests/unit/chorus/api-keys.spec.js
@@ -0,0 +1,31 @@
+import fs from 'fs'
+import path from 'path'
+import { actionText, apiKeyPageOf, safeAuditSummary, statusText } from '@/views/chorus/api-keys/helpers'
+
+describe('Chorus API key governance page', () => {
+ test('unwraps the paginated metadata contract', () => {
+ expect(apiKeyPageOf({ data: { items: [{ id: 7 }], total: 21, page: 2, page_size: 20 }})).toEqual({
+ items: [{ id: 7 }], total: 21, page: 2, pageSize: 20
+ })
+ expect(apiKeyPageOf({ data: [] })).toEqual({ items: [], total: 0, page: 1, pageSize: 20 })
+ })
+
+ test('renders only reviewed audit summary fields', () => {
+ const summary = safeAuditSummary({ source: 'admin', kind: 'image', already_revoked: false, operator_id: 3, prompt: 'do not show', token: 'secret' })
+ expect(summary).toEqual(['来源:admin', '类型:image', '本次撤销', '管理员 #3'])
+ expect(summary.join(' ')).not.toContain('do not show')
+ expect(summary.join(' ')).not.toContain('secret')
+ })
+
+ test('localizes key and audit states', () => {
+ expect(statusText('active')).toBe('有效')
+ expect(actionText('api_key.admin_revoked')).toBe('管理员撤销密钥')
+ })
+
+ test('does not render credential storage fields or a creation action', () => {
+ const source = fs.readFileSync(path.resolve(__dirname, '../../../src/views/chorus/api-keys/index.vue'), 'utf8')
+ expect(source).not.toMatch(/public_id|secret_hash/)
+ expect(source).not.toContain('创建 API 密钥')
+ expect(source).toContain('管理端不会显示完整密钥')
+ })
+})
diff --git a/admin-ui/tests/unit/chorus/navigation-copy.spec.js b/admin-ui/tests/unit/chorus/navigation-copy.spec.js
index 3fa74bc..ec29eab 100644
--- a/admin-ui/tests/unit/chorus/navigation-copy.spec.js
+++ b/admin-ui/tests/unit/chorus/navigation-copy.spec.js
@@ -10,7 +10,8 @@ describe('Chorus navigation terminology', () => {
test.each([
['providers', '
上游服务商
', 'Provider 配置
'],
['templates', '提示词模板
', 'Prompt 模板
'],
- ['health', '上游健康
', 'Provider 健康
']
+ ['health', '上游健康
', 'Provider 健康
'],
+ ['api-keys', 'API 密钥
', 'API Key 管理
']
])('%s uses the localized page heading', (view, localized, legacy) => {
const source = readView(view)
expect(source).toContain(localized)
diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md
index 185b625..1eca26f 100644
--- a/docs/04-local-development-and-verification.md
+++ b/docs/04-local-development-and-verification.md
@@ -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: 01d2c4aaf24e17d8b9f6cc90d59c1f7244941110
-synchronized_at: 2026-08-25T01:53:09Z
+wiki_revision: 31061a29cf4b73dee02b01d2bccd4a333c1abe2a
+synchronized_at: 2026-08-25T02:30:21Z
# 本地开发与验证
@@ -530,3 +530,22 @@ go -C admin test -count=1 -run '^(TestAdminAPIKeyGovernanceMySQL|TestChorusAPIMy
```
验证覆盖:生命周期事务审计、未知 Key 不写行、成功/拒绝/429 提交审计、summary 脱敏、`last_used_at` 节流、管理检索/详情、并发安全的幂等撤销、双审计和 JWT/Casbin 负向路径。管理 API 的真实 go-admin 鉴权失败沿用框架约定,可能返回 HTTP 200 且 JSON `code=401`;判断时必须同时检查响应 JSON,不能只看 HTTP 状态。
+
+
+## #46 管理端 API 密钥治理页面
+
+管理员登录后,从“Chorus 运营 → API 密钥”进入治理页面。列表仅展示终端用户、密钥名称、固定前缀、状态和时间元数据,支持按用户编号/账号/密钥名称/前缀与状态筛选,并使用服务端分页。页面和浏览器缓存中都不会取得完整密钥、`public_id` 或 `secret_hash`,也不提供管理员创建用户密钥的入口。
+
+“详情”读取单条元数据和最近 20 条脱敏安全事件;摘要只显示已审核的来源、类型、幂等结果和管理员编号。“撤销”必须经过二次确认,成功后立即刷新列表;重复请求保持幂等,但已撤销密钥不能恢复。没有权限、加载失败、空结果和加载中均有独立页面状态。
+
+前端回归命令:
+
+```powershell
+corepack pnpm --dir admin-ui lint
+corepack pnpm --dir admin-ui exec vue-cli-service test:unit --runInBand
+$env:NODE_OPTIONS = "--max-old-space-size=4096"
+corepack pnpm --dir admin-ui build:prod
+$env:NODE_OPTIONS = $null
+```
+
+浏览器验收需登录实际管理端,覆盖列表、筛选、详情、撤销确认的取消路径以及 375/768/1024/1440 四种视口;主页面不得横向溢出,表格在窄屏内自行滚动。真实撤销只对专门创建的合成测试密钥执行,不使用生产用户或真实凭据。