liuyun ff0e1dcd27 feat(layout): 多页签 keep-alive(默认缓存,meta.keepAlive:false 可关)
- 11 个菜单页面补 defineOptions name(缓存标识)
- tabs store 加 keepAlive 字段 + pathToName 辅助
- layout 用 keep-alive 包裹 router-view,按打开且未关闭缓存的页签缓存

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 22:13:18 +08:00

335 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>