feat: 交付管理端 API 密钥治理页面 (#46)
This commit is contained in:
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<main class="chorus-page api-key-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<h1>API 密钥</h1>
|
||||
<p>按用户、名称、前缀和状态检索密钥元数据;管理端不会显示完整密钥。</p>
|
||||
</div>
|
||||
<el-button :loading="loading" @click="load"><i class="ri-refresh-line" /> 刷新</el-button>
|
||||
</header>
|
||||
|
||||
<div class="toolbar" aria-label="API 密钥筛选">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
clearable
|
||||
placeholder="用户编号 / 账号 / 密钥名称 / 前缀"
|
||||
style="width: 320px"
|
||||
aria-label="筛选 API 密钥"
|
||||
@input="queueFilter"
|
||||
@clear="applyFilters"
|
||||
@keyup.enter="applyFilters"
|
||||
/>
|
||||
<el-select v-model="status" clearable placeholder="全部状态" style="width: 140px" aria-label="筛选密钥状态" @change="applyFilters">
|
||||
<el-option label="有效" value="active" />
|
||||
<el-option label="已过期" value="expired" />
|
||||
<el-option label="已撤销" value="revoked" />
|
||||
</el-select>
|
||||
<el-button @click="resetFilters"><i class="ri-restart-line" /> 重置</el-button>
|
||||
<span class="spacer" />
|
||||
<span class="muted" aria-live="polite">共 {{ total }} 条</span>
|
||||
</div>
|
||||
|
||||
<section class="table-panel" aria-label="API 密钥列表">
|
||||
<el-result v-if="loadState === 'forbidden'" icon="warning" title="没有访问权限" sub-title="当前管理员角色没有 API 密钥查看权限。" />
|
||||
<el-result v-else-if="loadState === 'error'" icon="error" title="API 密钥加载失败" sub-title="已有数据不会被修改,请检查服务状态后重试。">
|
||||
<template #extra><el-button type="primary" @click="load">重试</el-button></template>
|
||||
</el-result>
|
||||
<el-empty v-else-if="!loading && rows.length === 0" :description="keyword || status ? '没有匹配的 API 密钥' : '暂无 API 密钥'" />
|
||||
<el-table v-else v-loading="loading" :data="rows" border empty-text="暂无 API 密钥">
|
||||
<el-table-column label="用户" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ row.display_name || row.username || `用户 #${row.user_id}` }}</strong>
|
||||
<span class="cell-secondary mono">{{ row.username || '-' }} · #{{ row.user_id }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="名称 / 前缀" min-width="190">
|
||||
<template #default="{ row }"><strong>{{ row.name }}</strong><span class="cell-secondary mono">{{ row.key_prefix }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="最近使用" width="175"><template #default="{ row }">{{ formatTime(row.last_used_at) }}</template></el-table-column>
|
||||
<el-table-column label="到期时间" width="175"><template #default="{ row }">{{ formatTime(row.expires_at) }}</template></el-table-column>
|
||||
<el-table-column label="创建时间" width="175"><template #default="{ row }">{{ formatTime(row.created_at) }}</template></el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="142">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button v-if="row.status === 'active'" link type="danger" :loading="revoking === row.id" @click="confirmRevoke(row)">撤销</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="loadState === 'ready' && total > pageSize" class="pagination-row">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="total"
|
||||
@current-change="load"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="detailOpen" title="API 密钥详情" width="min(760px, 94vw)" :close-on-click-modal="false" @closed="clearDetail">
|
||||
<div v-loading="detailLoading" class="detail-content">
|
||||
<el-result v-if="detailError" icon="error" title="详情加载失败" sub-title="请关闭窗口后重试。" />
|
||||
<template v-else-if="detail">
|
||||
<div class="detail-heading"><div><strong>{{ detail.name }}</strong><span class="mono">{{ detail.key_prefix }}</span></div><el-tag :type="statusType(detail.status)">{{ statusText(detail.status) }}</el-tag></div>
|
||||
<dl class="detail-list">
|
||||
<dt>所属用户</dt><dd>{{ detail.display_name || detail.username || '-' }}({{ detail.username || '-' }} · #{{ detail.user_id }})</dd>
|
||||
<dt>最近使用</dt><dd>{{ formatTime(detail.last_used_at) }}</dd>
|
||||
<dt>到期时间</dt><dd>{{ formatTime(detail.expires_at) }}</dd>
|
||||
<dt>撤销时间</dt><dd>{{ formatTime(detail.revoked_at) }}</dd>
|
||||
<dt>创建时间</dt><dd>{{ formatTime(detail.created_at) }}</dd>
|
||||
</dl>
|
||||
<h2 class="audit-title">最近安全事件</h2>
|
||||
<div class="audit-table-wrap">
|
||||
<el-table :data="detail.recent_events || []" border empty-text="暂无安全事件">
|
||||
<el-table-column label="事件" min-width="155"><template #default="{ row }">{{ actionText(row.action) }}</template></el-table-column>
|
||||
<el-table-column label="结果" width="90"><template #default="{ row }"><el-tag :type="resultType(row.result)">{{ resultText(row.result) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="摘要" min-width="180"><template #default="{ row }"><span>{{ safeAuditSummary(row.summary).join(' · ') || '-' }}</span><span v-if="row.error_code" class="cell-secondary danger-text mono">{{ row.error_code }}</span></template></el-table-column>
|
||||
<el-table-column label="请求编号" min-width="150"><template #default="{ row }"><span class="mono request-id">{{ row.request_id }}</span></template></el-table-column>
|
||||
<el-table-column label="时间" width="175"><template #default="{ row }">{{ formatTime(row.created_at) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="detailOpen=false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</main>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getAPIKey, listAPIKeys, revokeAPIKey } from '@/api/chorus'
|
||||
import { formatTime } from '../shared'
|
||||
import { actionText, apiKeyPageOf, resultText, resultType, safeAuditSummary, statusText, statusType } from './helpers'
|
||||
|
||||
export default {
|
||||
name: 'ChorusAPIKeys',
|
||||
data() {
|
||||
return {
|
||||
rows: [], total: 0, page: 1, pageSize: 20, keyword: '', status: '', loading: false,
|
||||
loadState: 'ready', loadSerial: 0, filterTimer: null, detailOpen: false, detailLoading: false,
|
||||
detailError: false, detail: null, detailSerial: 0, revoking: null
|
||||
}
|
||||
},
|
||||
created() { this.load() },
|
||||
beforeUnmount() { if (this.filterTimer) clearTimeout(this.filterTimer) },
|
||||
methods: {
|
||||
formatTime, statusText, statusType, actionText, resultText, resultType, safeAuditSummary,
|
||||
async load() {
|
||||
const serial = ++this.loadSerial
|
||||
this.loading = true; this.loadState = 'ready'
|
||||
try {
|
||||
const response = await listAPIKeys({ keyword: this.keyword.trim(), status: this.status, page: this.page, page_size: this.pageSize })
|
||||
if (serial !== this.loadSerial) return
|
||||
const result = apiKeyPageOf(response)
|
||||
this.rows = result.items; this.total = result.total; this.page = result.page; this.pageSize = result.pageSize
|
||||
} catch (error) {
|
||||
if (serial !== this.loadSerial) return
|
||||
this.rows = []; this.total = 0
|
||||
this.loadState = this.isForbidden(error) ? 'forbidden' : 'error'
|
||||
} finally { if (serial === this.loadSerial) this.loading = false }
|
||||
},
|
||||
isForbidden(error) { return [401, 403].includes(Number(error && error.code)) || /unauthorized|forbidden|权限/i.test(String(error && error.message)) },
|
||||
queueFilter() { if (this.filterTimer) clearTimeout(this.filterTimer); this.filterTimer = setTimeout(this.applyFilters, 300) },
|
||||
applyFilters() { if (this.filterTimer) clearTimeout(this.filterTimer); this.filterTimer = null; this.page = 1; this.load() },
|
||||
resetFilters() { this.keyword = ''; this.status = ''; this.applyFilters() },
|
||||
async openDetail(row) {
|
||||
const serial = ++this.detailSerial
|
||||
this.detailOpen = true; this.detailLoading = true; this.detailError = false; this.detail = null
|
||||
try {
|
||||
const response = await getAPIKey(row.id)
|
||||
if (serial === this.detailSerial) this.detail = response.data || null
|
||||
} catch (_) {
|
||||
if (serial === this.detailSerial) this.detailError = true
|
||||
} finally { if (serial === this.detailSerial) this.detailLoading = false }
|
||||
},
|
||||
clearDetail() { this.detailSerial++; this.detail = null; this.detailError = false; this.detailLoading = false },
|
||||
async confirmRevoke(row) {
|
||||
try {
|
||||
await this.$confirm(`确认撤销“${row.name}”(${row.key_prefix})?撤销后立即失效且无法恢复。`, '撤销 API 密钥', { confirmButtonText: '确认撤销', cancelButtonText: '取消', type: 'warning' })
|
||||
} catch (_) { return }
|
||||
this.revoking = row.id
|
||||
try {
|
||||
await revokeAPIKey(row.id)
|
||||
this.$message.success('API 密钥已撤销')
|
||||
await this.load()
|
||||
if (this.detailOpen) await this.openDetail(row)
|
||||
} finally { this.revoking = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '../shared.scss';
|
||||
.cell-secondary { display: block; margin-top: 3px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.pagination-row { display: flex; justify-content: flex-end; padding: 12px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.detail-content { min-height: 180px; }
|
||||
.detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px; background: var(--el-fill-color-light); border: 1px solid var(--el-border-color-lighter); }
|
||||
.detail-heading strong, .detail-heading span { display: block; }
|
||||
.detail-heading .mono { margin-top: 4px; color: var(--el-text-color-secondary); }
|
||||
.audit-title { margin: 20px 0 10px; font-size: 16px; }
|
||||
.audit-table-wrap { max-width: 100%; overflow: hidden; }
|
||||
.request-id { overflow-wrap: anywhere; }
|
||||
@media (max-width: 760px) {
|
||||
.pagination-row { justify-content: center; }
|
||||
.detail-heading { align-items: center; }
|
||||
.audit-table-wrap { overflow-x: auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -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('管理端不会显示完整密钥')
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,8 @@ describe('Chorus navigation terminology', () => {
|
||||
test.each([
|
||||
['providers', '<h1>上游服务商</h1>', '<h1>Provider 配置</h1>'],
|
||||
['templates', '<h1>提示词模板</h1>', '<h1>Prompt 模板</h1>'],
|
||||
['health', '<h1>上游健康</h1>', '<h1>Provider 健康</h1>']
|
||||
['health', '<h1>上游健康</h1>', '<h1>Provider 健康</h1>'],
|
||||
['api-keys', '<h1>API 密钥</h1>', '<h1>API Key 管理</h1>']
|
||||
])('%s uses the localized page heading', (view, localized, legacy) => {
|
||||
const source = readView(view)
|
||||
expect(source).toContain(localized)
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -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 四种视口;主页面不得横向溢出,表格在窄屏内自行滚动。真实撤销只对专门创建的合成测试密钥执行,不使用生产用户或真实凭据。
|
||||
|
||||
Reference in New Issue
Block a user