feat: resource management page + 404 fallback + header fixes

- 资源管理:树形表格 + type 联动表单(menu/button/api 字段显隐)
- ty-form 新增 when 条件字段显隐;ty-crud 表单模型改 reactive
- 抽出 src/utils/tree.ts(buildTree/filterTree/collectDescendants),组织页复用
- 404 兜底:catch-all 路由 [...all].vue
- 顶栏去掉横向菜单底边框;修复切 Tab 顶部一级菜单高亮不跟随

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 11:06:21 +08:00
parent b5d95c95d8
commit b9c3a64c34
10 changed files with 590 additions and 80 deletions

View File

@ -136,7 +136,7 @@ const selectedRows = ref<Record<string, any>[]>([])
const query = reactive<Record<string, any>>(buildModel(props.searchSchema))
function buildModel(schema?: JsonSchema): Record<string, any> {
const model: Record<string, any> = {}
const model: Record<string, any> = reactive({})
if (!schema) return model
for (const [key, prop] of Object.entries(schema.properties)) {
if (prop.default !== undefined) model[key] = prop.default

View File

@ -122,17 +122,25 @@ function onEnter(): void {
if (props.enterToSubmit) emit('submit')
}
/** 按 mode 过滤 showOn 字段。 */
/** 按 mode 过滤 showOn 字段,并应用 when 条件(依赖字段值联动显隐)。 */
const visibleProperties = computed(() => {
const result: Record<string, JsonSchemaProperty> = {}
for (const [key, prop] of Object.entries(props.schema.properties)) {
if (!props.mode || !prop.showOn || prop.showOn.includes(props.mode)) {
result[key] = prop
}
if (props.mode && prop.showOn && !prop.showOn.includes(props.mode)) continue
if (prop.when && !matchesWhen(props.modelValue, prop.when)) continue
result[key] = prop
}
return result
})
/** when 条件匹配:依赖字段值等于 equals 或属于 in。 */
function matchesWhen(model: Record<string, any>, when: { field: string; equals?: unknown; in?: unknown[] }): boolean {
const value = model[when.field]
if (when.equals !== undefined) return value === when.equals
if (when.in) return when.in.includes(value)
return true
}
const rules = computed<FormRules>(() => {
const result: FormRules = {}
for (const [key, prop] of Object.entries(props.schema.properties)) {

View File

@ -16,7 +16,7 @@
class="ty-header__nav"
mode="horizontal"
:ellipsis="false"
:default-active="activePath"
:default-active="activeIndex"
@select="onNavSelect"
>
<template v-for="item in menus" :key="item.code">
@ -29,7 +29,7 @@
{{ child.name }}
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="item.path ?? item.code">
<el-menu-item v-else :index="item.code">
<icon type="iconify" :icon="item.icon ?? 'mdi:menu'" class="menu-icon" />
<span>{{ item.name }}</span>
</el-menu-item>
@ -65,13 +65,14 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ResourceNode } from '@/types/resource'
import type { LayoutType } from '@/config/layout'
import type { BreadcrumbItem } from './ty-breadcrumb.vue'
defineOptions({ name: 'TyHeader' })
withDefaults(
const props = withDefaults(
defineProps<{
/** 应用标题。 */
title?: string
@ -126,6 +127,16 @@ withDefaults(
},
)
/** 当前激活的顶部一级菜单 code(高亮包含当前路由的一级菜单)。 */
const activeIndex = computed(() => {
const path = props.activePath
for (const m of props.menus) {
if ((m.path ?? m.code) === path) return m.code
if (m.children?.some((c) => (c.path ?? c.code) === path)) return m.code
}
return path
})
const emit = defineEmits<{
(e: 'toggle-collapse'): void
(e: 'toggle-dark'): void
@ -214,9 +225,13 @@ function onUserCommand(command: string): void {
&__nav {
height: var(--ty-header-height, 56px);
border-bottom: none;
margin-left: 4px;
// 覆盖 EP 横向菜单根元素默认的 1px 底边框(提高选择器优先级)
&.el-menu--horizontal {
border-bottom: none;
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
height: var(--ty-header-height, 56px);
@ -226,7 +241,7 @@ function onUserCommand(command: string): void {
:deep(.el-menu-item.is-active) {
color: var(--el-color-primary);
border-bottom-color: var(--el-color-primary);
border-bottom: none;
}
:deep(.menu-icon) {

40
src/pages/[...all].vue Normal file
View File

@ -0,0 +1,40 @@
<template>
<div class="not-found">
<div class="not-found__code">404</div>
<div class="not-found__title">页面不存在</div>
<div class="not-found__desc">抱歉,你访问的路径「{{ fullPath }}」不存在或已被移除</div>
<el-button type="primary" @click="goHome">回到首页</el-button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const fullPath = computed(() => route.fullPath)
function goHome(): void {
router.push('/')
}
</script>
<style scoped lang="scss">
.not-found {
@apply flex h-full flex-col items-center justify-center gap-md;
}
.not-found__code {
@apply text-[96px] font-bold leading-none text-text-secondary;
}
.not-found__title {
@apply text-xl font-medium text-text-primary;
}
.not-found__desc {
@apply text-base text-text-regular;
}
</style>

View File

@ -44,6 +44,7 @@ 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 { buildTree, collectDescendants, filterTree, type TreeNode } from '@/utils/tree'
const STATUS_MAP = {
enabled: { label: '启用', type: 'success' },
@ -62,9 +63,7 @@ interface OrgRow {
createdAt: number
}
interface OrgNode extends OrgRow {
children: OrgNode[]
}
type OrgNode = TreeNode<OrgRow>
const now = Date.now()
const day = 24 * 60 * 60 * 1000
@ -112,59 +111,18 @@ const searchSchema: JsonSchema = {
},
}
// ---- 树构建 / 过滤 ----
function buildTree(rows: OrgRow[]): OrgNode[] {
const map = new Map<string, OrgNode>()
for (const r of rows) map.set(r.code, { ...r, children: [] })
const roots: OrgNode[] = []
for (const node of map.values()) {
const parent = node.parentCode ? map.get(node.parentCode) : undefined
if (parent) parent.children.push(node)
else roots.push(node)
}
const sortRec = (nodes: OrgNode[]): void => {
nodes.sort((a, b) => a.sort - b.sort || a.code.localeCompare(b.code))
for (const n of nodes) sortRec(n.children)
}
sortRec(roots)
return roots
}
/** 过滤:保留匹配节点 + 其祖先 + 其整棵子树。 */
function filterTree(rows: OrgRow[], name: string, code: string, status: string): OrgRow[] {
if (!name && !code && !status) return rows
const byCode = new Map(rows.map((r) => [r.code, r]))
const matches = rows.filter(
(r) => (!name || r.name.includes(name)) && (!code || r.code.includes(code)) && (!status || r.status === status),
)
const kept = new Set<string>()
for (const m of matches) {
// 祖先链
let cur: OrgRow | undefined = m
while (cur) {
kept.add(cur.code)
cur = cur.parentCode ? byCode.get(cur.parentCode) : undefined
}
// 子树
const stack = [m.code]
while (stack.length) {
const c = stack.pop()!
for (const r of rows) {
if (r.parentCode === c) {
kept.add(r.code)
stack.push(r.code)
}
}
}
}
return rows.filter((r) => kept.has(r.code))
}
const searchQuery = reactive({ name: '', code: '', status: '' })
const appliedQuery = reactive({ name: '', code: '', status: '' })
const displayTree = computed<OrgNode[]>(() =>
buildTree(filterTree(orgs.value, appliedQuery.name.trim(), appliedQuery.code.trim(), appliedQuery.status)),
buildTree(
filterTree(orgs.value, (r) => {
const name = appliedQuery.name.trim()
const code = appliedQuery.code.trim()
const status = appliedQuery.status
return (!name || r.name.includes(name)) && (!code || r.code.includes(code)) && (!status || r.status === status)
}),
),
)
function handleSearch(): void {
@ -234,21 +192,6 @@ function delay<T>(fn: () => T, ms = 300): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(fn()), ms))
}
function collectDescendants(code: string): string[] {
const result: string[] = []
const stack = [code]
while (stack.length) {
const c = stack.pop()!
for (const o of orgs.value) {
if (o.parentCode === c) {
result.push(o.code)
stack.push(o.code)
}
}
}
return result
}
async function openCreate(): Promise<void> {
const data = await showDialog({
component: TyForm,
@ -332,7 +275,7 @@ async function openEdit(row: Record<string, any>): Promise<void> {
async function handleDelete(row: Record<string, any>): Promise<void> {
const o = row as OrgRow
await ElMessageBox.confirm(`确认删除组织「${o.name}」及其所有下级组织?`, '提示', { type: 'warning' })
const codes = new Set([o.code, ...collectDescendants(o.code)])
const codes = new Set([o.code, ...collectDescendants(orgs.value, o.code)])
orgs.value = orgs.value.filter((x) => !codes.has(x.code))
ElMessage.success('删除成功')
}
@ -348,7 +291,7 @@ async function batchDelete(rows: Record<string, any>[]): Promise<void> {
const codes = new Set<string>()
for (const r of rows) {
codes.add(String(r.code))
for (const c of collectDescendants(String(r.code))) codes.add(c)
for (const c of collectDescendants(orgs.value, String(r.code))) codes.add(c)
}
orgs.value = orgs.value.filter((o) => !codes.has(o.code))
selectedRows.value = []

View File

@ -0,0 +1,404 @@
<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">
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 { buildTree, collectDescendants, filterTree, type TreeNode } from '@/utils/tree'
const STATUS_MAP = {
enabled: { label: '启用', type: 'success' },
disabled: { label: '停用', type: 'info' },
} as const
const TYPE_MAP = {
menu: { label: '菜单', type: 'primary' },
button: { label: '按钮', type: 'warning' },
api: { label: '接口', type: 'info' },
} as const
// ---- 占位数据(后续对接后端时替换为 http 调用)----
interface ResourceRow {
id: string
code: string
name: string
parentCode: string | null
type: 'menu' | 'button' | 'api'
path?: string
component?: string
method?: string
apiPath?: string
icon?: string
sort: number
status: 'enabled' | 'disabled'
createdAt: number
}
type ResourceNode = TreeNode<ResourceRow>
const now = Date.now()
const day = 24 * 60 * 60 * 1000
const resources = ref<ResourceRow[]>([
{ id: 'r-1', code: 'dashboard', name: '工作台', parentCode: null, type: 'menu', path: '/', component: 'dashboard/index', icon: 'mdi:view-dashboard', sort: 1, status: 'enabled', createdAt: now - 60 * day },
{ id: 'r-2', code: 'system', name: '系统管理', parentCode: null, type: 'menu', path: '/system', component: 'Layout', icon: 'mdi:cog', sort: 2, status: 'enabled', createdAt: now - 58 * day },
{ id: 'r-3', code: 'system:user', name: '用户管理', parentCode: 'system', type: 'menu', path: '/system/user', component: 'system/user/index', icon: 'mdi:account-group', sort: 1, status: 'enabled', createdAt: now - 55 * day },
{ id: 'r-4', code: 'system:user:create', name: '新增用户', parentCode: 'system:user', type: 'button', sort: 1, status: 'enabled', createdAt: now - 55 * day },
{ id: 'r-5', code: 'system:user:update', name: '编辑用户', parentCode: 'system:user', type: 'button', sort: 2, status: 'enabled', createdAt: now - 55 * day },
{ id: 'r-6', code: 'system:user:delete', name: '删除用户', parentCode: 'system:user', type: 'button', sort: 3, status: 'enabled', createdAt: now - 55 * day },
{ id: 'r-7', code: 'system:user:list', name: '用户列表接口', parentCode: 'system:user', type: 'api', method: 'GET', apiPath: '/users', sort: 4, status: 'enabled', createdAt: now - 55 * day },
{ id: 'r-8', code: 'system:role', name: '角色管理', parentCode: 'system', type: 'menu', path: '/system/role', component: 'system/role/index', icon: 'mdi:account-key', sort: 2, status: 'enabled', createdAt: now - 50 * day },
{ id: 'r-9', code: 'system:resource', name: '资源管理', parentCode: 'system', type: 'menu', path: '/system/resource', component: 'system/resource/index', icon: 'mdi:view-list', sort: 3, status: 'enabled', createdAt: now - 48 * day },
{ id: 'r-10', code: 'system:org', name: '组织管理', parentCode: 'system', type: 'menu', path: '/system/org', component: 'system/org/index', icon: 'mdi:sitemap', sort: 4, status: 'enabled', createdAt: now - 46 * day },
{ id: 'r-11', code: 'system:dict', name: '字典管理', parentCode: 'system', type: 'menu', path: '/system/dict', component: 'system/dict/index', icon: 'mdi:book-open-variant', sort: 5, status: 'enabled', createdAt: now - 44 * day },
{ id: 'r-12', code: 'system:config', name: '参数配置', parentCode: 'system', type: 'menu', path: '/system/config', component: 'system/config/index', icon: 'mdi:tune', sort: 6, status: 'enabled', createdAt: now - 42 * day },
{ id: 'r-13', code: 'monitor', name: '系统监控', parentCode: null, type: 'menu', path: '/monitor', component: 'Layout', icon: 'mdi:monitor-dashboard', sort: 3, status: 'enabled', createdAt: now - 56 * day },
{ id: 'r-14', code: 'monitor:audit', name: '操作日志', parentCode: 'monitor', type: 'menu', path: '/monitor/audit', component: 'monitor/audit/index', icon: 'mdi:clipboard-text', sort: 1, status: 'enabled', createdAt: now - 40 * day },
{ id: 'r-15', code: 'monitor:login-log', name: '登录日志', parentCode: 'monitor', type: 'menu', path: '/monitor/login-log', component: 'monitor/login-log/index', icon: 'mdi:login', sort: 2, status: 'enabled', createdAt: now - 38 * day },
])
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', tagMap: TYPE_MAP },
{ 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', tagMap: STATUS_MAP },
]
const searchSchema: JsonSchema = {
type: 'object',
properties: {
name: { type: 'string', title: '资源名称', placeholder: '请输入名称' },
code: { type: 'string', title: '资源编码', placeholder: '请输入编码' },
type: {
type: 'string',
title: '类型',
enum: ['menu', 'button', 'api'],
enumNames: ['菜单', '按钮', '接口'],
placeholder: '全部',
},
status: {
type: 'string',
title: '状态',
enum: ['enabled', 'disabled'],
enumNames: ['启用', '停用'],
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: '资源类型',
enum: ['menu', 'button', 'api'],
enumNames: ['菜单', '按钮', '接口'],
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: '状态',
enum: ['enabled', 'disabled'],
enumNames: ['启用', '停用'],
default: 'enabled',
},
},
required: ['code', 'name', 'type'],
}
}
function buildResourceModel(r?: ResourceRow): Record<string, any> {
return reactive({
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',
})
}
let seq = 0
const nextId = () => `r-${Date.now()}-${++seq}`
function delay<T>(fn: () => T, ms = 300): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(fn()), ms))
}
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 delay(() => {
resources.value.push({
id: nextId(),
code: data.code,
name: data.name,
parentCode: data.parentCode || null,
type: data.type,
path: data.type === 'menu' ? data.path : undefined,
component: data.type === 'menu' ? data.component : undefined,
icon: data.type === 'menu' ? data.icon : undefined,
method: data.type === 'api' ? data.method : undefined,
apiPath: data.type === 'api' ? data.apiPath : undefined,
sort: data.sort ?? 0,
status: data.status,
createdAt: Date.now(),
})
return null
})
ElMessage.success('新增成功')
}
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
await delay(() => {
resources.value.push({
id: nextId(),
code: data.code,
name: data.name,
parentCode: data.parentCode || parent.code,
type: data.type,
path: data.type === 'menu' ? data.path : undefined,
component: data.type === 'menu' ? data.component : undefined,
icon: data.type === 'menu' ? data.icon : undefined,
method: data.type === 'api' ? data.method : undefined,
apiPath: data.type === 'api' ? data.apiPath : undefined,
sort: data.sort ?? 0,
status: data.status,
createdAt: Date.now(),
})
return null
})
ElMessage.success('新增成功')
}
async function openEdit(row: Record<string, any>): Promise<void> {
const o = row as ResourceRow
const data = await showDialog({
component: TyForm,
props: {
schema: resourceFormSchema(o.code),
modelValue: buildResourceModel(o),
mode: 'edit',
readonlyKeys: ['code'],
},
title: '编辑资源',
width: '520px',
})
if (data == null) return
await delay(() => {
const r = resources.value.find((x) => x.id === o.id)
if (r) {
Object.assign(r, {
parentCode: data.parentCode || null,
type: data.type,
name: data.name,
path: data.type === 'menu' ? data.path : undefined,
component: data.type === 'menu' ? data.component : undefined,
icon: data.type === 'menu' ? data.icon : undefined,
method: data.type === 'api' ? data.method : undefined,
apiPath: data.type === 'api' ? data.apiPath : undefined,
sort: data.sort ?? 0,
status: data.status,
})
}
return null
})
ElMessage.success('更新成功')
}
async function handleDelete(row: Record<string, any>): Promise<void> {
const o = row as ResourceRow
await ElMessageBox.confirm(`确认删除资源「${o.name}」及其所有下级资源?`, '提示', { type: 'warning' })
const codes = new Set([o.code, ...collectDescendants(resources.value, o.code)])
resources.value = resources.value.filter((x) => !codes.has(x.code))
ElMessage.success('删除成功')
}
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)
}
resources.value = resources.value.filter((o) => !codes.has(o.code))
selectedRows.value = []
ElMessage.success('删除成功')
}
</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>

View File

@ -30,6 +30,8 @@ export interface JsonSchemaProperty {
treeData?: Record<string, any>[]
/** format=tree-select 时的树字段映射。 */
treeProps?: { label?: string; value?: string; children?: string }
/** 字段显示条件:仅当依赖字段值匹配时显示(如 type=menu 时显示 path)。 */
when?: { field: string; equals?: unknown; in?: unknown[] }
}
/** JSON Schema 对象(表单 schema)。 */

72
src/utils/tree.ts Normal file
View File

@ -0,0 +1,72 @@
/** 树操作工具:用于 code/parentCode 组织的扁平树数据。 */
export interface FlatNode {
code: string
parentCode: string | null
sort?: number
}
/** 树节点:扁平节点 + children。 */
export type TreeNode<T extends FlatNode> = T & { children: TreeNode<T>[] }
/** 由扁平列表构建树(按 sort + code 升序)。 */
export function buildTree<T extends FlatNode>(rows: T[]): TreeNode<T>[] {
const map = new Map<string, TreeNode<T>>()
for (const r of rows) map.set(r.code, { ...r, children: [] })
const roots: TreeNode<T>[] = []
for (const node of map.values()) {
const parent = node.parentCode ? map.get(node.parentCode) : undefined
if (parent) parent.children.push(node)
else roots.push(node)
}
const sortRec = (nodes: TreeNode<T>[]): void => {
nodes.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.code.localeCompare(b.code))
for (const n of nodes) sortRec(n.children)
}
sortRec(roots)
return roots
}
/** 过滤:保留匹配节点 + 其祖先 + 其整棵子树。 */
export function filterTree<T extends FlatNode>(rows: T[], match: (row: T) => boolean): T[] {
const matched = rows.filter(match)
if (matched.length === rows.length) return rows
const byCode = new Map(rows.map((r) => [r.code, r]))
const kept = new Set<string>()
for (const m of matched) {
// 祖先链
let cur: T | undefined = m
while (cur) {
kept.add(cur.code)
cur = cur.parentCode ? byCode.get(cur.parentCode) : undefined
}
// 子树
const stack = [m.code]
while (stack.length) {
const c = stack.pop()!
for (const r of rows) {
if (r.parentCode === c) {
kept.add(r.code)
stack.push(r.code)
}
}
}
}
return rows.filter((r) => kept.has(r.code))
}
/** 收集某节点及其全部后代 code。 */
export function collectDescendants<T extends FlatNode>(rows: T[], code: string): string[] {
const result: string[] = []
const stack = [code]
while (stack.length) {
const c = stack.pop()!
for (const r of rows) {
if (r.parentCode === c) {
result.push(r.code)
stack.push(r.code)
}
}
}
return result
}

File diff suppressed because one or more lines are too long

26
typed-router.d.ts vendored
View File

@ -30,6 +30,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/[...all]': RouteRecordInfo<
'/[...all]',
'/:all(.*)',
{ all: ParamValue<true> },
{ all: ParamValue<false> },
| never
>,
'/demo': RouteRecordInfo<
'/demo',
'/demo',
@ -65,6 +72,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/system/resource/': RouteRecordInfo<
'/system/resource/',
'/system/resource',
Record<never, never>,
Record<never, never>,
| never
>,
'/system/user/': RouteRecordInfo<
'/system/user/',
'/system/user',
@ -98,6 +112,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/[...all].vue': {
routes:
| '/[...all]'
views:
| never
}
'src/pages/demo.vue': {
routes:
| '/demo'
@ -128,6 +148,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/system/resource/index.vue': {
routes:
| '/system/resource/'
views:
| never
}
'src/pages/system/user/index.vue': {
routes:
| '/system/user/'