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:
@@ -40,6 +40,9 @@ const scopeForm = reactive({ id: null, name: '', code: '', memo: '' })
|
|||||||
const scopeToolsDialog = ref(false)
|
const scopeToolsDialog = ref(false)
|
||||||
const activeScope = ref(null)
|
const activeScope = ref(null)
|
||||||
const selectedToolIds = ref([])
|
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 title = computed(() => menus.find(item => item.id === current.value)?.label || '')
|
||||||
const categories = computed(() => [...new Set(allTools.value.map(tool => tool.category))])
|
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)
|
;(groups[category] ||= []).push(tool)
|
||||||
return groups
|
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(() => {
|
const issuedKeyCurl = computed(() => {
|
||||||
if (!issuedOpenKey.value) return ''
|
if (!issuedOpenKey.value) return ''
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
@@ -276,10 +300,23 @@ async function deleteScope(scope) {
|
|||||||
|
|
||||||
async function openScopeTools(scope) {
|
async function openScopeTools(scope) {
|
||||||
activeScope.value = scope
|
activeScope.value = scope
|
||||||
|
scopeToolsTab.value = ''; scopeToolsKeyword.value = ''; scopeToolsOnlySelected.value = false
|
||||||
;[selectedToolIds.value] = await Promise.all([api(`/scopes/${scope.id}/tools`), loadAllTools()])
|
;[selectedToolIds.value] = await Promise.all([api(`/scopes/${scope.id}/tools`), loadAllTools()])
|
||||||
scopeToolsDialog.value = true
|
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() {
|
async function saveScopeTools() {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
await api(`/scopes/${activeScope.value.id}/tools`, { method: 'PUT', body: JSON.stringify({ toolIds: selectedToolIds.value }) })
|
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>
|
<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>
|
||||||
|
|
||||||
<el-dialog v-model="scopeToolsDialog" :title="`配置工具 · ${activeScope?.name || ''}`" width="680px" destroy-on-close>
|
<el-dialog v-model="scopeToolsDialog" :title="`配置工具 · ${activeScope?.name || ''}`" width="780px" destroy-on-close>
|
||||||
<el-checkbox-group v-model="selectedToolIds" class="tool-groups" data-testid="admin-scope-tool-options">
|
<div class="scope-tools-toolbar">
|
||||||
<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-input v-model="scopeToolsKeyword" clearable placeholder="搜索工具名 / 描述" aria-label="搜索工具" data-testid="admin-scope-tools-search" />
|
||||||
</el-checkbox-group>
|
<el-checkbox v-model="scopeToolsOnlySelected" data-testid="admin-scope-tools-only-selected">仅看已选</el-checkbox>
|
||||||
<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>
|
</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>
|
||||||
|
|
||||||
<el-dialog :model-value="!!issuedTokens.length" title="Token 签发成功" width="600px" :close-on-click-modal="false" @close="issuedTokens = []">
|
<el-dialog :model-value="!!issuedTokens.length" title="Token 签发成功" width="600px" :close-on-click-modal="false" @close="issuedTokens = []">
|
||||||
|
|||||||
@@ -52,13 +52,14 @@ code { font-family: Menlo, Consolas, monospace; font-size: 12px; }
|
|||||||
.audit-toolbar .el-input { width: 150px; }
|
.audit-toolbar .el-input { width: 150px; }
|
||||||
.audit-toolbar .el-select { width: 140px; }
|
.audit-toolbar .el-select { width: 140px; }
|
||||||
.audit-toolbar .el-date-editor { width: 150px; }
|
.audit-toolbar .el-date-editor { width: 150px; }
|
||||||
.tool-groups { display: block; max-height: 520px; overflow: auto; }
|
.scope-tools-toolbar { display: flex; gap: 12px; align-items: center; }
|
||||||
.tool-group { margin-bottom: 12px; border: 1px solid #ebeef5; border-radius: 4px; }
|
.scope-tools-toolbar .el-input { max-width: 260px; }
|
||||||
.tool-group h3 { margin: 0; padding: 9px 12px; font-size: 14px; background: #fafafa; border-bottom: 1px solid #ebeef5; }
|
.scope-tools-tabs { margin-top: 4px; }
|
||||||
.tool-group .el-checkbox { width: 100%; height: auto; margin: 0; padding: 9px 12px; align-items: flex-start; }
|
.scope-tools-tabs .el-tabs__header { margin-bottom: 8px; }
|
||||||
.tool-group .el-checkbox + .el-checkbox { border-top: 1px solid #f2f3f5; }
|
.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; }
|
||||||
.tool-group .el-checkbox__label { display: flex; flex-direction: column; white-space: normal; }
|
.scope-tools-footer { display: flex; align-items: center; justify-content: space-between; width: 100%; }
|
||||||
.tool-group small { margin-top: 3px; color: #909399; }
|
.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; }
|
.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 { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
|
||||||
.issued-row .token-secret { flex: 1; margin-top: 0; padding: 9px 12px; }
|
.issued-row .token-secret { flex: 1; margin-top: 0; padding: 9px 12px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user