- 菜单点击打开标签页,标签可关闭,关闭当前标签自动切换相邻标签 - 页面面板 v-show 保活,切换标签保留筛选/分页/选中状态 - 激活标签仍刷新数据,登录/退出重置标签 - 补充多标签契约测试
690 lines
44 KiB
Vue
690 lines
44 KiB
Vue
<script setup>
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
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: '📋' },
|
||
]
|
||
const loggedIn = ref(false)
|
||
const busy = ref(false)
|
||
const current = ref('tokens')
|
||
const openTabs = ref(['tokens'])
|
||
const session = reactive({ name: '', csrfToken: '' })
|
||
const loginForm = reactive({ account: '', password: '' })
|
||
const users = ref([])
|
||
const userKeyword = ref('')
|
||
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 allTools = ref([])
|
||
const audits = ref([])
|
||
const toolFilter = reactive({ category: '', kind: '', keyword: '' })
|
||
const toolPage = reactive({ page: 1, pageSize: 20, total: 0 })
|
||
const auditFilter = reactive({ person: '', tokenPrefix: '', tool: '', result: '', client: '', from: '', to: '' })
|
||
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: '', serviceEnabled: false })
|
||
const scopeToolsDialog = ref(false)
|
||
const activeScope = ref(null)
|
||
const selectedToolIds = ref([])
|
||
const scopeToolsTab = ref('')
|
||
const scopeToolsKeyword = ref('')
|
||
const scopeToolsOnlySelected = ref(false)
|
||
|
||
const title = computed(() => menus.find(item => item.id === current.value)?.label || '')
|
||
const openedTabItems = computed(() => openTabs.value
|
||
.map(id => menus.find(item => item.id === id))
|
||
.filter(Boolean))
|
||
const categories = computed(() => [...new Set(allTools.value.map(tool => tool.category))])
|
||
const groupedTools = computed(() => allTools.value.reduce((groups, tool) => {
|
||
const category = tool.category || '其他'
|
||
;(groups[category] ||= []).push(tool)
|
||
return groups
|
||
}, {}))
|
||
const scopeToolTabs = computed(() => Object.entries(groupedTools.value).map(([category, items]) => ({
|
||
category,
|
||
total: items.length,
|
||
selected: items.filter(tool => selectedToolIds.value.includes(tool.id)).length,
|
||
})))
|
||
const scopeToolsVisible = computed(() => {
|
||
const keyword = scopeToolsKeyword.value.trim().toLowerCase()
|
||
const items = scopeToolsTab.value ? (groupedTools.value[scopeToolsTab.value] || []) : allTools.value
|
||
return items.filter(tool => {
|
||
if (scopeToolsOnlySelected.value && !selectedToolIds.value.includes(tool.id)) return false
|
||
if (!keyword) return true
|
||
return tool.name.toLowerCase().includes(keyword) || (tool.description || '').toLowerCase().includes(keyword)
|
||
})
|
||
})
|
||
const scopeToolsSummary = computed(() => {
|
||
const detail = scopeToolTabs.value.filter(tab => tab.selected).map(tab => `${tab.category} ×${tab.selected}`).join(' · ')
|
||
return { selected: selectedToolIds.value.length, total: allTools.value.length, detail }
|
||
})
|
||
const scopeToolsAllVisibleSelected = computed(() => scopeToolsVisible.value.length > 0
|
||
&& scopeToolsVisible.value.every(tool => selectedToolIds.value.includes(tool.id)))
|
||
const scopeToolsSomeVisibleSelected = computed(() => scopeToolsVisible.value.some(tool => selectedToolIds.value.includes(tool.id)))
|
||
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()
|
||
Object.entries(values).forEach(([key, value]) => { if (value !== '' && value != null) params.set(key, value) })
|
||
return params.toString() ? `?${params}` : ''
|
||
}
|
||
|
||
async function run(task) {
|
||
busy.value = true
|
||
try { return await task() }
|
||
catch (error) {
|
||
if (error.status === 401) { loggedIn.value = false; setCsrf('') }
|
||
ElMessage.error(error.message)
|
||
throw error
|
||
} finally { busy.value = false }
|
||
}
|
||
|
||
async function login() {
|
||
if (!loginForm.account.trim() || !loginForm.password) return ElMessage.warning('请输入账号和密码')
|
||
await run(async () => {
|
||
const data = await api('/auth/login', { method: 'POST', body: JSON.stringify(loginForm) })
|
||
Object.assign(session, data); setCsrf(data.csrfToken); loggedIn.value = true
|
||
loginForm.password = ''
|
||
openTabs.value = ['tokens']; current.value = 'tokens'
|
||
await loadUsers()
|
||
}).catch(() => {})
|
||
}
|
||
|
||
async function logout() {
|
||
await run(() => api('/auth/logout', { method: 'POST' })).catch(() => {})
|
||
loggedIn.value = false; setCsrf('')
|
||
openTabs.value = ['tokens']; current.value = 'tokens'
|
||
}
|
||
|
||
async function switchMenu(id) {
|
||
if (!openTabs.value.includes(id)) openTabs.value = [...openTabs.value, id]
|
||
current.value = id
|
||
if (id === 'tokens') await loadUsers()
|
||
if (id === 'clients') await loadOpenClients()
|
||
if (id === 'tools') await Promise.all([loadTools(), loadAllTools()])
|
||
if (id === 'scopes') await loadScopes()
|
||
if (id === 'audit') await Promise.all([loadAudit(), loadAllTools()])
|
||
}
|
||
|
||
async function closeTab(id) {
|
||
const index = openTabs.value.indexOf(id)
|
||
if (index === -1) return
|
||
openTabs.value = openTabs.value.filter(tab => tab !== id)
|
||
if (current.value !== id) return
|
||
const next = openTabs.value[Math.min(index, openTabs.value.length - 1)]
|
||
current.value = next || ''
|
||
if (next) await switchMenu(next)
|
||
}
|
||
|
||
async function loadUsers(resetPage = false) {
|
||
if (resetPage) userPage.page = 1
|
||
const data = await api(`/users${qs({ keyword: userKeyword.value, page: userPage.page, pageSize: userPage.pageSize })}`)
|
||
users.value = data.records
|
||
userPage.total = data.total
|
||
const existing = users.value.find(user => user.id === selectedUser.value?.id)
|
||
if (existing) selectedUser.value = existing
|
||
else if (users.value.length) await selectUser(users.value[0])
|
||
else { selectedUser.value = null; permission.value = null; tokens.value = [] }
|
||
}
|
||
|
||
async function changeUserPage(page) {
|
||
userPage.page = page
|
||
await run(loadUsers)
|
||
}
|
||
|
||
async function selectUser(user) {
|
||
selectedUser.value = user
|
||
;[permission.value, tokens.value, scopes.value] = await Promise.all([
|
||
api(`/users/${user.id}/permission`), api(`/users/${user.id}/tokens`), api('/scopes'),
|
||
])
|
||
}
|
||
|
||
function openToken(token = null) {
|
||
Object.assign(tokenForm, token ? {
|
||
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() {
|
||
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, expireTime: tokenForm.expireTime })
|
||
if (tokenForm.id) await api(`/tokens/${tokenForm.id}`, { method: 'PUT', body })
|
||
else {
|
||
// 一作用域一 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 已更新' : `已签发 ${issuedTokens.value.length} 把 Token`)
|
||
}).catch(() => {})
|
||
}
|
||
|
||
async function setTokenStatus(token) {
|
||
await run(async () => {
|
||
await api(`/tokens/${token.id}/status`, { method: 'PATCH', body: JSON.stringify({ status: token.status === 1 ? 0 : 1 }) })
|
||
tokens.value = await api(`/users/${selectedUser.value.id}/tokens`)
|
||
}).catch(() => {})
|
||
}
|
||
|
||
async function deleteToken(token) {
|
||
try { await ElMessageBox.confirm(`确认删除 ${token.keyPrefix}?此操作不可恢复。`, '删除 Token', { type: 'warning' }) }
|
||
catch { return }
|
||
await run(async () => {
|
||
await api(`/tokens/${token.id}`, { method: 'DELETE' })
|
||
tokens.value = await api(`/users/${selectedUser.value.id}/tokens`)
|
||
await loadUsers()
|
||
}).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(resetPage = false) {
|
||
if (resetPage) toolPage.page = 1
|
||
const data = await api(`/tools${qs({ ...toolFilter, page: toolPage.page, pageSize: toolPage.pageSize })}`)
|
||
tools.value = data.records
|
||
toolPage.total = data.total
|
||
}
|
||
|
||
async function loadAllTools() {
|
||
const data = await api('/tools?pageSize=0')
|
||
allTools.value = data.records
|
||
}
|
||
|
||
async function changeToolPage(page) {
|
||
toolPage.page = page
|
||
await run(loadTools)
|
||
}
|
||
|
||
async function setToolStatus(tool) {
|
||
await run(async () => {
|
||
await api(`/tools/${tool.id}/status`, { method: 'PATCH', body: JSON.stringify({ status: tool.status === 1 ? 0 : 1 }) })
|
||
await loadTools()
|
||
}).catch(() => {})
|
||
}
|
||
|
||
async function loadScopes() { scopes.value = await api('/scopes') }
|
||
|
||
function openScope(scope = null) {
|
||
Object.assign(scopeForm, scope ? { id: scope.id, name: scope.name, code: scope.code, memo: scope.memo || '', serviceEnabled: scope.serviceEnabled === 1 }
|
||
: { id: null, name: '', code: '', memo: '', serviceEnabled: false })
|
||
scopeDialog.value = true
|
||
}
|
||
|
||
async function saveScope() {
|
||
if (!scopeForm.name.trim() || !scopeForm.code.trim()) return ElMessage.warning('请填写名称和编码')
|
||
await run(async () => {
|
||
const body = JSON.stringify({ name: scopeForm.name, code: scopeForm.code, memo: scopeForm.memo, serviceEnabled: scopeForm.serviceEnabled })
|
||
if (scopeForm.id) await api(`/scopes/${scopeForm.id}`, { method: 'PUT', body })
|
||
else await api('/scopes', { method: 'POST', body })
|
||
scopeDialog.value = false; await loadScopes(); ElMessage.success('作用域已保存')
|
||
}).catch(() => {})
|
||
}
|
||
|
||
async function setScopeStatus(scope) {
|
||
await run(async () => {
|
||
await api(`/scopes/${scope.id}/status`, { method: 'PATCH', body: JSON.stringify({ status: scope.status === 1 ? 0 : 1 }) })
|
||
await loadScopes()
|
||
}).catch(() => {})
|
||
}
|
||
|
||
async function deleteScope(scope) {
|
||
try { await ElMessageBox.confirm(`确认删除作用域“${scope.name}”?`, '删除作用域', { type: 'warning' }) }
|
||
catch { return }
|
||
await run(async () => {
|
||
await api(`/scopes/${scope.id}`, { method: 'DELETE' })
|
||
await loadScopes(); ElMessage.success('作用域已删除')
|
||
}).catch(() => {})
|
||
}
|
||
|
||
async function openScopeTools(scope) {
|
||
activeScope.value = scope
|
||
scopeToolsTab.value = ''; scopeToolsKeyword.value = ''; scopeToolsOnlySelected.value = false
|
||
;[selectedToolIds.value] = await Promise.all([api(`/scopes/${scope.id}/tools`), loadAllTools()])
|
||
scopeToolsDialog.value = true
|
||
}
|
||
|
||
function toggleScopeTool(id, checked) {
|
||
const set = new Set(selectedToolIds.value)
|
||
if (checked) set.add(id); else set.delete(id)
|
||
selectedToolIds.value = [...set]
|
||
}
|
||
|
||
function toggleScopeToolsVisible(checked) {
|
||
const set = new Set(selectedToolIds.value)
|
||
scopeToolsVisible.value.forEach(tool => { if (checked) set.add(tool.id); else set.delete(tool.id) })
|
||
selectedToolIds.value = [...set]
|
||
}
|
||
|
||
async function saveScopeTools() {
|
||
await run(async () => {
|
||
await api(`/scopes/${activeScope.value.id}/tools`, { method: 'PUT', body: JSON.stringify({ toolIds: selectedToolIds.value }) })
|
||
scopeToolsDialog.value = false; await loadScopes(); ElMessage.success('工具配置已保存')
|
||
}).catch(() => {})
|
||
}
|
||
|
||
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 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'))
|
||
}
|
||
function clearIssuedOpenKey() { issuedOpenKey.value = null }
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
const data = await api('/auth/me')
|
||
Object.assign(session, data); setCsrf(data.csrfToken); loggedIn.value = true; await loadUsers()
|
||
} catch { loggedIn.value = false }
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="!loggedIn" class="login-page">
|
||
<section class="login-card" @keyup.enter="login">
|
||
<div class="brand-mark">趣</div>
|
||
<h1>小趣智能体服务</h1>
|
||
<p>管理后台</p>
|
||
<label class="field-label" for="account">管理员账号</label>
|
||
<el-input id="account" v-model="loginForm.account" size="large" autocomplete="username" aria-label="管理员账号"
|
||
placeholder="请输入管理员账号" data-testid="admin-input-account" />
|
||
<label class="field-label" for="password">密码</label>
|
||
<el-input id="password" v-model="loginForm.password" type="password" show-password size="large"
|
||
autocomplete="current-password" aria-label="密码" placeholder="请输入密码" data-testid="admin-input-password" />
|
||
<el-button type="primary" size="large" :loading="busy" data-testid="admin-btn-login" @click="login">登 录</el-button>
|
||
</section>
|
||
</div>
|
||
|
||
<div v-else class="shell">
|
||
<aside class="sidebar">
|
||
<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>
|
||
</nav>
|
||
</aside>
|
||
<main class="main">
|
||
<header class="header">
|
||
<div><span class="header-kicker">控制台</span><strong>{{ title }}</strong></div>
|
||
<div class="admin-user"><span class="avatar">{{ session.name?.slice(0, 1) }}</span>{{ session.name }}
|
||
<el-button text data-testid="admin-btn-logout" @click="logout">退出</el-button>
|
||
</div>
|
||
</header>
|
||
<nav class="tabbar" aria-label="已打开的标签页" data-testid="admin-tabbar">
|
||
<span v-for="tab in openedTabItems" :key="tab.id"
|
||
:class="['tabbar-tab', { active: current === tab.id }]"
|
||
:data-testid="`admin-tab-${tab.id}`"
|
||
@click="switchMenu(tab.id)">
|
||
{{ tab.icon }} {{ tab.label }}
|
||
<button class="tabbar-close" :aria-label="`关闭${tab.label}`"
|
||
:data-testid="`admin-tab-close-${tab.id}`" @click.stop="closeTab(tab.id)">×</button>
|
||
</span>
|
||
</nav>
|
||
<section v-loading="busy" class="content">
|
||
<el-empty v-if="!openTabs.length" :image-size="80" description="所有标签页已关闭,点击左侧菜单重新打开" />
|
||
<template v-if="openTabs.includes('tokens')">
|
||
<div v-show="current === 'tokens'">
|
||
<div class="token-layout">
|
||
<div>
|
||
<article class="card">
|
||
<div class="card-title"><span>人员</span><small>来自现有 user 表 · 仅企业账号(不含物业)</small></div>
|
||
<div class="toolbar compact">
|
||
<el-input v-model="userKeyword" clearable placeholder="姓名 / 手机号" aria-label="搜索人员"
|
||
data-testid="admin-input-user-search" @keyup.enter="run(() => loadUsers(true))" />
|
||
<el-button data-testid="admin-user-search-button" @click="run(() => loadUsers(true))">搜索</el-button>
|
||
</div>
|
||
<div class="user-list">
|
||
<button v-for="user in users" :key="user.id" :class="['user-row', { selected: selectedUser?.id === user.id }]"
|
||
:data-testid="`admin-user-row-${user.id}`" @click="run(() => selectUser(user))">
|
||
<span><strong>{{ user.name || '未命名' }}</strong><small>{{ user.mobile || '无手机号' }}</small></span>
|
||
<el-tag size="small" effect="plain">{{ user.tokenCount }} 把</el-tag>
|
||
</button>
|
||
<el-empty v-if="!users.length" :image-size="60" description="未找到人员" />
|
||
</div>
|
||
<el-pagination class="user-pager" layout="prev, pager, next, total" :total="userPage.total"
|
||
:page-size="userPage.pageSize" :current-page="userPage.page" small background
|
||
data-testid="admin-user-pager" @current-change="changeUserPage" />
|
||
</article>
|
||
<article class="card permission-card">
|
||
<div class="card-title"><span>数据权限归属</span><small>只读 · 在现有系统维护</small></div>
|
||
<dl v-if="permission">
|
||
<div><dt>权限组</dt><dd>{{ permission.authorityName }} <el-tag v-if="permission.isAgent != null" size="small" type="warning">isagent={{ permission.isAgent }}</el-tag></dd></div>
|
||
<div><dt>所属代理商</dt><dd>{{ permission.agentName }}</dd></div>
|
||
<div><dt>企业类型</dt><dd>{{ enterprise(permission.enterpriseType) }}</dd></div>
|
||
<div><dt>可见数据范围</dt><dd>{{ permission.visibleRange }}</dd></div>
|
||
</dl>
|
||
<p class="hint">行级权限跟人走。排查“查不到数据”时先检查这里。</p>
|
||
</article>
|
||
</div>
|
||
<article class="card token-card">
|
||
<div class="card-title">
|
||
<span>{{ selectedUser?.name || '请选择人员' }} 的 Token</span>
|
||
<el-button type="primary" :disabled="!selectedUser" data-testid="admin-btn-issue-token" @click="openToken()">+ 签发 Token</el-button>
|
||
</div>
|
||
<el-table :data="tokens" data-testid="admin-token-table" empty-text="暂无 Token">
|
||
<el-table-column label="前缀" min-width="125"><template #default="{ row }"><code>{{ row.keyPrefix }}…</code></template></el-table-column>
|
||
<el-table-column prop="memo" label="备注" min-width="150" />
|
||
<el-table-column label="作用域" min-width="180"><template #default="{ row }"><el-tag v-for="name in row.scopeNames" :key="name" size="small" effect="plain">{{ name }}</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="150"><template #default="{ row }">{{ expiry(row.expireTime) }}</template></el-table-column>
|
||
<el-table-column label="最后使用" min-width="150"><template #default="{ row }">{{ date(row.lastUsedTime) }}</template></el-table-column>
|
||
<el-table-column label="操作" width="175" fixed="right"><template #default="{ row }">
|
||
<el-button link type="primary" :data-testid="`admin-token-edit-${row.id}`" @click="openToken(row)">编辑</el-button>
|
||
<el-button link :type="row.status === 1 ? 'warning' : 'success'" :data-testid="`admin-token-status-${row.id}`" @click="setTokenStatus(row)">{{ row.status === 1 ? '禁用' : '启用' }}</el-button>
|
||
<el-button link type="danger" :data-testid="`admin-token-delete-${row.id}`" @click="deleteToken(row)">删除</el-button>
|
||
</template></el-table-column>
|
||
</el-table>
|
||
<p class="hint">数据权限跟人不跟 Token;每把 Token 仅通过作用域控制可用工具。</p>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-if="openTabs.includes('clients')">
|
||
<div v-show="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>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-if="openTabs.includes('tools')">
|
||
<div v-show="current === 'tools'">
|
||
<article class="card">
|
||
<div class="toolbar">
|
||
<el-select v-model="toolFilter.category" clearable placeholder="全部分类" aria-label="工具分类" data-testid="admin-tool-category"><el-option v-for="item in categories" :key="item" :label="item" :value="item" /></el-select>
|
||
<el-select v-model="toolFilter.kind" clearable placeholder="全部类型" aria-label="工具类型" data-testid="admin-tool-kind"><el-option label="读" value="read" /><el-option label="写" value="write" /></el-select>
|
||
<el-input v-model="toolFilter.keyword" clearable placeholder="工具名 / 描述" aria-label="工具关键词" data-testid="admin-tool-keyword" @keyup.enter="run(() => loadTools(true))" />
|
||
<el-button type="primary" data-testid="admin-tool-search" @click="run(() => loadTools(true))">查询</el-button>
|
||
</div>
|
||
</article>
|
||
<article class="card">
|
||
<div class="card-title"><span>工具列表</span><small>服务端注册自动同步</small></div>
|
||
<el-table :data="tools" data-testid="admin-table-tools" empty-text="暂无工具">
|
||
<el-table-column prop="name" label="工具名" min-width="210"><template #default="{ row }"><code>{{ row.name }}</code></template></el-table-column>
|
||
<el-table-column prop="category" label="分类" width="110" />
|
||
<el-table-column prop="description" label="描述" min-width="220" show-overflow-tooltip />
|
||
<el-table-column label="类型" width="80"><template #default="{ row }"><el-tag :type="row.kind === 'write' ? 'danger' : 'success'" size="small">{{ row.kind === 'write' ? '写' : '读' }}</el-tag></template></el-table-column>
|
||
<el-table-column label="所属作用域" min-width="180"><template #default="{ row }"><el-tag v-for="name in row.scopeNames" :key="name" size="small" effect="plain">{{ name }}</el-tag><span v-if="!row.scopeNames.length" class="danger-text">未被引用</span></template></el-table-column>
|
||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '启用' : '已停用' }}</el-tag></template></el-table-column>
|
||
<el-table-column label="同步时间" min-width="145"><template #default="{ row }">{{ date(row.modifyTime) }}</template></el-table-column>
|
||
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button link :type="row.status === 1 ? 'danger' : 'primary'" :data-testid="`admin-tool-status-${row.id}`" @click="setToolStatus(row)">{{ row.status === 1 ? '停用' : '启用' }}</el-button></template></el-table-column>
|
||
</el-table>
|
||
<el-pagination class="user-pager" layout="prev, pager, next, total" :total="toolPage.total"
|
||
:page-size="toolPage.pageSize" :current-page="toolPage.page" small background
|
||
data-testid="admin-tool-pager" @current-change="changeToolPage" />
|
||
<p class="hint">“停用”是全局紧急开关,生效后任何 Token 都无法调用该工具。</p>
|
||
</article>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-if="openTabs.includes('scopes')">
|
||
<div v-show="current === 'scopes'">
|
||
<article class="card">
|
||
<div class="card-title"><span>作用域</span><el-button type="primary" data-testid="admin-btn-new-scope" @click="openScope()">+ 新建作用域</el-button></div>
|
||
<el-table :data="scopes" data-testid="admin-table-scopes" empty-text="暂无作用域">
|
||
<el-table-column prop="name" label="名称" min-width="140" />
|
||
<el-table-column prop="code" label="编码" min-width="150"><template #default="{ row }"><code>{{ row.code }}</code></template></el-table-column>
|
||
<el-table-column prop="memo" label="备注" min-width="220" />
|
||
<el-table-column label="工具数" width="90"><template #default="{ row }">{{ row.toolCount }} 个</template></el-table-column>
|
||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '启用' : '已停用' }}</el-tag></template></el-table-column>
|
||
<el-table-column label="服务主体" width="90"><template #default="{ row }"><el-tag v-if="row.serviceEnabled === 1" type="warning" size="small">允许</el-tag><span v-else>—</span></template></el-table-column>
|
||
<el-table-column label="更新时间" min-width="150"><template #default="{ row }">{{ date(row.modifyTime) }}</template></el-table-column>
|
||
<el-table-column label="操作" width="255"><template #default="{ row }">
|
||
<el-button link type="primary" :data-testid="`admin-scope-tools-${row.id}`" @click="openScopeTools(row)">配置工具</el-button>
|
||
<el-button link type="primary" :data-testid="`admin-scope-edit-${row.id}`" @click="openScope(row)">编辑</el-button>
|
||
<el-button link :type="row.status === 1 ? 'danger' : 'success'" :data-testid="`admin-scope-status-${row.id}`" @click="setScopeStatus(row)">{{ row.status === 1 ? '停用' : '启用' }}</el-button>
|
||
<el-button link type="danger" :data-testid="`admin-scope-delete-${row.id}`" @click="deleteScope(row)">删除</el-button>
|
||
</template></el-table-column>
|
||
</el-table>
|
||
</article>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-if="openTabs.includes('audit')">
|
||
<div v-show="current === 'audit'">
|
||
<article class="card">
|
||
<div class="toolbar audit-toolbar">
|
||
<el-input v-model="auditFilter.person" clearable placeholder="姓名 / 手机号" aria-label="人员" data-testid="admin-audit-person" />
|
||
<el-input v-model="auditFilter.tokenPrefix" clearable placeholder="Token 前缀" aria-label="Token 前缀" data-testid="admin-audit-token" />
|
||
<el-select v-model="auditFilter.tool" clearable filterable placeholder="全部工具" aria-label="工具名" data-testid="admin-audit-tool"><el-option v-for="item in allTools" :key="item.id" :label="item.name" :value="item.name" /></el-select>
|
||
<el-select v-model="auditFilter.result" clearable placeholder="全部结果" aria-label="调用结果" data-testid="admin-audit-result"><el-option label="成功" value="success" /><el-option label="拒绝" value="denied" /><el-option label="错误" value="error" /></el-select>
|
||
<el-select v-model="auditFilter.client" clearable placeholder="全部客户端" aria-label="客户端" data-testid="admin-audit-client"><el-option label="Kimi" value="mcp-kimi" /><el-option label="Claude" value="mcp-claude" /><el-option label="CLI" value="cli" /><el-option label="未知" value="unknown" /></el-select>
|
||
<el-date-picker v-model="auditFilter.from" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" aria-label="开始日期" data-testid="admin-audit-from" />
|
||
<el-date-picker v-model="auditFilter.to" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" aria-label="结束日期" data-testid="admin-audit-to" />
|
||
<el-button type="primary" data-testid="admin-audit-search" @click="run(loadAudit)">查询</el-button>
|
||
</div>
|
||
</article>
|
||
<article class="card">
|
||
<div class="card-title"><span>调用审计</span><small>最近最多 200 条</small></div>
|
||
<el-table :data="audits" data-testid="admin-table-audit" empty-text="暂无审计记录">
|
||
<el-table-column label="时间" min-width="165"><template #default="{ row }">{{ date(row.createTime) }}</template></el-table-column>
|
||
<el-table-column prop="userName" label="人员" min-width="110" />
|
||
<el-table-column prop="tokenPrefix" label="Token" min-width="125"><template #default="{ row }"><code>{{ row.tokenPrefix || '—' }}</code></template></el-table-column>
|
||
<el-table-column prop="toolName" label="工具" min-width="190" />
|
||
<el-table-column prop="paramsDigest" label="参数摘要" min-width="190" show-overflow-tooltip />
|
||
<el-table-column label="结果" width="90"><template #default="{ row }"><el-tag :type="row.resultStatus === 'success' ? 'success' : row.resultStatus === 'denied' ? 'warning' : 'danger'" size="small">{{ row.resultStatus }}</el-tag></template></el-table-column>
|
||
<el-table-column prop="resultMessage" label="说明" min-width="160" show-overflow-tooltip />
|
||
<el-table-column label="耗时" width="90"><template #default="{ row }">{{ row.costMs == null ? '—' : `${row.costMs} ms` }}</template></el-table-column>
|
||
<el-table-column prop="clientType" label="客户端" width="110" />
|
||
<el-table-column prop="clientIp" label="IP" min-width="125" />
|
||
</el-table>
|
||
</article>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
|
||
<el-dialog v-model="tokenDialog" :title="tokenForm.id ? '编辑 Token' : '签发 Token'" width="560px" destroy-on-close>
|
||
<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-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>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="scopeDialog" :title="scopeForm.id ? '编辑作用域' : '新建作用域'" width="520px" destroy-on-close>
|
||
<el-form label-width="88px">
|
||
<el-form-item label="名称"><el-input v-model="scopeForm.name" maxlength="64" aria-label="作用域名称" data-testid="admin-scope-name" /></el-form-item>
|
||
<el-form-item label="编码"><el-input v-model="scopeForm.code" maxlength="64" placeholder="如 operation_read" aria-label="作用域编码" data-testid="admin-scope-code" /></el-form-item>
|
||
<el-form-item label="备注"><el-input v-model="scopeForm.memo" type="textarea" maxlength="255" aria-label="作用域备注" data-testid="admin-scope-memo" /></el-form-item>
|
||
<el-form-item label="服务主体"><el-switch v-model="scopeForm.serviceEnabled" data-testid="admin-scope-service-enabled" /><span class="hint" style="margin-left:8px">允许服务 Token(无登录身份)绑定签发,其调用查询工具时全量可见</span></el-form-item>
|
||
</el-form>
|
||
<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="780px" destroy-on-close>
|
||
<div class="scope-tools-toolbar">
|
||
<el-input v-model="scopeToolsKeyword" clearable placeholder="搜索工具名 / 描述" aria-label="搜索工具" data-testid="admin-scope-tools-search" />
|
||
<el-checkbox v-model="scopeToolsOnlySelected" data-testid="admin-scope-tools-only-selected">仅看已选</el-checkbox>
|
||
</div>
|
||
<el-tabs v-model="scopeToolsTab" class="scope-tools-tabs" data-testid="admin-scope-tools-tabs">
|
||
<el-tab-pane name="">
|
||
<template #label>全部({{ allTools.length }})<span v-if="scopeToolsSummary.selected" class="tab-badge">{{ scopeToolsSummary.selected }}</span></template>
|
||
</el-tab-pane>
|
||
<el-tab-pane v-for="tab in scopeToolTabs" :key="tab.category" :name="tab.category">
|
||
<template #label>{{ tab.category }}({{ tab.total }})<span v-if="tab.selected" class="tab-badge">{{ tab.selected }}</span></template>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
<el-table :data="scopeToolsVisible" height="360" empty-text="没有匹配的工具" data-testid="admin-scope-tool-options"
|
||
:row-class-name="({ row }) => selectedToolIds.includes(row.id) ? 'row-selected' : ''">
|
||
<el-table-column width="48" align="center">
|
||
<template #header>
|
||
<el-checkbox :model-value="scopeToolsAllVisibleSelected" :indeterminate="scopeToolsSomeVisibleSelected && !scopeToolsAllVisibleSelected"
|
||
aria-label="全选当前列表" data-testid="admin-scope-tool-select-all" @change="toggleScopeToolsVisible" />
|
||
</template>
|
||
<template #default="{ row }">
|
||
<el-checkbox :model-value="selectedToolIds.includes(row.id)" :aria-label="row.name"
|
||
:data-testid="`admin-scope-tool-${row.id}`" @change="checked => toggleScopeTool(row.id, checked)" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="工具名" min-width="200"><template #default="{ row }"><code>{{ row.name }}</code></template></el-table-column>
|
||
<el-table-column prop="description" label="描述" min-width="220" show-overflow-tooltip />
|
||
<el-table-column label="类型" width="70"><template #default="{ row }"><el-tag :type="row.kind === 'write' ? 'danger' : 'success'" size="small">{{ row.kind === 'write' ? '写' : '读' }}</el-tag></template></el-table-column>
|
||
<el-table-column label="状态" width="80"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '启用' : '已停用' }}</el-tag></template></el-table-column>
|
||
</el-table>
|
||
<template #footer>
|
||
<div class="scope-tools-footer">
|
||
<span class="scope-tools-summary" data-testid="admin-scope-tools-summary">已选 {{ scopeToolsSummary.selected }}/{{ scopeToolsSummary.total }} 个工具<template v-if="scopeToolsSummary.detail"> {{ scopeToolsSummary.detail }}</template></span>
|
||
<span><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></span>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<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>
|
||
|
||
<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>
|