feat(admin): add managed Open API client keys

This commit is contained in:
jiashumeng
2026-09-15 12:05:26 +09:30
parent 6cf1c0f4a0
commit e064e86def
22 changed files with 783 additions and 27 deletions
+128
View File
@@ -5,6 +5,7 @@ import { api, setCsrf } from './api'
const menus = [
{ id: 'tokens', label: '用户与 Token', icon: '👥' },
{ id: 'clients', label: '服务密钥', icon: '🔐' },
{ id: 'tools', label: '工具管理', icon: '🔧' },
{ id: 'scopes', label: '作用域管理', icon: '🧩' },
{ id: 'audit', label: '审计日志', icon: '📋' },
@@ -20,6 +21,7 @@ const userPage = reactive({ page: 1, pageSize: 20, total: 0 })
const selectedUser = ref(null)
const permission = ref(null)
const tokens = ref([])
const openClients = ref([])
const scopes = ref([])
const tools = ref([])
const audits = ref([])
@@ -28,6 +30,9 @@ const auditFilter = reactive({ person: '', tokenPrefix: '', tool: '', result: ''
const tokenDialog = ref(false)
const tokenForm = reactive({ id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null })
const issuedTokens = ref([])
const clientDialog = ref(false)
const clientForm = reactive({ id: null, name: '', memo: '', scopeIds: [] })
const issuedOpenKey = ref(null)
const scopeDialog = ref(false)
const scopeForm = reactive({ id: null, name: '', code: '', memo: '' })
const scopeToolsDialog = ref(false)
@@ -41,6 +46,20 @@ const groupedTools = computed(() => tools.value.reduce((groups, tool) => {
;(groups[category] ||= []).push(tool)
return groups
}, {}))
const issuedKeyCurl = computed(() => {
if (!issuedOpenKey.value) return ''
const payload = JSON.stringify({
scopeCode: 'sync', principalType: 'service', serviceName: issuedOpenKey.value.client.name,
})
return [
"curl -X POST 'https://agent.ququclean.com/api/open/token/ensure' \\",
` -H 'X-Open-Key: ${issuedOpenKey.value.plaintext}' \\`,
" -H 'Content-Type: application/json' \\",
` -d ${shellQuote(payload)}`,
].join('\n')
})
function shellQuote(value) { return `'${value.replaceAll("'", `'"'"'`)}'` }
function qs(values) {
const params = new URLSearchParams()
@@ -76,6 +95,7 @@ async function logout() {
async function switchMenu(id) {
current.value = id
if (id === 'tokens') await loadUsers()
if (id === 'clients') await loadOpenClients()
if (id === 'tools') await loadTools()
if (id === 'scopes') await loadScopes()
if (id === 'audit') await Promise.all([loadAudit(), api('/tools').then(data => { tools.value = data })])
@@ -146,6 +166,54 @@ async function deleteToken(token) {
}).catch(() => {})
}
async function loadOpenClients() {
;[openClients.value, scopes.value] = await Promise.all([api('/open-clients'), api('/scopes')])
}
function openClient(client = null) {
Object.assign(clientForm, client ? {
id: client.id, name: client.name, memo: client.memo || '', scopeIds: [...client.scopeIds],
} : { id: null, name: '', memo: '', scopeIds: [] })
clientDialog.value = true
}
async function saveClient() {
if (!clientForm.name.trim()) return ElMessage.warning('请填写调用方名称')
if (!clientForm.scopeIds.length) return ElMessage.warning('至少选择一个作用域')
await run(async () => {
const body = JSON.stringify({ name: clientForm.name, memo: clientForm.memo, scopeIds: clientForm.scopeIds })
if (clientForm.id) await api(`/open-clients/${clientForm.id}`, { method: 'PUT', body })
else issuedOpenKey.value = await api('/open-clients', { method: 'POST', body })
clientDialog.value = false
await loadOpenClients()
ElMessage.success(clientForm.id ? '服务密钥配置已保存' : '服务密钥已创建')
}).catch(() => {})
}
async function setClientStatus(client) {
if (client.status === 1) {
try { await ElMessageBox.confirm(`吊销后 ${client.name} 将立即无法用此密钥换取 Token,确认继续?`, '吊销服务密钥', { type: 'warning' }) }
catch { return }
}
await run(async () => {
await api(`/open-clients/${client.id}/status`, {
method: 'PATCH', body: JSON.stringify({ status: client.status === 1 ? 0 : 1 }),
})
await loadOpenClients()
ElMessage.success(client.status === 1 ? '服务密钥已吊销' : '服务密钥已启用')
}).catch(() => {})
}
async function rotateClient(client) {
try { await ElMessageBox.confirm(`轮换后 ${client.name} 的旧密钥立即失效,确认继续?`, '轮换服务密钥', { type: 'warning' }) }
catch { return }
await run(async () => {
issuedOpenKey.value = await api(`/open-clients/${client.id}/rotate`, { method: 'POST' })
await loadOpenClients()
ElMessage.success('服务密钥已轮换')
}).catch(() => {})
}
async function loadTools() {
tools.value = await api(`/tools${qs(toolFilter)}`)
}
@@ -212,6 +280,7 @@ async function copyText(value) { await navigator.clipboard.writeText(value); ElM
async function copyAllTokens() {
await copyText(issuedTokens.value.map(item => `${item.token.scopeNames.join('/')}: ${item.plaintext}`).join('\n'))
}
function clearIssuedOpenKey() { issuedOpenKey.value = null }
onMounted(async () => {
try {
@@ -242,6 +311,7 @@ onMounted(async () => {
<div class="logo"><span>趣</span> 智能体服务后台</div>
<nav aria-label="主导航">
<button :class="['menu-item', { active: current === 'tokens' }]" data-testid="admin-menu-tokens" @click="switchMenu('tokens')">👥 用户与 Token</button>
<button :class="['menu-item', { active: current === 'clients' }]" data-testid="admin-menu-clients" @click="switchMenu('clients')">🔐 服务密钥</button>
<button :class="['menu-item', { active: current === 'tools' }]" data-testid="admin-menu-tools" @click="switchMenu('tools')">🔧 工具管理</button>
<button :class="['menu-item', { active: current === 'scopes' }]" data-testid="admin-menu-scopes" @click="switchMenu('scopes')">🧩 作用域管理</button>
<button :class="['menu-item', { active: current === 'audit' }]" data-testid="admin-menu-audit" @click="switchMenu('audit')">📋 审计日志</button>
@@ -311,6 +381,31 @@ onMounted(async () => {
</div>
</template>
<template v-else-if="current === 'clients'">
<article class="card">
<div class="card-title">
<span>服务密钥调用方</span>
<el-button type="primary" data-testid="admin-btn-new-client" @click="openClient()">+ 创建服务密钥</el-button>
</div>
<el-alert title="密钥明文只在创建或轮换成功后展示一次;已通过环境变量配置的存量密钥继续有效,但不在本页展示。" type="info" :closable="false" show-icon />
<el-table :data="openClients" class="client-table" data-testid="admin-table-clients" empty-text="暂无服务密钥">
<el-table-column prop="name" label="调用方" min-width="140" />
<el-table-column label="密钥前缀" min-width="145"><template #default="{ row }"><code>{{ row.keyPrefix }}…</code></template></el-table-column>
<el-table-column prop="memo" label="用途备注" min-width="180" />
<el-table-column label="允许作用域" min-width="200"><template #default="{ row }"><el-tag v-for="(name, index) in row.scopeNames" :key="row.scopeIds[index]" size="small" effect="plain">{{ name }}({{ row.scopeCodes[index] }})</el-tag></template></el-table-column>
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">{{ row.status === 1 ? '正常' : '已吊销' }}</el-tag></template></el-table-column>
<el-table-column label="最后使用" min-width="155"><template #default="{ row }">{{ date(row.lastUsedTime) }}</template></el-table-column>
<el-table-column label="更新时间" min-width="155"><template #default="{ row }">{{ date(row.modifyTime) }}</template></el-table-column>
<el-table-column label="操作" width="205" fixed="right"><template #default="{ row }">
<el-button link type="primary" :data-testid="`admin-client-edit-${row.id}`" @click="openClient(row)">编辑</el-button>
<el-button link type="warning" :data-testid="`admin-client-rotate-${row.id}`" @click="rotateClient(row)">轮换</el-button>
<el-button link :type="row.status === 1 ? 'danger' : 'success'" :data-testid="`admin-client-status-${row.id}`" @click="setClientStatus(row)">{{ row.status === 1 ? '吊销' : '启用' }}</el-button>
</template></el-table-column>
</el-table>
<p class="hint">服务密钥用于调用 <code>/api/open/token/ensure</code> 换取业务 Token;作用域决定该密钥能为哪些数据域签发或获取 Token。</p>
</article>
</template>
<template v-else-if="current === 'tools'">
<article class="card">
<div class="toolbar">
@@ -416,6 +511,25 @@ onMounted(async () => {
<template #footer><el-button data-testid="admin-scope-cancel" @click="scopeDialog = false">取消</el-button><el-button type="primary" data-testid="admin-scope-save" @click="saveScope">保存</el-button></template>
</el-dialog>
<el-dialog v-model="clientDialog" :title="clientForm.id ? '编辑服务密钥' : '创建服务密钥'" width="560px" destroy-on-close>
<el-form label-width="92px">
<el-form-item label="调用方名称"><el-input v-model="clientForm.name" maxlength="64" placeholder="如 crm" aria-label="服务密钥调用方名称" data-testid="admin-client-name" /></el-form-item>
<el-form-item label="用途备注"><el-input v-model="clientForm.memo" type="textarea" maxlength="255" placeholder="如:CRM 合同增量同步" aria-label="服务密钥用途备注" data-testid="admin-client-memo" /></el-form-item>
<el-form-item label="允许作用域">
<el-checkbox-group v-model="clientForm.scopeIds" data-testid="admin-client-scopes">
<el-checkbox
v-for="scope in scopes"
:key="scope.id"
:value="scope.id"
:disabled="scope.status !== 1 && !clientForm.scopeIds.includes(scope.id)"
>{{ scope.name }}({{ scope.code }})<template v-if="scope.status !== 1"> · 已停用</template></el-checkbox>
</el-checkbox-group>
<div class="hint" style="margin:4px 0 0">CRM 后台增量同步请选择 sync;其他调用方按实际用途授权。</div>
</el-form-item>
</el-form>
<template #footer><el-button data-testid="admin-client-cancel" @click="clientDialog = false">取消</el-button><el-button type="primary" data-testid="admin-client-save" @click="saveClient">保存</el-button></template>
</el-dialog>
<el-dialog v-model="scopeToolsDialog" :title="`配置工具 · ${activeScope?.name || ''}`" width="680px" destroy-on-close>
<el-checkbox-group v-model="selectedToolIds" class="tool-groups" data-testid="admin-scope-tool-options">
<section v-for="(items, category) in groupedTools" :key="category" class="tool-group"><h3>{{ category }}</h3><el-checkbox v-for="tool in items" :key="tool.id" :value="tool.id" :data-testid="`admin-scope-tool-${tool.id}`"><code>{{ tool.name }}</code><small>{{ tool.description }}</small></el-checkbox></section>
@@ -432,4 +546,18 @@ onMounted(async () => {
</div>
<template #footer><el-button type="primary" data-testid="admin-copy-all-tokens" @click="copyAllTokens">全部复制</el-button><el-button data-testid="admin-close-token" @click="issuedTokens = []">我已保存</el-button></template>
</el-dialog>
<el-dialog :model-value="!!issuedOpenKey" title="服务密钥已生成" width="720px" :close-on-click-modal="false" @close="clearIssuedOpenKey">
<el-alert title="明文只展示这一次,请立即复制并交给对应系统安全保存。关闭后后台无法找回,只能轮换。" type="warning" :closable="false" show-icon />
<template v-if="issuedOpenKey">
<div class="issued-row">
<code class="token-secret" data-testid="admin-issued-client-key">{{ issuedOpenKey.plaintext }}</code>
<el-button link type="primary" data-testid="admin-copy-client-key" @click="copyText(issuedOpenKey.plaintext)">复制密钥</el-button>
</div>
<p class="field-label key-example-label">CRM 换取服务 Token 示例</p>
<pre class="key-example" data-testid="admin-client-curl">{{ issuedKeyCurl }}</pre>
<p class="hint">如果绑定的不是 sync 作用域,请按实际作用域修改请求中的 scopeCode 和主体参数。</p>
</template>
<template #footer><el-button type="primary" data-testid="admin-copy-client-curl" @click="copyText(issuedKeyCurl)">复制调用示例</el-button><el-button data-testid="admin-close-client-key" @click="clearIssuedOpenKey">我已保存</el-button></template>
</el-dialog>
</template>
+3
View File
@@ -62,4 +62,7 @@ code { font-family: Menlo, Consolas, monospace; font-size: 12px; }
.token-secret { display: block; margin-top: 14px; padding: 14px; word-break: break-all; border-radius: 4px; background: #f4f4f5; font-size: 13px; }
.issued-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.issued-row .token-secret { flex: 1; margin-top: 0; padding: 9px 12px; }
.client-table { margin-top: 16px; }
.key-example-label { margin: 18px 0 7px; }
.key-example { margin: 0; padding: 14px; overflow: auto; color: #e6edf3; background: #1f2328; border-radius: 5px; font: 12px/1.6 Menlo, Consolas, monospace; white-space: pre-wrap; word-break: break-all; }
@media (max-width: 1200px) { .token-layout { grid-template-columns: 300px minmax(640px, 1fr); } }