feat(admin): 管理端改为多标签导航

- 菜单点击打开标签页,标签可关闭,关闭当前标签自动切换相邻标签
- 页面面板 v-show 保活,切换标签保留筛选/分页/选中状态
- 激活标签仍刷新数据,登录/退出重置标签
- 补充多标签契约测试
This commit is contained in:
wangheng
2026-09-23 13:03:16 +08:00
parent c1ebb30cce
commit 51f8639a3a
3 changed files with 62 additions and 5 deletions
+43 -5
View File
@@ -13,6 +13,7 @@ const menus = [
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([])
@@ -45,6 +46,9 @@ 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 || '其他'
@@ -109,6 +113,7 @@ async function login() {
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(() => {})
}
@@ -116,9 +121,11 @@ async function login() {
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()
@@ -127,6 +134,16 @@ async function switchMenu(id) {
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 })}`)
@@ -376,8 +393,20 @@ onMounted(async () => {
<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">
<template v-if="current === 'tokens'">
<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">
@@ -431,9 +460,11 @@ onMounted(async () => {
<p class="hint">数据权限跟人不跟 Token;每把 Token 仅通过作用域控制可用工具。</p>
</article>
</div>
</div>
</template>
<template v-else-if="current === 'clients'">
<template v-if="openTabs.includes('clients')">
<div v-show="current === 'clients'">
<article class="card">
<div class="card-title">
<span>服务密钥调用方</span>
@@ -456,9 +487,11 @@ onMounted(async () => {
</el-table>
<p class="hint">服务密钥用于调用 <code>/api/open/token/ensure</code> 换取业务 Token;作用域决定该密钥能为哪些数据域签发或获取 Token。</p>
</article>
</div>
</template>
<template v-else-if="current === 'tools'">
<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>
@@ -484,9 +517,11 @@ onMounted(async () => {
data-testid="admin-tool-pager" @current-change="changeToolPage" />
<p class="hint">“停用”是全局紧急开关,生效后任何 Token 都无法调用该工具。</p>
</article>
</div>
</template>
<template v-else-if="current === 'scopes'">
<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="暂无作用域">
@@ -505,9 +540,11 @@ onMounted(async () => {
</template></el-table-column>
</el-table>
</article>
</div>
</template>
<template v-else>
<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" />
@@ -535,6 +572,7 @@ onMounted(async () => {
<el-table-column prop="clientIp" label="IP" min-width="125" />
</el-table>
</article>
</div>
</template>
</section>
</main>
+6
View File
@@ -24,6 +24,12 @@ button, input { font: inherit; }
.admin-user { display: flex; align-items: center; gap: 8px; color: #606266; }
.avatar { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; color: #409eff; background: #ecf5ff; }
.content { padding: 20px; }
.tabbar { display: flex; align-items: flex-end; gap: 6px; padding: 8px 16px 0; overflow-x: auto; background: #f0f2f5; }
.tabbar-tab { display: inline-flex; align-items: center; gap: 6px; padding: 7px 10px 7px 14px; border: 1px solid #dcdfe6; border-bottom-color: transparent; border-radius: 6px 6px 0 0; color: #606266; background: #fafafa; cursor: pointer; font-size: 13px; white-space: nowrap; user-select: none; }
.tabbar-tab:hover { color: #409eff; }
.tabbar-tab.active { color: #409eff; background: white; border-color: #dcdfe6; border-bottom-color: white; box-shadow: 0 -1px 2px rgb(0 0 0 / 4%); }
.tabbar-close { display: inline-grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; color: #909399; background: transparent; cursor: pointer; font-size: 13px; line-height: 1; }
.tabbar-close:hover { color: white; background: #c0c4cc; }
.card { margin-bottom: 16px; padding: 20px; border-radius: 5px; background: white; box-shadow: 0 1px 2px rgb(0 0 0 / 4%); }
.card-title { min-height: 32px; margin-bottom: 14px; display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 15px; font-weight: 650; }
.card-title small { margin-left: 5px; color: #909399; font-size: 12px; font-weight: 400; }
@@ -29,3 +29,16 @@ test('prototype interactions expose stable test ids', async () => {
assert.match(source, new RegExp(`data-testid=["']${testId}["']`), testId)
}
})
test('multi-tab navigation exposes tab bar and close controls', async () => {
const source = await readFile(new URL('../src/App.vue', import.meta.url), 'utf8')
assert.match(source, /data-testid=["']admin-tabbar["']/, 'admin-tabbar')
assert.match(source, /admin-tab-\$\{tab\.id\}/, 'admin-tab-*')
assert.match(source, /admin-tab-close-\$\{tab\.id\}/, 'admin-tab-close-*')
assert.match(source, /openTabs\.includes\('tokens'\)/, 'tokens panel gated by openTabs')
assert.match(source, /openTabs\.includes\('clients'\)/, 'clients panel gated by openTabs')
assert.match(source, /openTabs\.includes\('tools'\)/, 'tools panel gated by openTabs')
assert.match(source, /openTabs\.includes\('scopes'\)/, 'scopes panel gated by openTabs')
assert.match(source, /openTabs\.includes\('audit'\)/, 'audit panel gated by openTabs')
assert.match(source, /function closeTab\(/, 'closeTab handler')
})