初始化本地仓库

This commit is contained in:
2026-08-31 11:33:59 +08:00
commit 663f74b633
2390 changed files with 402918 additions and 0 deletions
@@ -0,0 +1,479 @@
<!-- 菜单管理页面 -->
<template>
<div class="menu-page art-full-height">
<!-- 搜索栏 -->
<ArtSearchBar
v-model="formFilters"
:items="formItems"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader
:showZebra="false"
:loading="loading"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton v-auth="'add'" @click="handleAddMenu" v-ripple> 添加菜单 </ElButton>
<ElButton @click="toggleExpand" v-ripple>
{{ isExpanded ? '收起' : '展开' }}
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
rowKey="path"
:loading="loading"
:columns="columns"
:data="filteredTableData"
:stripe="false"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="false"
/>
<!-- 菜单弹窗 -->
<MenuDialog
v-model:visible="dialogVisible"
:type="dialogType"
:editData="editData"
:lockType="lockMenuType"
@submit="handleSubmit"
/>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { formatMenuTitle } from '@/utils/router'
import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue'
import { useTableColumns } from '@/hooks/core/useTableColumns'
import type { AppRouteRecord } from '@/types/router'
import MenuDialog from './modules/menu-dialog.vue'
import { fetchGetMenuList } from '@/apps/demo/api/system-manage'
import { ElTag, ElMessageBox } from 'element-plus'
defineOptions({ name: 'Menus' })
// 状态管理
const loading = ref(false)
const isExpanded = ref(false)
const tableRef = ref()
// 弹窗相关
const dialogVisible = ref(false)
const dialogType = ref<'menu' | 'button'>('menu')
const editData = ref<AppRouteRecord | any>(null)
const lockMenuType = ref(false)
// 搜索相关
const initialSearchState = {
name: '',
route: ''
}
const formFilters = reactive({ ...initialSearchState })
const appliedFilters = reactive({ ...initialSearchState })
const formItems = computed(() => [
{
label: '菜单名称',
key: 'name',
type: 'input',
props: { clearable: true }
},
{
label: '路由地址',
key: 'route',
type: 'input',
props: { clearable: true }
}
])
onMounted(() => {
getMenuList()
})
/**
* 获取菜单列表数据
*/
const getMenuList = async (): Promise<void> => {
loading.value = true
try {
const list = await fetchGetMenuList()
tableData.value = list
} catch (error) {
throw error instanceof Error ? error : new Error('获取菜单失败')
} finally {
loading.value = false
}
}
/**
* 获取菜单类型标签颜色
* @param row 菜单行数据
* @returns 标签颜色类型
*/
const getMenuTypeTag = (
row: AppRouteRecord
): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
if (row.meta?.isAuthButton) return 'danger'
if (row.children?.length) return 'info'
if (row.meta?.link && row.meta?.isIframe) return 'success'
if (row.path) return 'primary'
if (row.meta?.link) return 'warning'
return 'info'
}
/**
* 获取菜单类型文本
* @param row 菜单行数据
* @returns 菜单类型文本
*/
const getMenuTypeText = (row: AppRouteRecord): string => {
if (row.meta?.isAuthButton) return '按钮'
if (row.children?.length) return '目录'
if (row.meta?.link && row.meta?.isIframe) return '内嵌'
if (row.path) return '菜单'
if (row.meta?.link) return '外链'
return '未知'
}
// 表格列配置
const { columnChecks, columns } = useTableColumns(() => [
{
prop: 'meta.title',
label: '菜单名称',
minWidth: 120,
formatter: (row: AppRouteRecord) => formatMenuTitle(row.meta?.title)
},
{
prop: 'type',
label: '菜单类型',
formatter: (row: AppRouteRecord) => {
return h(ElTag, { type: getMenuTypeTag(row) }, () => getMenuTypeText(row))
}
},
{
prop: 'path',
label: '路由',
formatter: (row: AppRouteRecord) => {
if (row.meta?.isAuthButton) return ''
return row.meta?.link || row.path || ''
}
},
{
prop: 'meta.authList',
label: '权限标识',
formatter: (row: AppRouteRecord) => {
if (row.meta?.isAuthButton) {
return row.meta?.authMark || ''
}
if (!row.meta?.authList?.length) return ''
return `${row.meta.authList.length} 个权限标识`
}
},
{
prop: 'date',
label: '编辑时间',
formatter: () => '2022-3-12 12:00:00'
},
{
prop: 'status',
label: '状态',
formatter: () => h(ElTag, { type: 'success' }, () => '启用')
},
{
prop: 'operation',
label: '操作',
width: 180,
align: 'right',
formatter: (row: AppRouteRecord) => {
const buttonStyle = { style: 'text-align: right' }
if (row.meta?.isAuthButton) {
return h('div', buttonStyle, [
h(ArtButtonTable, {
type: 'edit',
onClick: () => handleEditAuth(row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => handleDeleteAuth()
})
])
}
return h('div', buttonStyle, [
h(ArtButtonTable, {
type: 'add',
onClick: () => handleAddAuth(),
title: '新增权限'
}),
h(ArtButtonTable, {
type: 'edit',
onClick: () => handleEditMenu(row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => handleDeleteMenu()
})
])
}
}
])
// 数据相关
const tableData = ref<AppRouteRecord[]>([])
/**
* 重置搜索条件
*/
const handleReset = (): void => {
Object.assign(formFilters, { ...initialSearchState })
Object.assign(appliedFilters, { ...initialSearchState })
getMenuList()
}
/**
* 执行搜索
*/
const handleSearch = (): void => {
Object.assign(appliedFilters, { ...formFilters })
getMenuList()
}
/**
* 刷新菜单列表
*/
const handleRefresh = (): void => {
getMenuList()
}
/**
* 深度克隆对象
* @param obj 要克隆的对象
* @returns 克隆后的对象
*/
const deepClone = <T,>(obj: T): T => {
if (obj === null || typeof obj !== 'object') return obj
if (obj instanceof Date) return new Date(obj) as T
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as T
const cloned = {} as T
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
cloned[key] = deepClone(obj[key])
}
}
return cloned
}
/**
* 将权限列表转换为子节点
* @param items 菜单项数组
* @returns 转换后的菜单项数组
*/
const convertAuthListToChildren = (items: AppRouteRecord[]): AppRouteRecord[] => {
return items.map((item) => {
const clonedItem = deepClone(item)
if (clonedItem.children?.length) {
clonedItem.children = convertAuthListToChildren(clonedItem.children)
}
if (item.meta?.authList?.length) {
const authChildren: AppRouteRecord[] = item.meta.authList.map(
(auth: { title: string; authMark: string }) => ({
path: `${item.path}_auth_${auth.authMark}`,
name: `${String(item.name)}_auth_${auth.authMark}`,
meta: {
title: auth.title,
authMark: auth.authMark,
isAuthButton: true,
parentPath: item.path
}
})
)
clonedItem.children = clonedItem.children?.length
? [...clonedItem.children, ...authChildren]
: authChildren
}
return clonedItem
})
}
/**
* 搜索菜单
* @param items 菜单项数组
* @returns 搜索结果数组
*/
const searchMenu = (items: AppRouteRecord[]): AppRouteRecord[] => {
const results: AppRouteRecord[] = []
for (const item of items) {
const searchName = appliedFilters.name?.toLowerCase().trim() || ''
const searchRoute = appliedFilters.route?.toLowerCase().trim() || ''
const menuTitle = formatMenuTitle(item.meta?.title || '').toLowerCase()
const menuPath = (item.path || '').toLowerCase()
const nameMatch = !searchName || menuTitle.includes(searchName)
const routeMatch = !searchRoute || menuPath.includes(searchRoute)
if (item.children?.length) {
const matchedChildren = searchMenu(item.children)
if (matchedChildren.length > 0) {
const clonedItem = deepClone(item)
clonedItem.children = matchedChildren
results.push(clonedItem)
continue
}
}
if (nameMatch && routeMatch) {
results.push(deepClone(item))
}
}
return results
}
// 过滤后的表格数据
const filteredTableData = computed(() => {
const searchedData = searchMenu(tableData.value)
return convertAuthListToChildren(searchedData)
})
/**
* 添加菜单
*/
const handleAddMenu = (): void => {
dialogType.value = 'menu'
editData.value = null
lockMenuType.value = true
dialogVisible.value = true
}
/**
* 添加权限按钮
*/
const handleAddAuth = (): void => {
dialogType.value = 'menu'
editData.value = null
lockMenuType.value = false
dialogVisible.value = true
}
/**
* 编辑菜单
* @param row 菜单行数据
*/
const handleEditMenu = (row: AppRouteRecord): void => {
dialogType.value = 'menu'
editData.value = row
lockMenuType.value = true
dialogVisible.value = true
}
/**
* 编辑权限按钮
* @param row 权限行数据
*/
const handleEditAuth = (row: AppRouteRecord): void => {
dialogType.value = 'button'
editData.value = {
title: row.meta?.title,
authMark: row.meta?.authMark
}
lockMenuType.value = false
dialogVisible.value = true
}
/**
* 菜单表单数据类型
*/
interface MenuFormData {
name: string
path: string
component?: string
icon?: string
roles?: string[]
sort?: number
[key: string]: any
}
/**
* 提交表单数据
* @param formData 表单数据
*/
const handleSubmit = (formData: MenuFormData): void => {
console.log('提交数据:', formData)
// TODO: 调用API保存数据
getMenuList()
}
/**
* 删除菜单
*/
const handleDeleteMenu = async (): Promise<void> => {
try {
await ElMessageBox.confirm('确定要删除该菜单吗?删除后无法恢复', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('删除成功')
getMenuList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
/**
* 删除权限按钮
*/
const handleDeleteAuth = async (): Promise<void> => {
try {
await ElMessageBox.confirm('确定要删除该权限吗?删除后无法恢复', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('删除成功')
getMenuList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
/**
* 切换展开/收起所有菜单
*/
const toggleExpand = (): void => {
isExpanded.value = !isExpanded.value
nextTick(() => {
if (tableRef.value?.elTableRef && filteredTableData.value) {
const processRows = (rows: AppRouteRecord[]) => {
rows.forEach((row) => {
if (row.children?.length) {
tableRef.value.elTableRef.toggleRowExpansion(row, isExpanded.value)
processRows(row.children)
}
})
}
processRows(filteredTableData.value)
}
})
}
</script>
@@ -0,0 +1,384 @@
<template>
<ElDialog
:title="dialogTitle"
:model-value="visible"
@update:model-value="handleCancel"
width="860px"
align-center
class="menu-dialog"
@closed="handleClosed"
>
<ArtForm
ref="formRef"
v-model="form"
:items="formItems"
:rules="rules"
:span="width > 640 ? 12 : 24"
:gutter="20"
label-width="100px"
:show-reset="false"
:show-submit="false"
>
<template #menuType>
<ElRadioGroup v-model="form.menuType" :disabled="disableMenuType">
<ElRadioButton value="menu" label="menu">菜单</ElRadioButton>
<ElRadioButton value="button" label="button">按钮</ElRadioButton>
</ElRadioGroup>
</template>
</ArtForm>
<template #footer>
<span class="dialog-footer">
<ElButton @click="handleCancel">取 消</ElButton>
<ElButton type="primary" @click="handleSubmit">确 定</ElButton>
</span>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import type { FormRules } from 'element-plus'
import { ElIcon, ElTooltip } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue'
import { formatMenuTitle } from '@/utils/router'
import type { AppRouteRecord } from '@/types/router'
import type { FormItem } from '@/components/core/forms/art-form/index.vue'
import ArtForm from '@/components/core/forms/art-form/index.vue'
import { useWindowSize } from '@vueuse/core'
const { width } = useWindowSize()
/**
* 创建带 tooltip 的表单标签
* @param label 标签文本
* @param tooltip 提示文本
* @returns 渲染函数
*/
const createLabelTooltip = (label: string, tooltip: string) => {
return () =>
h('span', { class: 'flex items-center' }, [
h('span', label),
h(
ElTooltip,
{
content: tooltip,
placement: 'top'
},
() => h(ElIcon, { class: 'ml-0.5 cursor-help' }, () => h(QuestionFilled))
)
])
}
interface MenuFormData {
id: number
name: string
path: string
label: string
component: string
icon: string
isEnable: boolean
sort: number
isMenu: boolean
keepAlive: boolean
isHide: boolean
isHideTab: boolean
link: string
isIframe: boolean
showBadge: boolean
showTextBadge: string
fixedTab: boolean
activePath: string
roles: string[]
isFullPage: boolean
authName: string
authLabel: string
authIcon: string
authSort: number
}
interface Props {
visible: boolean
editData?: AppRouteRecord | any
type?: 'menu' | 'button'
lockType?: boolean
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'submit', data: MenuFormData): void
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
type: 'menu',
lockType: false
})
const emit = defineEmits<Emits>()
const formRef = ref()
const isEdit = ref(false)
const form = reactive<MenuFormData & { menuType: 'menu' | 'button' }>({
menuType: 'menu',
id: 0,
name: '',
path: '',
label: '',
component: '',
icon: '',
isEnable: true,
sort: 1,
isMenu: true,
keepAlive: true,
isHide: false,
isHideTab: false,
link: '',
isIframe: false,
showBadge: false,
showTextBadge: '',
fixedTab: false,
activePath: '',
roles: [],
isFullPage: false,
authName: '',
authLabel: '',
authIcon: '',
authSort: 1
})
const rules = reactive<FormRules>({
name: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
path: [{ required: true, message: '请输入路由地址', trigger: 'blur' }],
label: [{ required: true, message: '输入权限标识', trigger: 'blur' }],
authName: [{ required: true, message: '请输入权限名称', trigger: 'blur' }],
authLabel: [{ required: true, message: '请输入权限标识', trigger: 'blur' }]
})
/**
* 表单项配置
*/
const formItems = computed<FormItem[]>(() => {
const baseItems: FormItem[] = [{ label: '菜单类型', key: 'menuType', span: 24 }]
// Switch 组件的 span:小屏幕 12,大屏幕 6
const switchSpan = width.value < 640 ? 12 : 6
if (form.menuType === 'menu') {
return [
...baseItems,
{ label: '菜单名称', key: 'name', type: 'input', props: { placeholder: '菜单名称' } },
{
label: createLabelTooltip(
'路由地址',
'一级菜单:以 / 开头的绝对路径(如 /dashboard)\n二级及以下:相对路径(如 console、user)'
),
key: 'path',
type: 'input',
props: { placeholder: '如:/dashboard 或 console' }
},
{ label: '权限标识', key: 'label', type: 'input', props: { placeholder: '如:User' } },
{
label: createLabelTooltip(
'组件路径',
'一级父级菜单:填写 /index/index\n具体页面:填写组件路径(如 /system/user)\n目录菜单:留空'
),
key: 'component',
type: 'input',
props: { placeholder: '如:/system/user 或留空' }
},
{ label: '图标', key: 'icon', type: 'input', props: { placeholder: '如:ri:user-line' } },
{
label: createLabelTooltip(
'角色权限',
'仅用于前端权限模式:配置角色标识(如 R_SUPER、R_ADMIN)\n后端权限模式:无需配置'
),
key: 'roles',
type: 'inputtag',
props: { placeholder: '输入角色标识后按回车,如:R_SUPER' }
},
{
label: '菜单排序',
key: 'sort',
type: 'number',
props: { min: 1, controlsPosition: 'right', style: { width: '100%' } }
},
{
label: '外部链接',
key: 'link',
type: 'input',
props: { placeholder: '如:https://www.example.com' }
},
{
label: '文本徽章',
key: 'showTextBadge',
type: 'input',
props: { placeholder: '如:New、Hot' }
},
{
label: createLabelTooltip(
'激活路径',
'用于详情页等隐藏菜单,指定高亮显示的父级菜单路径\n例如:用户详情页高亮显示"用户管理"菜单'
),
key: 'activePath',
type: 'input',
props: { placeholder: '如:/system/user' }
},
{ label: '是否启用', key: 'isEnable', type: 'switch', span: switchSpan },
{ label: '页面缓存', key: 'keepAlive', type: 'switch', span: switchSpan },
{ label: '隐藏菜单', key: 'isHide', type: 'switch', span: switchSpan },
{ label: '是否内嵌', key: 'isIframe', type: 'switch', span: switchSpan },
{ label: '显示徽章', key: 'showBadge', type: 'switch', span: switchSpan },
{ label: '固定标签', key: 'fixedTab', type: 'switch', span: switchSpan },
{ label: '标签隐藏', key: 'isHideTab', type: 'switch', span: switchSpan },
{ label: '全屏页面', key: 'isFullPage', type: 'switch', span: switchSpan }
]
} else {
return [
...baseItems,
{
label: '权限名称',
key: 'authName',
type: 'input',
props: { placeholder: '如:新增、编辑、删除' }
},
{
label: '权限标识',
key: 'authLabel',
type: 'input',
props: { placeholder: '如:add、edit、delete' }
},
{
label: '权限排序',
key: 'authSort',
type: 'number',
props: { min: 1, controlsPosition: 'right', style: { width: '100%' } }
}
]
}
})
const dialogTitle = computed(() => {
const type = form.menuType === 'menu' ? '菜单' : '按钮'
return isEdit.value ? `编辑${type}` : `新建${type}`
})
/**
* 是否禁用菜单类型切换
*/
const disableMenuType = computed(() => {
if (isEdit.value) return true
if (!isEdit.value && form.menuType === 'menu' && props.lockType) return true
return false
})
/**
* 重置表单数据
*/
const resetForm = (): void => {
formRef.value?.reset()
form.menuType = 'menu'
}
/**
* 加载表单数据(编辑模式)
*/
const loadFormData = (): void => {
if (!props.editData) return
isEdit.value = true
if (form.menuType === 'menu') {
const row = props.editData
form.id = row.id || 0
form.name = formatMenuTitle(row.meta?.title || '')
form.path = row.path || ''
form.label = row.name || ''
form.component = row.component || ''
form.icon = row.meta?.icon || ''
form.sort = row.meta?.sort || 1
form.isMenu = row.meta?.isMenu ?? true
form.keepAlive = row.meta?.keepAlive ?? false
form.isHide = row.meta?.isHide ?? false
form.isHideTab = row.meta?.isHideTab ?? false
form.isEnable = row.meta?.isEnable ?? true
form.link = row.meta?.link || ''
form.isIframe = row.meta?.isIframe ?? false
form.showBadge = row.meta?.showBadge ?? false
form.showTextBadge = row.meta?.showTextBadge || ''
form.fixedTab = row.meta?.fixedTab ?? false
form.activePath = row.meta?.activePath || ''
form.roles = row.meta?.roles || []
form.isFullPage = row.meta?.isFullPage ?? false
} else {
const row = props.editData
form.authName = row.title || ''
form.authLabel = row.authMark || ''
form.authIcon = row.icon || ''
form.authSort = row.sort || 1
}
}
/**
* 提交表单
*/
const handleSubmit = async (): Promise<void> => {
if (!formRef.value) return
try {
await formRef.value.validate()
emit('submit', { ...form })
ElMessage.success(`${isEdit.value ? '编辑' : '新增'}成功`)
handleCancel()
} catch {
ElMessage.error('表单校验失败,请检查输入')
}
}
/**
* 取消操作
*/
const handleCancel = (): void => {
emit('update:visible', false)
}
/**
* 对话框关闭后的回调
*/
const handleClosed = (): void => {
resetForm()
isEdit.value = false
}
/**
* 监听对话框显示状态
*/
watch(
() => props.visible,
(newVal) => {
if (newVal) {
form.menuType = props.type
nextTick(() => {
if (props.editData) {
loadFormData()
}
})
}
}
)
/**
* 监听菜单类型变化
*/
watch(
() => props.type,
(newType) => {
if (props.visible) {
form.menuType = newType
}
}
)
</script>