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:
parent
b5d95c95d8
commit
b9c3a64c34
@ -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
|
||||
|
||||
@ -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)) {
|
||||
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)) {
|
||||
|
||||
@ -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
40
src/pages/[...all].vue
Normal 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>
|
||||
@ -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 = []
|
||||
|
||||
404
src/pages/system/resource/index.vue
Normal file
404
src/pages/system/resource/index.vue
Normal 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>
|
||||
@ -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
72
src/utils/tree.ts
Normal 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
26
typed-router.d.ts
vendored
@ -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/'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user