删除菜单管理功能

This commit is contained in:
jingrow 2025-12-27 22:40:47 +08:00
parent 51a8654248
commit f434b9fe4e
5 changed files with 3062 additions and 714 deletions

View File

@ -180,7 +180,6 @@ const breadcrumbItems = computed(() => {
NodeList: t('Node Management'), NodeList: t('Node Management'),
NodeDetail: t('Node Detail'), NodeDetail: t('Node Detail'),
FlowBuilder: t('Flow Builder'), FlowBuilder: t('Flow Builder'),
MenuManager: t('Menu Manager'),
Settings: t('Settings'), Settings: t('Settings'),
SearchResults: t('Search Results') SearchResults: t('Search Results')
} }

View File

@ -60,11 +60,6 @@ const router = createRouter({
name: 'PageTypeDetailLegacy', name: 'PageTypeDetailLegacy',
component: () => import('./detailPage') component: () => import('./detailPage')
}, },
{
path: 'settings/menu',
name: 'MenuManager',
component: () => import('../../views/settings/MenuManager.vue')
},
{ {
path: 'settings', path: 'settings',
name: 'Settings', name: 'Settings',

View File

@ -34,11 +34,6 @@ const user = computed(() => authStore.user)
const userMenuOptions = computed(() => { const userMenuOptions = computed(() => {
const options: any[] = [ const options: any[] = [
{
label: t('Menu Management'),
key: 'menu-manager',
icon: () => h(Icon, { icon: 'tabler:menu-2' })
},
{ {
label: t('Settings'), label: t('Settings'),
key: 'settings', key: 'settings',
@ -61,8 +56,6 @@ const handleUserMenuSelect = async (key: string) => {
if (key === 'logout') { if (key === 'logout') {
await authStore.logout() await authStore.logout()
// logout // logout
} else if (key === 'menu-manager') {
router.push({ name: 'MenuManager' })
} else if (key === 'settings') { } else if (key === 'settings') {
router.push({ name: 'Settings' }) router.push({ name: 'Settings' })
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,701 +0,0 @@
<template>
<div class="page-menu-manager">
<div class="page-header">
<div class="header-left">
<h2>{{ t('Menu Management') }}</h2>
<p class="page-description">{{ t('Manage navigation menu items') }}</p>
</div>
<div class="header-right">
<button class="reset-btn" @click="handleResetDefault">
<i class="fa fa-undo"></i>
{{ t('Reset to Default') }}
</button>
<button class="create-btn" @click="openCreate">
<i class="fa fa-plus"></i>
{{ t('Add Menu') }}
</button>
</div>
</div>
<div class="page-content">
<n-space vertical :size="16">
<n-data-table
:columns="columns"
:data="tableData"
:bordered="false"
:row-key="(row: AppMenuItem) => row.id"
:row-props="(row: AppMenuItem, index: number) => getRowProps(row, index)"
@update:checked-row-keys="handleCheckedRowKeysChange"
/>
</n-space>
</div>
<n-modal v-model:show="showModal" preset="dialog" :title="editing ? t('Edit Menu') : t('Add Menu')">
<n-form :model="form" :rules="rules" label-width="100" ref="formRef">
<n-form-item :label="t('Display Name')"><n-input v-model:value="form.label" /></n-form-item>
<n-form-item :label="t('Menu Type')">
<n-select
v-model:value="form.type"
:options="typeOptions"
:placeholder="t('Select Menu Type')"
@update:value="onTypeChange"
/>
</n-form-item>
<!-- 上级菜单选择所有类型都可选默认为空 -->
<n-form-item :label="t('Parent Menu')" path="parentId">
<n-select
v-model:value="(form as any).parentId"
:options="parentMenuOptions"
:placeholder="t('No parent (top-level)')"
clearable
/>
</n-form-item>
<!-- 页面类型 -->
<n-form-item v-if="form.type === 'pagetype'" :label="t('PageType Name')">
<n-input
v-model:value="form.pagetype"
:placeholder="t('Enter PageType name, e.g.: Knowledge Base')"
@input="onPageTypeChange"
/>
<template #feedback>
<n-text depth="3" style="font-size: 12px;">
{{ t('Enter PageType name, system will auto-generate friendly URL') }}{{ previewUrl }}
</n-text>
</template>
</n-form-item>
<!-- 路由名 -->
<n-form-item v-if="form.type === 'route'" :label="t('Route Name')">
<n-input
v-model:value="form.routeName"
:placeholder="t('Enter route name, e.g.: Dashboard')"
/>
</n-form-item>
<!-- URL路径 -->
<n-form-item v-if="form.type === 'url'" :label="t('URL Path')">
<n-input
v-model:value="form.url"
:placeholder="t('Enter complete URL path')"
/>
<template #feedback>
<n-text depth="3" style="font-size: 12px;">
{{ t('Internal path: /app/knowledge-base') }} <br>
{{ t('External link: starts with http:// or https://') }}
</n-text>
</template>
</n-form-item>
<n-form-item :label="t('Icon')">
<IconPicker v-model="form.icon" />
</n-form-item>
<n-form-item :label="t('Order')"><n-input-number v-model:value="form.order" :min="0" /></n-form-item>
<n-form-item :label="t('Hidden')"><n-switch v-model:value="form.hidden" /></n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="showModal = false">{{ t('Cancel') }}</n-button>
<n-button type="primary" @click="save">{{ t('Save') }}</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, h, computed } from 'vue'
import { NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NSelect, NInputNumber, NSwitch, NText, useDialog, type FormInst, type FormRules } from 'naive-ui'
import { useMenuStore, type AppMenuItem } from '../../shared/stores/menu'
import { useAuthStore } from '../../shared/stores/auth'
import { pageTypeToSlug } from '../../shared/utils/slug'
import IconPicker from '../../core/components/IconPicker.vue'
import { t } from '../../shared/i18n'
const menuStore = useMenuStore()
const authStore = useAuthStore()
// items
function buildTree(items: AppMenuItem[]) {
const byId: Record<string, AppMenuItem & { children?: AppMenuItem[] }> = {}
items.forEach((i) => (byId[i.id] = { ...i, children: [] }))
const roots: (AppMenuItem & { children?: AppMenuItem[] })[] = []
items.forEach((i) => {
const pid = (i as any).parentId as string | null | undefined
if (pid && byId[pid]) {
byId[pid].children!.push(byId[i.id])
} else {
roots.push(byId[i.id])
}
})
// childrenNaive UI children
const prune = (nodes: (AppMenuItem & { children?: AppMenuItem[] })[]) => {
nodes.forEach((n) => {
if (n.children && n.children.length) prune(n.children)
else delete n.children
})
}
prune(roots)
return roots
}
//
const filteredItems = computed(() => {
const userType = authStore.user?.user_type
const isSystemUser = userType === 'System User'
return menuStore.items.filter(m => {
//
if (m.hidden) return false
// System User
if (!isSystemUser) {
// pagetype
if (m.type === 'pagetype') {
return false
}
//
const allowedRootMenus = ['tools', 'dev-group']
if (!m.parentId && !allowedRootMenus.includes(m.id)) {
return false
}
// System User
if (m.parentId === 'dev-group') {
const allowedDevMenus = ['tool-marketplace']
if (!allowedDevMenus.includes(m.id)) {
return false
}
}
}
return true
})
})
const displayItems = computed(() => (draggedItem.value ? previewItems.value : filteredItems.value))
const tableData = computed(() => buildTree(displayItems.value))
const dialog = useDialog()
const columns = [
{ title: t('Display Name'), key: 'label' },
{ title: t('Type'), key: 'type', render: (row: AppMenuItem) => {
const typeMap = { 'pagetype': t('PageType'), 'route': t('Route'), 'url': t('URL'), 'group': t('Group') }
return typeMap[row.type] || row.type
}},
{ title: t('Parent Menu'), key: 'parentId', render: (row: AppMenuItem) => {
if (!(row as any).parentId) return t('None')
const p = menuStore.items.find(m => m.id === (row as any).parentId)
return p ? p.label : t('None')
}},
{ title: t('PageType'), key: 'pagetype' },
{ title: t('Route Name'), key: 'routeName' },
{ title: t('URL Path'), key: 'url' },
{ title: t('Icon'), key: 'icon' },
{ title: t('Order'), key: 'order' },
{ title: t('Hidden'), key: 'hidden', render: (row: AppMenuItem) => row.hidden ? t('Yes') : t('No') },
{ title: t('Actions'), key: 'actions', render: (row: AppMenuItem) => h(NSpace, {}, {
default: () => [
h(NButton, { size: 'small', onClick: () => openEdit(row) }, { default: () => t('Edit') }),
h(NButton, { size: 'small', type: 'error', onClick: () => onRemove(row) }, { default: () => t('Delete') })
]
}) }
]
const showModal = ref(false)
const editing = ref<AppMenuItem | null>(null)
const form = ref<AppMenuItem>({
id: '',
key: '',
label: '',
icon: 'MenuOutlined',
type: 'pagetype', //
pagetype: '',
routeName: '',
url: '',
order: 0,
hidden: false
})
const formRef = ref<FormInst | null>(null)
const rules: FormRules = {
label: [{ required: true, message: t('Please enter display name') }],
type: [{ required: true, message: t('Please select menu type') }],
}
const typeOptions = computed(() => {
const userType = authStore.user?.user_type
const isSystemUser = userType === 'System User'
const allOptions = [
{ label: t('PageType'), value: 'pagetype' },
{ label: t('Route'), value: 'route' },
{ label: t('URL'), value: 'url' },
{ label: t('Group'), value: 'group' }
]
// System User pagetype
if (!isSystemUser) {
return allOptions.filter(opt => opt.value !== 'pagetype')
}
return allOptions
})
const parentMenuOptions = computed(() => {
// 使
const opts = filteredItems.value.map(m => ({ label: m.label, value: m.id }))
return [{ label: t('None'), value: null as any }].concat(opts)
})
// URL
const previewUrl = computed(() => {
if (form.value.type === 'pagetype' && form.value.pagetype) {
const slug = pageTypeToSlug(form.value.pagetype)
return `/app/${slug}`
}
return ''
})
function openCreate() {
editing.value = null
const userType = authStore.user?.user_type
const isSystemUser = userType === 'System User'
form.value = {
id: '',
key: '',
label: '',
icon: '',
type: isSystemUser ? 'pagetype' : 'route', //
pagetype: '',
routeName: '',
url: '',
order: 0,
hidden: false,
parentId: null
}
showModal.value = true
}
function openEdit(row: AppMenuItem) {
editing.value = row
form.value = { ...row }
showModal.value = true
}
//
function onTypeChange() {
//
form.value.pagetype = ''
form.value.routeName = ''
form.value.url = ''
}
// PageType
function onPageTypeChange() {
if (form.value.type === 'pagetype' && form.value.pagetype) {
const slug = pageTypeToSlug(form.value.pagetype)
form.value.url = `/app/${slug}`
}
}
function save() {
const data = { ...form.value }
// System User pagetype
const userType = authStore.user?.user_type
const isSystemUser = userType === 'System User'
if (!isSystemUser && data.type === 'pagetype') {
dialog.error({
title: t('Permission Denied'),
content: t('Non-System User cannot create or edit pagetype menu items'),
positiveText: t('OK')
})
return
}
// key
if (data.type === 'pagetype' && data.pagetype) {
data.key = pageTypeToSlug(data.pagetype)
data.url = `/app/${data.key}`
} else if (data.type === 'route' && data.routeName) {
data.key = data.routeName
} else if (data.type === 'url' && data.url) {
data.key = data.url
} else if (data.type === 'group') {
data.key = data.key || `group_${Date.now()}`
}
formRef.value?.validate((errors) => {
if (errors) return
if (editing.value) {
menuStore.updateMenu(editing.value.id, data)
} else {
data.id = ''
menuStore.addMenu(data)
}
showModal.value = false
})
}
function onRemove(row: AppMenuItem) {
dialog.warning({
title: t('Confirm Delete'),
content: `${t('Are you sure you want to delete menu')}"${row.label}"`,
positiveText: t('Delete'),
negativeText: t('Cancel'),
onPositiveClick: () => menuStore.removeMenu(row.id)
})
}
function handleResetDefault() {
dialog.warning({
title: t('Reset to Default'),
content: t('Are you sure to reset menus to default? This will overwrite current settings.'),
positiveText: t('Confirm'),
negativeText: t('Cancel'),
onPositiveClick: () => menuStore.resetDefault()
})
}
// -
const draggedItem = ref<AppMenuItem | null>(null)
const dropIndex = ref<number>(-1)
const previewItems = ref<AppMenuItem[]>([])
function getRowProps(row: AppMenuItem, _index: number) {
//
const flatIndex = menuStore.items.findIndex(item => item.id === row.id)
return {
draggable: true,
class: {
'draggable-row': true,
'drag-over': dropIndex.value === flatIndex,
'dragging': draggedItem.value?.id === row.id
},
onDragstart: (e: DragEvent) => {
draggedItem.value = row
previewItems.value = [...menuStore.items]
e.dataTransfer!.effectAllowed = 'move'
},
onDragover: (e: DragEvent) => {
e.preventDefault()
if (draggedItem.value && dropIndex.value !== flatIndex) {
dropIndex.value = flatIndex
updatePreview()
}
},
onDragleave: () => {
//
setTimeout(() => {
if (!draggedItem.value) {
dropIndex.value = -1
previewItems.value = [...menuStore.items]
}
}, 50)
},
onDrop: (e: DragEvent) => {
e.preventDefault()
if (draggedItem.value && dropIndex.value !== -1) {
applyReorder()
}
resetDragState()
},
onDragend: resetDragState
}
}
function updatePreview() {
if (!draggedItem.value || dropIndex.value === -1) return
const items = [...menuStore.items]
const currentIndex = items.findIndex(i => i.id === draggedItem.value!.id)
if (currentIndex === -1 || currentIndex === dropIndex.value) return
//
items.splice(currentIndex, 1)
//
const insertIndex = dropIndex.value > currentIndex ? dropIndex.value - 1 : dropIndex.value
items.splice(insertIndex, 0, draggedItem.value)
previewItems.value = items
}
function applyReorder() {
if (!draggedItem.value || dropIndex.value === -1) return
const items = [...previewItems.value]
// order
const rootMenus = items.filter(item => !item.parentId)
const childMenus = items.filter(item => item.parentId)
// order
rootMenus.forEach((item, index) => {
item.order = index + 1
})
// order
const groupedChildren = childMenus.reduce((acc, child) => {
const parentId = child.parentId!
if (!acc[parentId]) acc[parentId] = []
acc[parentId].push(child)
return acc
}, {} as Record<string, AppMenuItem[]>)
Object.values(groupedChildren).forEach(children => {
children.forEach((child, index) => {
child.order = index + 1
})
})
menuStore.items = items
menuStore.persist()
}
function resetDragState() {
draggedItem.value = null
dropIndex.value = -1
previewItems.value = [...menuStore.items]
}
function handleCheckedRowKeysChange(keys: (string | number)[]) {
//
console.log('Selected rows:', keys)
}
</script>
<style scoped>
.page-menu-manager {
width: 100%;
padding: 16px;
}
/* 页面头部样式 */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e5e7eb;
}
.header-left h2 {
font-size: 24px;
font-weight: 700;
color: #1f2937;
margin: 0 0 4px 0;
}
.page-description {
font-size: 14px;
color: #6b7280;
margin: 0;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
/* 恢复默认按钮 */
.reset-btn {
height: 36px;
padding: 0 16px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #ffffff;
color: #6b7280;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.reset-btn:hover {
background: #f9fafb;
color: #374151;
border-color: #9ca3af;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.reset-btn:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.reset-btn i {
font-size: 12px;
}
/* 新增菜单按钮 - 使用柔和的品牌色系与pagetype列表页创建按钮一致 */
.create-btn {
height: 36px;
padding: 0 16px;
border: 1px solid #1fc76f;
border-radius: 8px;
background: #e6f8f0;
color: #0d684b;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.create-btn:hover {
background: #dcfce7;
border-color: #1fc76f;
color: #166534;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.15);
}
.create-btn:active {
background: #1fc76f;
border-color: #1fc76f;
color: white;
transform: translateY(0);
box-shadow: 0 1px 4px rgba(31, 199, 111, 0.2);
}
.create-btn:disabled {
background: #f1f5f9;
border-color: #e2e8f0;
color: #94a3b8;
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.create-btn:disabled:hover {
background: #f1f5f9;
border-color: #e2e8f0;
color: #94a3b8;
transform: none;
box-shadow: none;
}
.create-btn i {
font-size: 12px;
}
/* 页面内容区域 */
.page-content {
margin-top: 16px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.page-menu-manager {
padding: 12px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.header-right {
width: 100%;
justify-content: flex-end;
}
.header-left h2 {
font-size: 20px;
}
}
@media (max-width: 480px) {
.page-menu-manager {
padding: 8px;
}
.header-right {
flex-direction: column;
width: 100%;
gap: 8px;
}
.reset-btn,
.create-btn {
width: 100%;
justify-content: center;
}
.header-left h2 {
font-size: 18px;
}
}
/* 拖拽行 - 实时预览设计 */
:deep(.draggable-row) {
cursor: grab;
transition: opacity 0.2s ease;
}
:deep(.draggable-row:hover) {
background-color: #f8fafc;
}
:deep(.draggable-row:active) {
cursor: grabbing;
}
/* 拖拽中 - 实时预览效果 */
:deep(.draggable-row.dragging) {
opacity: 0.7;
background-color: #f0f9ff;
border: 1px dashed #3b82f6;
}
/* 拖拽目标 - 清晰指示 */
:deep(.draggable-row.drag-over) {
border-top: 2px solid #3b82f6;
}
/* 表格样式 */
:deep(.n-data-table) {
border-radius: 8px;
}
/* 禁用文本选择 */
:deep(.draggable-row *) {
user-select: none;
}
/* 拖拽时的平滑过渡 */
:deep(.n-data-table-tbody tr) {
transition: all 0.2s ease;
}
</style>