feat(token): 一作用域一Token(签发N个作用域生成N把); 新增Open API /api/open/token/ensure(get-or-create, X-Open-Key鉴权, 明文不可回吐)

This commit is contained in:
xiaoqu
2026-09-04 16:53:18 +08:00
parent b1ad0f727a
commit 965b314751
14 changed files with 503 additions and 39 deletions
+32 -15
View File
@@ -26,8 +26,8 @@ const audits = ref([])
const toolFilter = reactive({ category: '', kind: '', keyword: '' })
const auditFilter = reactive({ person: '', tokenPrefix: '', tool: '', result: '', client: '', from: '', to: '' })
const tokenDialog = ref(false)
const tokenForm = reactive({ id: null, memo: '', scopeIds: [], expireTime: null })
const issuedPlaintext = ref('')
const tokenForm = reactive({ id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null })
const issuedTokens = ref([])
const scopeDialog = ref(false)
const scopeForm = reactive({ id: null, name: '', code: '', memo: '' })
const scopeToolsDialog = ref(false)
@@ -106,24 +106,26 @@ async function selectUser(user) {
function openToken(token = null) {
Object.assign(tokenForm, token ? {
id: token.id, memo: token.memo || '', scopeIds: [...token.scopeIds], expireTime: token.expireTime || null,
} : { id: null, memo: '', scopeIds: [], expireTime: null })
id: token.id, memo: token.memo || '', scopeIds: [], scopeId: token.scopeIds[0] ?? null,
expireTime: token.expireTime || null,
} : { id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null })
tokenDialog.value = true
}
async function saveToken() {
if (!tokenForm.scopeIds.length) return ElMessage.warning('至少选择一个作用域')
const scopeIds = tokenForm.id ? (tokenForm.scopeId ? [tokenForm.scopeId] : []) : tokenForm.scopeIds
if (!scopeIds.length) return ElMessage.warning('至少选择一个作用域')
await run(async () => {
const body = JSON.stringify({ memo: tokenForm.memo, scopeIds: tokenForm.scopeIds, expireTime: tokenForm.expireTime })
const body = JSON.stringify({ memo: tokenForm.memo, scopeIds, expireTime: tokenForm.expireTime })
if (tokenForm.id) await api(`/tokens/${tokenForm.id}`, { method: 'PUT', body })
else {
const issued = await api(`/users/${selectedUser.value.id}/tokens`, { method: 'POST', body })
issuedPlaintext.value = issued.plaintext
// 一作用域一 Token:选 N 个作用域返回 N 把明文
issuedTokens.value = await api(`/users/${selectedUser.value.id}/tokens`, { method: 'POST', body })
}
tokenDialog.value = false
tokens.value = await api(`/users/${selectedUser.value.id}/tokens`)
await loadUsers()
ElMessage.success(tokenForm.id ? 'Token 已更新' : 'Token 已签发')
ElMessage.success(tokenForm.id ? 'Token 已更新' : `已签发 ${issuedTokens.value.length} 把 Token`)
}).catch(() => {})
}
@@ -206,7 +208,10 @@ async function loadAudit() { audits.value = await api(`/audit${qs(auditFilter)}`
function date(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
function expiry(value) { return value ? date(value) : '永久' }
function enterprise(value) { return ({ 1: '代理', 2: '物业' })[value] || `类型 ${value ?? '未配置'}` }
async function copyToken() { await navigator.clipboard.writeText(issuedPlaintext.value); ElMessage.success('已复制') }
async function copyText(value) { await navigator.clipboard.writeText(value); ElMessage.success('已复制') }
async function copyAllTokens() {
await copyText(issuedTokens.value.map(item => `${item.token.scopeNames.join('/')}: ${item.plaintext}`).join('\n'))
}
onMounted(async () => {
try {
@@ -388,7 +393,15 @@ onMounted(async () => {
<el-form label-width="88px">
<el-form-item label="所属人员"><strong>{{ selectedUser?.name }}</strong></el-form-item>
<el-form-item label="用途备注"><el-input v-model="tokenForm.memo" maxlength="255" placeholder="如:Kimi 桌面用" aria-label="Token 用途备注" data-testid="admin-token-memo" /></el-form-item>
<el-form-item label="作用域"><el-checkbox-group v-model="tokenForm.scopeIds" data-testid="admin-token-scopes"><el-checkbox v-for="scope in scopes.filter(item => item.status === 1)" :key="scope.id" :value="scope.id">{{ scope.name }}</el-checkbox></el-checkbox-group></el-form-item>
<el-form-item label="作用域">
<el-radio-group v-if="tokenForm.id" v-model="tokenForm.scopeId" data-testid="admin-token-scopes">
<el-radio v-for="scope in scopes.filter(item => item.status === 1)" :key="scope.id" :value="scope.id">{{ scope.name }}</el-radio>
</el-radio-group>
<el-checkbox-group v-else v-model="tokenForm.scopeIds" data-testid="admin-token-scopes">
<el-checkbox v-for="scope in scopes.filter(item => item.status === 1)" :key="scope.id" :value="scope.id">{{ scope.name }}</el-checkbox>
</el-checkbox-group>
<div class="hint" style="margin:4px 0 0">{{ tokenForm.id ? '一把 Token 只绑一个作用域' : '一作用域一 Token:选几个作用域就签发几把' }}</div>
</el-form-item>
<el-form-item label="过期时间"><el-date-picker v-model="tokenForm.expireTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="留空为永久" aria-label="Token 过期时间" data-testid="admin-token-expiry" /></el-form-item>
</el-form>
<template #footer><el-button data-testid="admin-token-cancel" @click="tokenDialog = false">取消</el-button><el-button type="primary" data-testid="admin-token-save" @click="saveToken">确认</el-button></template>
@@ -410,9 +423,13 @@ onMounted(async () => {
<template #footer><el-button data-testid="admin-scope-tools-cancel" @click="scopeToolsDialog = false">取消</el-button><el-button type="primary" data-testid="admin-scope-tools-save" @click="saveScopeTools">保存配置</el-button></template>
</el-dialog>
<el-dialog :model-value="!!issuedPlaintext" title="Token 签发成功" width="560px" :close-on-click-modal="false" @close="issuedPlaintext = ''">
<el-alert title="明文只展示这一次,请立即复制并妥善保存。" type="warning" :closable="false" show-icon />
<code class="token-secret" data-testid="admin-issued-token">{{ issuedPlaintext }}</code>
<template #footer><el-button type="primary" data-testid="admin-copy-token" @click="copyToken">复制 Token</el-button><el-button data-testid="admin-close-token" @click="issuedPlaintext = ''">我已保存</el-button></template>
<el-dialog :model-value="!!issuedTokens.length" title="Token 签发成功" width="600px" :close-on-click-modal="false" @close="issuedTokens = []">
<el-alert title="每个作用域一把 Token,明文只展示这一次,请立即复制并妥善保存。" type="warning" :closable="false" show-icon />
<div v-for="item in issuedTokens" :key="item.token.id" class="issued-row">
<el-tag size="small" effect="plain">{{ item.token.scopeNames.join('/') }}</el-tag>
<code class="token-secret" :data-testid="`admin-issued-token-${item.token.id}`">{{ item.plaintext }}</code>
<el-button link type="primary" :data-testid="`admin-copy-token-${item.token.id}`" @click="copyText(item.plaintext)">复制</el-button>
</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>
</template>
+2
View File
@@ -60,4 +60,6 @@ code { font-family: Menlo, Consolas, monospace; font-size: 12px; }
.tool-group .el-checkbox__label { display: flex; flex-direction: column; white-space: normal; }
.tool-group small { margin-top: 3px; color: #909399; }
.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; }
@media (max-width: 1200px) { .token-layout { grid-template-columns: 300px minmax(640px, 1fr); } }