419 lines
27 KiB
Vue
419 lines
27 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: '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>
|