Files
xiaoqu_mcp_service/xiaoqu-agent-service/front/src/App.vue
T

419 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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: 'tools', label: '工具管理', icon: '🔧' },
{ id: 'scopes', label: '作用域管理', icon: '🧩' },
{ id: 'audit', label: '审计日志', icon: '📋' },
]
const loggedIn = ref(false)
const busy = ref(false)
const current = 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 scopes = ref([])
const tools = ref([])
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 scopeDialog = ref(false)
const scopeForm = reactive({ id: null, name: '', code: '', memo: '' })
const scopeToolsDialog = ref(false)
const activeScope = ref(null)
const selectedToolIds = ref([])
const title = computed(() => menus.find(item => item.id === current.value)?.label || '')
const categories = computed(() => [...new Set(tools.value.map(tool => tool.category))])
const groupedTools = computed(() => tools.value.reduce((groups, tool) => {
const category = tool.category || '其他'
;(groups[category] ||= []).push(tool)
return groups
}, {}))
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 = ''
await loadUsers()
}).catch(() => {})
}
async function logout() {
await run(() => api('/auth/logout', { method: 'POST' })).catch(() => {})
loggedIn.value = false; setCsrf('')
}
async function switchMenu(id) {
current.value = id
if (id === 'tokens') await loadUsers()
if (id === 'tools') await loadTools()
if (id === 'scopes') await loadScopes()
if (id === 'audit') await Promise.all([loadAudit(), api('/tools').then(data => { tools.value = data })])
}
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: [...token.scopeIds], expireTime: token.expireTime || null,
} : { id: null, memo: '', scopeIds: [], expireTime: null })
tokenDialog.value = true
}
async function saveToken() {
if (!tokenForm.scopeIds.length) return ElMessage.warning('至少选择一个作用域')
await run(async () => {
const body = JSON.stringify({ memo: tokenForm.memo, scopeIds: tokenForm.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
}
tokenDialog.value = false
tokens.value = await api(`/users/${selectedUser.value.id}/tokens`)
await loadUsers()
ElMessage.success(tokenForm.id ? 'Token 已更新' : '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 loadTools() {
tools.value = await api(`/tools${qs(toolFilter)}`)
}
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 || '' }
: { id: null, name: '', code: '', memo: '' })
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 })
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
;[selectedToolIds.value, tools.value] = await Promise.all([api(`/scopes/${scope.id}/tools`), api('/tools')])
scopeToolsDialog.value = true
}
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 copyToken() { await navigator.clipboard.writeText(issuedPlaintext.value); ElMessage.success('已复制') }
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 === '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>
<section v-loading="busy" class="content">
<template v-if="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>
</template>
<template v-else-if="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)" />
<el-button type="primary" data-testid="admin-tool-search" @click="run(loadTools)">查询</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>
<p class="hint">“停用”是全局紧急开关,生效后任何 Token 都无法调用该工具。</p>
</article>
</template>
<template v-else-if="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="更新时间" 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>
</template>
<template v-else>
<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 tools" :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>
</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-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-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>
<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="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>
</el-checkbox-group>
<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>
</template>