- 11 个菜单页面补 defineOptions name(缓存标识) - tabs store 加 keepAlive 字段 + pathToName 辅助 - layout 用 keep-alive 包裹 router-view,按打开且未关闭缓存的页签缓存 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
335 lines
11 KiB
Vue
335 lines
11 KiB
Vue
<template>
|
||
<div class="res-page">
|
||
<el-card class="res-page__search" shadow="never">
|
||
<ty-search-form :schema="searchSchema" :model-value="searchQuery" @search="handleSearch" @reset="handleReset" />
|
||
</el-card>
|
||
|
||
<el-card class="res-page__table" shadow="never">
|
||
<div class="res-page__toolbar">
|
||
<div class="res-page__toolbar-left">
|
||
<el-button type="primary" @click="openCreate">新增资源</el-button>
|
||
</div>
|
||
<div class="res-page__toolbar-right">
|
||
<el-button type="danger" :disabled="selectedRows.length === 0" @click="batchDelete(selectedRows)">
|
||
批量删除
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="res-page__table-body">
|
||
<ty-table
|
||
:columns="columns"
|
||
:data="displayTree"
|
||
selection
|
||
row-key="code"
|
||
:tree-props="{ children: 'children' }"
|
||
default-expand-all
|
||
:actions-width="180"
|
||
@selection-change="onSelectionChange"
|
||
>
|
||
<template #actions="{ row }">
|
||
<el-button link type="primary" @click="openCreateChild(row)">新增子</el-button>
|
||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||
</template>
|
||
</ty-table>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
|
||
defineOptions({ name: 'resource' })
|
||
import { computed, reactive, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import type { JsonSchema, TableColumn } from '@/types/crud'
|
||
import TyForm from '@/components/ty-form.vue'
|
||
import { showDialog } from '@/utils/dialog'
|
||
import { useRequest } from '@/composables/useRequest'
|
||
import { buildTree, collectDescendants, filterTree, type TreeNode } from '@/utils/tree'
|
||
import { batchDeleteResources, createResource, deleteResource, listResources, updateResource, type Resource, type ResourceUpsertData } from '@/api/resource'
|
||
|
||
type ResourceNode = TreeNode<Resource>
|
||
|
||
const { data: resourceList, refresh: refreshResources } = useRequest((signal: AbortSignal) => listResources(signal))
|
||
const resources = computed<Resource[]>(() => resourceList.value ?? [])
|
||
|
||
function pathDisplay(_value: unknown, row: Record<string, unknown>): string {
|
||
const type = row.type
|
||
if (type === 'api') {
|
||
const method = row.method as string | undefined
|
||
const apiPath = row.apiPath as string | undefined
|
||
return `${method ? `${method} ` : ''}${apiPath ?? ''}`.trim() || '-'
|
||
}
|
||
if (type === 'menu') return (row.path as string) || '-'
|
||
return '-'
|
||
}
|
||
|
||
const columns: TableColumn[] = [
|
||
{ prop: 'name', label: '资源名称', minWidth: 200 },
|
||
{ prop: 'type', label: '类型', width: 80, align: 'center', type: 'tag', dictCode: 'resource_type' },
|
||
{ prop: 'code', label: '资源编码', minWidth: 160 },
|
||
{ prop: 'path', label: '路由 / 接口', minWidth: 180, formatter: pathDisplay },
|
||
{ prop: 'sort', label: '排序', width: 70, align: 'center' },
|
||
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'common_status' },
|
||
]
|
||
|
||
const searchSchema: JsonSchema = {
|
||
type: 'object',
|
||
properties: {
|
||
name: { type: 'string', title: '资源名称', placeholder: '请输入名称' },
|
||
code: { type: 'string', title: '资源编码', placeholder: '请输入编码' },
|
||
type: {
|
||
type: 'string',
|
||
title: '类型',
|
||
dictCode: 'resource_type',
|
||
placeholder: '全部',
|
||
},
|
||
status: {
|
||
type: 'string',
|
||
title: '状态',
|
||
dictCode: 'common_status',
|
||
placeholder: '全部',
|
||
},
|
||
},
|
||
}
|
||
|
||
const searchQuery = reactive({ name: '', code: '', type: '', status: '' })
|
||
const appliedQuery = reactive({ name: '', code: '', type: '', status: '' })
|
||
|
||
const displayTree = computed<ResourceNode[]>(() =>
|
||
buildTree(
|
||
filterTree(resources.value, (r) => {
|
||
const name = appliedQuery.name.trim()
|
||
const code = appliedQuery.code.trim()
|
||
const type = appliedQuery.type
|
||
const status = appliedQuery.status
|
||
return (
|
||
(!name || r.name.includes(name)) &&
|
||
(!code || r.code.includes(code)) &&
|
||
(!type || r.type === type) &&
|
||
(!status || r.status === status)
|
||
)
|
||
}),
|
||
),
|
||
)
|
||
|
||
function handleSearch(): void {
|
||
appliedQuery.name = searchQuery.name
|
||
appliedQuery.code = searchQuery.code
|
||
appliedQuery.type = searchQuery.type
|
||
appliedQuery.status = searchQuery.status
|
||
}
|
||
|
||
function handleReset(): void {
|
||
appliedQuery.name = ''
|
||
appliedQuery.code = ''
|
||
appliedQuery.type = ''
|
||
appliedQuery.status = ''
|
||
}
|
||
|
||
// ---- 表单 ----
|
||
function treeSelectData(excludeCode?: string): ResourceNode[] {
|
||
const tree = buildTree(resources.value)
|
||
if (!excludeCode) return tree
|
||
const prune = (nodes: ResourceNode[]): ResourceNode[] =>
|
||
nodes.filter((n) => n.code !== excludeCode).map((n) => ({ ...n, children: prune(n.children) }))
|
||
return prune(tree)
|
||
}
|
||
|
||
function resourceFormSchema(excludeCode?: string): JsonSchema {
|
||
return {
|
||
type: 'object',
|
||
properties: {
|
||
parentCode: {
|
||
type: 'string',
|
||
title: '上级资源',
|
||
format: 'tree-select',
|
||
treeData: treeSelectData(excludeCode),
|
||
treeProps: { label: 'name', value: 'code', children: 'children' },
|
||
placeholder: '不选则为顶级资源',
|
||
},
|
||
type: {
|
||
type: 'string',
|
||
title: '资源类型',
|
||
dictCode: 'resource_type',
|
||
default: 'menu',
|
||
},
|
||
code: { type: 'string', title: '资源编码', placeholder: '如 system:user:create' },
|
||
name: { type: 'string', title: '资源名称', placeholder: '如 用户管理' },
|
||
path: { type: 'string', title: '路由路径', placeholder: '如 /system/user', when: { field: 'type', in: ['menu'] } },
|
||
component: { type: 'string', title: '组件路径', placeholder: '如 system/user/index', when: { field: 'type', in: ['menu'] } },
|
||
icon: { type: 'string', title: '图标', placeholder: '如 mdi:account-group', when: { field: 'type', in: ['menu'] } },
|
||
method: {
|
||
type: 'string',
|
||
title: '请求方法',
|
||
enum: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
|
||
placeholder: '请选择',
|
||
when: { field: 'type', equals: 'api' },
|
||
},
|
||
apiPath: { type: 'string', title: '接口路径', placeholder: '如 /users', when: { field: 'type', equals: 'api' } },
|
||
sort: { type: 'integer', title: '排序', minimum: 0, default: 0 },
|
||
status: {
|
||
type: 'string',
|
||
title: '状态',
|
||
dictCode: 'common_status',
|
||
default: 'enabled',
|
||
showOn: ['edit'],
|
||
},
|
||
},
|
||
required: ['code', 'name', 'type'],
|
||
}
|
||
}
|
||
|
||
function buildResourceModel(r?: Resource): Record<string, any> {
|
||
return {
|
||
parentCode: r?.parentCode ?? '',
|
||
type: r?.type ?? 'menu',
|
||
code: r?.code ?? '',
|
||
name: r?.name ?? '',
|
||
path: r?.path ?? '',
|
||
component: r?.component ?? '',
|
||
icon: r?.icon ?? '',
|
||
method: r?.method ?? '',
|
||
apiPath: r?.apiPath ?? '',
|
||
sort: r?.sort ?? 0,
|
||
status: r?.status ?? 'enabled',
|
||
}
|
||
}
|
||
|
||
/** 按类型归一化表单数据:菜单只带 path/component/icon,接口只带 method/apiPath。 */
|
||
function toUpsertData(data: Record<string, any>): ResourceUpsertData {
|
||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
|
||
const isMenu = data.type === 'menu'
|
||
const isApi = data.type === 'api'
|
||
return {
|
||
type: data.type,
|
||
code: str(data.code),
|
||
name: str(data.name),
|
||
parentCode: str(data.parentCode) ?? null,
|
||
path: isMenu ? str(data.path) : undefined,
|
||
component: isMenu ? str(data.component) : undefined,
|
||
icon: isMenu ? str(data.icon) : undefined,
|
||
method: isApi ? str(data.method) : undefined,
|
||
apiPath: isApi ? str(data.apiPath) : undefined,
|
||
sort: data.sort ?? 0,
|
||
status: str(data.status),
|
||
}
|
||
}
|
||
|
||
async function openCreate(): Promise<void> {
|
||
const data = await showDialog({
|
||
component: TyForm,
|
||
props: { schema: resourceFormSchema(), modelValue: buildResourceModel(), mode: 'create' },
|
||
title: '新增资源',
|
||
width: '520px',
|
||
})
|
||
if (data == null) return
|
||
await createResource(toUpsertData(data))
|
||
ElMessage.success('新增成功')
|
||
await refreshResources()
|
||
}
|
||
|
||
async function openCreateChild(parent: Record<string, any>): Promise<void> {
|
||
const model = buildResourceModel()
|
||
model.parentCode = parent.code
|
||
const data = await showDialog({
|
||
component: TyForm,
|
||
props: { schema: resourceFormSchema(), modelValue: model, mode: 'create' },
|
||
title: `新增子资源(${parent.name})`,
|
||
width: '520px',
|
||
})
|
||
if (data == null) return
|
||
const upsert = toUpsertData(data)
|
||
upsert.parentCode = upsert.parentCode || parent.code
|
||
await createResource(upsert)
|
||
ElMessage.success('新增成功')
|
||
await refreshResources()
|
||
}
|
||
|
||
async function openEdit(row: Record<string, any>): Promise<void> {
|
||
const o = row as Resource
|
||
const data = await showDialog({
|
||
component: TyForm,
|
||
props: {
|
||
schema: resourceFormSchema(o.code),
|
||
modelValue: buildResourceModel(o),
|
||
mode: 'edit',
|
||
readonlyKeys: ['code', 'type'],
|
||
},
|
||
title: '编辑资源',
|
||
width: '520px',
|
||
})
|
||
if (data == null) return
|
||
await updateResource(o.id, toUpsertData(data))
|
||
ElMessage.success('更新成功')
|
||
await refreshResources()
|
||
}
|
||
|
||
async function handleDelete(row: Record<string, any>): Promise<void> {
|
||
const o = row as Resource
|
||
await ElMessageBox.confirm(`确认删除资源「${o.name}」及其所有下级资源?`, '提示', { type: 'warning' })
|
||
const codes = new Set([o.code, ...collectDescendants(resources.value, o.code)])
|
||
const targets = resources.value.filter((x) => codes.has(x.code))
|
||
for (const t of targets) await deleteResource(t.id)
|
||
ElMessage.success('删除成功')
|
||
await refreshResources()
|
||
}
|
||
|
||
const selectedRows = ref<Record<string, any>[]>([])
|
||
|
||
function onSelectionChange(rows: Record<string, any>[]): void {
|
||
selectedRows.value = rows
|
||
}
|
||
|
||
async function batchDelete(rows: Record<string, any>[]): Promise<void> {
|
||
await ElMessageBox.confirm(`确认删除选中的 ${rows.length} 个资源及其下级?`, '提示', { type: 'warning' })
|
||
const codes = new Set<string>()
|
||
for (const r of rows) {
|
||
codes.add(String(r.code))
|
||
for (const c of collectDescendants(resources.value, String(r.code))) codes.add(c)
|
||
}
|
||
const ids = resources.value.filter((o) => codes.has(o.code)).map((o) => o.id)
|
||
const result = await batchDeleteResources(ids)
|
||
if (result.failed.length > 0) {
|
||
ElMessage.warning(`删除完成:成功 ${result.success} 个,失败 ${result.failed.length} 个`)
|
||
} else {
|
||
ElMessage.success('删除成功')
|
||
}
|
||
selectedRows.value = []
|
||
await refreshResources()
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.res-page {
|
||
@apply flex h-full flex-col;
|
||
}
|
||
|
||
.res-page__search {
|
||
@apply mb-md flex-shrink-0;
|
||
}
|
||
|
||
.res-page__table {
|
||
@apply flex min-h-0 flex-1 flex-col;
|
||
|
||
:deep(.el-card__body) {
|
||
@apply flex min-h-0 flex-1 flex-col;
|
||
}
|
||
}
|
||
|
||
.res-page__toolbar {
|
||
@apply mb-md flex flex-shrink-0 items-center justify-between;
|
||
}
|
||
|
||
.res-page__toolbar-left,
|
||
.res-page__toolbar-right {
|
||
@apply flex items-center gap-2;
|
||
}
|
||
|
||
.res-page__table-body {
|
||
@apply min-h-0 flex-1;
|
||
}
|
||
</style>
|