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))
|
const query = reactive<Record<string, any>>(buildModel(props.searchSchema))
|
||||||
|
|
||||||
function buildModel(schema?: JsonSchema): Record<string, any> {
|
function buildModel(schema?: JsonSchema): Record<string, any> {
|
||||||
const model: Record<string, any> = {}
|
const model: Record<string, any> = reactive({})
|
||||||
if (!schema) return model
|
if (!schema) return model
|
||||||
for (const [key, prop] of Object.entries(schema.properties)) {
|
for (const [key, prop] of Object.entries(schema.properties)) {
|
||||||
if (prop.default !== undefined) model[key] = prop.default
|
if (prop.default !== undefined) model[key] = prop.default
|
||||||
|
|||||||
@ -122,17 +122,25 @@ function onEnter(): void {
|
|||||||
if (props.enterToSubmit) emit('submit')
|
if (props.enterToSubmit) emit('submit')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按 mode 过滤 showOn 字段。 */
|
/** 按 mode 过滤 showOn 字段,并应用 when 条件(依赖字段值联动显隐)。 */
|
||||||
const visibleProperties = computed(() => {
|
const visibleProperties = computed(() => {
|
||||||
const result: Record<string, JsonSchemaProperty> = {}
|
const result: Record<string, JsonSchemaProperty> = {}
|
||||||
for (const [key, prop] of Object.entries(props.schema.properties)) {
|
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
|
result[key] = prop
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return result
|
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 rules = computed<FormRules>(() => {
|
||||||
const result: FormRules = {}
|
const result: FormRules = {}
|
||||||
for (const [key, prop] of Object.entries(props.schema.properties)) {
|
for (const [key, prop] of Object.entries(props.schema.properties)) {
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
class="ty-header__nav"
|
class="ty-header__nav"
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
:ellipsis="false"
|
:ellipsis="false"
|
||||||
:default-active="activePath"
|
:default-active="activeIndex"
|
||||||
@select="onNavSelect"
|
@select="onNavSelect"
|
||||||
>
|
>
|
||||||
<template v-for="item in menus" :key="item.code">
|
<template v-for="item in menus" :key="item.code">
|
||||||
@ -29,7 +29,7 @@
|
|||||||
{{ child.name }}
|
{{ child.name }}
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-sub-menu>
|
</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" />
|
<icon type="iconify" :icon="item.icon ?? 'mdi:menu'" class="menu-icon" />
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
@ -65,13 +65,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import type { ResourceNode } from '@/types/resource'
|
import type { ResourceNode } from '@/types/resource'
|
||||||
import type { LayoutType } from '@/config/layout'
|
import type { LayoutType } from '@/config/layout'
|
||||||
import type { BreadcrumbItem } from './ty-breadcrumb.vue'
|
import type { BreadcrumbItem } from './ty-breadcrumb.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'TyHeader' })
|
defineOptions({ name: 'TyHeader' })
|
||||||
|
|
||||||
withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
/** 应用标题。 */
|
/** 应用标题。 */
|
||||||
title?: string
|
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<{
|
const emit = defineEmits<{
|
||||||
(e: 'toggle-collapse'): void
|
(e: 'toggle-collapse'): void
|
||||||
(e: 'toggle-dark'): void
|
(e: 'toggle-dark'): void
|
||||||
@ -214,9 +225,13 @@ function onUserCommand(command: string): void {
|
|||||||
|
|
||||||
&__nav {
|
&__nav {
|
||||||
height: var(--ty-header-height, 56px);
|
height: var(--ty-header-height, 56px);
|
||||||
border-bottom: none;
|
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
|
|
||||||
|
// 覆盖 EP 横向菜单根元素默认的 1px 底边框(提高选择器优先级)
|
||||||
|
&.el-menu--horizontal {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.el-menu-item),
|
:deep(.el-menu-item),
|
||||||
:deep(.el-sub-menu__title) {
|
:deep(.el-sub-menu__title) {
|
||||||
height: var(--ty-header-height, 56px);
|
height: var(--ty-header-height, 56px);
|
||||||
@ -226,7 +241,7 @@ function onUserCommand(command: string): void {
|
|||||||
|
|
||||||
:deep(.el-menu-item.is-active) {
|
:deep(.el-menu-item.is-active) {
|
||||||
color: var(--el-color-primary);
|
color: var(--el-color-primary);
|
||||||
border-bottom-color: var(--el-color-primary);
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.menu-icon) {
|
: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 type { JsonSchema, TableColumn } from '@/types/crud'
|
||||||
import TyForm from '@/components/ty-form.vue'
|
import TyForm from '@/components/ty-form.vue'
|
||||||
import { showDialog } from '@/utils/dialog'
|
import { showDialog } from '@/utils/dialog'
|
||||||
|
import { buildTree, collectDescendants, filterTree, type TreeNode } from '@/utils/tree'
|
||||||
|
|
||||||
const STATUS_MAP = {
|
const STATUS_MAP = {
|
||||||
enabled: { label: '启用', type: 'success' },
|
enabled: { label: '启用', type: 'success' },
|
||||||
@ -62,9 +63,7 @@ interface OrgRow {
|
|||||||
createdAt: number
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrgNode extends OrgRow {
|
type OrgNode = TreeNode<OrgRow>
|
||||||
children: OrgNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const day = 24 * 60 * 60 * 1000
|
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 searchQuery = reactive({ name: '', code: '', status: '' })
|
||||||
const appliedQuery = reactive({ name: '', code: '', status: '' })
|
const appliedQuery = reactive({ name: '', code: '', status: '' })
|
||||||
|
|
||||||
const displayTree = computed<OrgNode[]>(() =>
|
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 {
|
function handleSearch(): void {
|
||||||
@ -234,21 +192,6 @@ function delay<T>(fn: () => T, ms = 300): Promise<T> {
|
|||||||
return new Promise((resolve) => setTimeout(() => resolve(fn()), ms))
|
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> {
|
async function openCreate(): Promise<void> {
|
||||||
const data = await showDialog({
|
const data = await showDialog({
|
||||||
component: TyForm,
|
component: TyForm,
|
||||||
@ -332,7 +275,7 @@ async function openEdit(row: Record<string, any>): Promise<void> {
|
|||||||
async function handleDelete(row: Record<string, any>): Promise<void> {
|
async function handleDelete(row: Record<string, any>): Promise<void> {
|
||||||
const o = row as OrgRow
|
const o = row as OrgRow
|
||||||
await ElMessageBox.confirm(`确认删除组织「${o.name}」及其所有下级组织?`, '提示', { type: 'warning' })
|
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))
|
orgs.value = orgs.value.filter((x) => !codes.has(x.code))
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
}
|
}
|
||||||
@ -348,7 +291,7 @@ async function batchDelete(rows: Record<string, any>[]): Promise<void> {
|
|||||||
const codes = new Set<string>()
|
const codes = new Set<string>()
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
codes.add(String(r.code))
|
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))
|
orgs.value = orgs.value.filter((o) => !codes.has(o.code))
|
||||||
selectedRows.value = []
|
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>[]
|
treeData?: Record<string, any>[]
|
||||||
/** format=tree-select 时的树字段映射。 */
|
/** format=tree-select 时的树字段映射。 */
|
||||||
treeProps?: { label?: string; value?: string; children?: string }
|
treeProps?: { label?: string; value?: string; children?: string }
|
||||||
|
/** 字段显示条件:仅当依赖字段值匹配时显示(如 type=menu 时显示 path)。 */
|
||||||
|
when?: { field: string; equals?: unknown; in?: unknown[] }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** JSON Schema 对象(表单 schema)。 */
|
/** 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>,
|
Record<never, never>,
|
||||||
| never
|
| never
|
||||||
>,
|
>,
|
||||||
|
'/[...all]': RouteRecordInfo<
|
||||||
|
'/[...all]',
|
||||||
|
'/:all(.*)',
|
||||||
|
{ all: ParamValue<true> },
|
||||||
|
{ all: ParamValue<false> },
|
||||||
|
| never
|
||||||
|
>,
|
||||||
'/demo': RouteRecordInfo<
|
'/demo': RouteRecordInfo<
|
||||||
'/demo',
|
'/demo',
|
||||||
'/demo',
|
'/demo',
|
||||||
@ -65,6 +72,13 @@ declare module 'vue-router/auto-routes' {
|
|||||||
Record<never, never>,
|
Record<never, never>,
|
||||||
| never
|
| never
|
||||||
>,
|
>,
|
||||||
|
'/system/resource/': RouteRecordInfo<
|
||||||
|
'/system/resource/',
|
||||||
|
'/system/resource',
|
||||||
|
Record<never, never>,
|
||||||
|
Record<never, never>,
|
||||||
|
| never
|
||||||
|
>,
|
||||||
'/system/user/': RouteRecordInfo<
|
'/system/user/': RouteRecordInfo<
|
||||||
'/system/user/',
|
'/system/user/',
|
||||||
'/system/user',
|
'/system/user',
|
||||||
@ -98,6 +112,12 @@ declare module 'vue-router/auto-routes' {
|
|||||||
views:
|
views:
|
||||||
| never
|
| never
|
||||||
}
|
}
|
||||||
|
'src/pages/[...all].vue': {
|
||||||
|
routes:
|
||||||
|
| '/[...all]'
|
||||||
|
views:
|
||||||
|
| never
|
||||||
|
}
|
||||||
'src/pages/demo.vue': {
|
'src/pages/demo.vue': {
|
||||||
routes:
|
routes:
|
||||||
| '/demo'
|
| '/demo'
|
||||||
@ -128,6 +148,12 @@ declare module 'vue-router/auto-routes' {
|
|||||||
views:
|
views:
|
||||||
| never
|
| never
|
||||||
}
|
}
|
||||||
|
'src/pages/system/resource/index.vue': {
|
||||||
|
routes:
|
||||||
|
| '/system/resource/'
|
||||||
|
views:
|
||||||
|
| never
|
||||||
|
}
|
||||||
'src/pages/system/user/index.vue': {
|
'src/pages/system/user/index.vue': {
|
||||||
routes:
|
routes:
|
||||||
| '/system/user/'
|
| '/system/user/'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user