Files
xiaoqu_mcp_service/xiaoqu-agent-service/front/src/App.vue
T
wangheng 51f8639a3a feat(admin): 管理端改为多标签导航
- 菜单点击打开标签页,标签可关闭,关闭当前标签自动切换相邻标签
- 页面面板 v-show 保活,切换标签保留筛选/分页/选中状态
- 激活标签仍刷新数据,登录/退出重置标签
- 补充多标签契约测试
2026-09-23 13:03:16 +08:00

690 lines
44 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>