feat(admin): scope tools dialog with category tabs and table

- top category tabs with per-category counts and selected badges
- table layout: checkbox, tool name, description, type, status
- header checkbox for select-all with indeterminate state
- search filter and selected-only toggle
- footer summary of cross-category selections
- keep existing data-testid hooks for E2E tests
This commit is contained in:
my_local_kimi_dev2
2026-09-16 11:35:17 +08:00
parent 487a41df74
commit 12de320459
2 changed files with 81 additions and 12 deletions
+73 -5
View File
@@ -40,6 +40,9 @@ const scopeForm = reactive({ id: null, name: '', code: '', memo: '' })
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 categories = computed(() => [...new Set(allTools.value.map(tool => tool.category))])
@@ -48,6 +51,27 @@ const groupedTools = computed(() => allTools.value.reduce((groups, tool) => {
;(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({
@@ -276,10 +300,23 @@ async function deleteScope(scope) {
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 }) })
@@ -548,11 +585,42 @@ onMounted(async () => {
<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="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 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 = []">
+8 -7
View File
@@ -52,13 +52,14 @@ code { font-family: Menlo, Consolas, monospace; font-size: 12px; }
.audit-toolbar .el-input { width: 150px; }
.audit-toolbar .el-select { width: 140px; }
.audit-toolbar .el-date-editor { width: 150px; }
.tool-groups { display: block; max-height: 520px; overflow: auto; }
.tool-group { margin-bottom: 12px; border: 1px solid #ebeef5; border-radius: 4px; }
.tool-group h3 { margin: 0; padding: 9px 12px; font-size: 14px; background: #fafafa; border-bottom: 1px solid #ebeef5; }
.tool-group .el-checkbox { width: 100%; height: auto; margin: 0; padding: 9px 12px; align-items: flex-start; }
.tool-group .el-checkbox + .el-checkbox { border-top: 1px solid #f2f3f5; }
.tool-group .el-checkbox__label { display: flex; flex-direction: column; white-space: normal; }
.tool-group small { margin-top: 3px; color: #909399; }
.scope-tools-toolbar { display: flex; gap: 12px; align-items: center; }
.scope-tools-toolbar .el-input { max-width: 260px; }
.scope-tools-tabs { margin-top: 4px; }
.scope-tools-tabs .el-tabs__header { margin-bottom: 8px; }
.tab-badge { display: inline-block; margin-left: 6px; padding: 0 6px; font-size: 12px; line-height: 16px; border-radius: 8px; background: #f0f9eb; color: #67c23a; vertical-align: 2px; }
.scope-tools-footer { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.scope-tools-summary { color: #606266; font-size: 13px; text-align: left; }
.el-table .row-selected { background: #ecf5ff; }
.token-secret { display: block; margin-top: 14px; padding: 14px; word-break: break-all; border-radius: 4px; background: #f4f4f5; font-size: 13px; }
.issued-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.issued-row .token-secret { flex: 1; margin-top: 0; padding: 9px 12px; }