441 lines
12 KiB
Vue
441 lines
12 KiB
Vue
<template>
|
||
<!-- 添加/编辑菜单对话框 -->
|
||
<el-dialog :model-value="visible" :title="dialogTitle" width="500px" :close-on-click-modal="false" @update:model-value="handleDialogClose">
|
||
<el-form
|
||
:model="currentMenu"
|
||
label-width="100px"
|
||
:rules="formRules"
|
||
ref="menuFormRef"
|
||
>
|
||
<el-form-item label="父级菜单" prop="pid">
|
||
<el-cascader
|
||
:model-value="pidValue"
|
||
@update:model-value="handlePidUpdate"
|
||
:options="parentMenuOptions"
|
||
:props="cascaderProps"
|
||
placeholder="请选择父级菜单"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="菜单名称" prop="title">
|
||
<el-input v-model="currentMenu.title" placeholder="请输入菜单名称" />
|
||
</el-form-item>
|
||
|
||
<el-form-item label="菜单类型" prop="type">
|
||
<el-radio-group v-model="currentMenu.type" style="width: 100%">
|
||
<el-radio-button :value="1">目录</el-radio-button>
|
||
<el-radio-button :value="2">页面</el-radio-button>
|
||
<el-radio-button :value="3">接口</el-radio-button>
|
||
</el-radio-group>
|
||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||
<div>• 目录:只有路由地址,用于<span style="color: var(--el-color-primary);">目录管理</span>和<span style="color: var(--el-color-primary);">菜单分组</span></div>
|
||
<div>• 页面:有路由和组件地址,用于<span style="color: var(--el-color-primary);">页面管理</span></div>
|
||
<div>• 接口:无路由和组件,用于<span style="color: var(--el-color-primary);">接口管理</span>和<span style="color: var(--el-color-primary);">权限控制</span></div>
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<el-form-item
|
||
label="路由地址"
|
||
prop="path"
|
||
v-if="currentMenu.type !== 3"
|
||
>
|
||
<el-input v-model="currentMenu.path" placeholder="例如:/system" />
|
||
</el-form-item>
|
||
|
||
<el-form-item
|
||
label="组件路径"
|
||
prop="component_path"
|
||
v-if="currentMenu.type === 2"
|
||
>
|
||
<el-input
|
||
v-model="currentMenu.component_path"
|
||
placeholder="例如:/apps/knowledge/index.vue"
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="图标" prop="icon">
|
||
<el-input
|
||
v-model="currentMenu.icon"
|
||
placeholder="例如:fas fa-tachometer-alt"
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="排序" prop="sort">
|
||
<el-input-number
|
||
v-model="currentMenu.sort"
|
||
:min="0"
|
||
placeholder="数字越小越靠前"
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="状态" prop="status">
|
||
<el-switch
|
||
v-model="currentMenu.status"
|
||
:active-value="1"
|
||
:inactive-value="0"
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="是否显示" prop="is_visible">
|
||
<el-switch
|
||
v-model="currentMenu.is_visible"
|
||
:active-value="1"
|
||
:inactive-value="0"
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="权限标识" prop="permission">
|
||
<el-input
|
||
v-model="currentMenu.permission"
|
||
placeholder="请输入权限标识"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<span class="dialog-footer">
|
||
<el-button @click="handleCancel">取消</el-button>
|
||
<el-button type="primary" @click="handleSave">确定</el-button>
|
||
</span>
|
||
</template>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch } from "vue";
|
||
import { ElMessage, ElForm } from "element-plus";
|
||
|
||
// 定义菜单数据类型
|
||
interface Menu {
|
||
id: number;
|
||
pid: number;
|
||
title: string;
|
||
path: string;
|
||
component_path: string;
|
||
icon: string;
|
||
sort: number;
|
||
status: 0 | 1;
|
||
is_visible: 0 | 1;
|
||
type: 1 | 2 | 3;
|
||
permission: string;
|
||
children?: Menu[];
|
||
hasChildren?: boolean;
|
||
}
|
||
|
||
// Props
|
||
interface Props {
|
||
visible: boolean;
|
||
menu: Partial<Menu> | null;
|
||
parentMenuOptions: Menu[];
|
||
dialogType: 'add' | 'edit' | 'addSub';
|
||
parentTitle?: string;
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
visible: false,
|
||
menu: null,
|
||
parentMenuOptions: () => [],
|
||
dialogType: 'add',
|
||
parentTitle: ''
|
||
});
|
||
|
||
// Emits
|
||
const emit = defineEmits<{
|
||
(e: 'update:visible', value: boolean): void;
|
||
(e: 'save', menu: Partial<Menu>): void;
|
||
(e: 'cancel'): void;
|
||
}>();
|
||
|
||
// 表单引用
|
||
const menuFormRef = ref<InstanceType<typeof ElForm>>();
|
||
|
||
// 当前操作的菜单
|
||
const currentMenu = ref<Partial<Menu>>({
|
||
id: 0,
|
||
pid: 0,
|
||
title: '',
|
||
path: '',
|
||
component_path: '',
|
||
icon: '',
|
||
sort: 0,
|
||
status: 1,
|
||
is_visible: 1,
|
||
type: 1,
|
||
permission: '',
|
||
});
|
||
|
||
// 使用计算属性处理 pid,确保始终是单个整数
|
||
const pidValue = computed({
|
||
get: () => {
|
||
const pid = currentMenu.value.pid;
|
||
// 如果已经是数组,取最后一个元素(el-cascader 返回的是路径数组)
|
||
if (Array.isArray(pid)) {
|
||
const normalized = pid.length > 0 ? (parseInt(String(pid[pid.length - 1]), 10) || 0) : 0;
|
||
// 立即修正 currentMenu.value.pid
|
||
currentMenu.value.pid = normalized;
|
||
return normalized;
|
||
}
|
||
// 确保是整数
|
||
const normalized = typeof pid === 'number' ? pid : (pid ? parseInt(String(pid), 10) || 0 : 0);
|
||
// 如果值被修改了,更新回去
|
||
if (normalized !== pid && pid !== null && pid !== undefined) {
|
||
currentMenu.value.pid = normalized;
|
||
}
|
||
return normalized;
|
||
},
|
||
set: (value: number | number[] | null | undefined) => {
|
||
// 处理数组情况:el-cascader 可能返回路径数组,取最后一个元素
|
||
let pid: number = 0;
|
||
if (Array.isArray(value)) {
|
||
pid = value.length > 0 ? (parseInt(String(value[value.length - 1]), 10) || 0) : 0;
|
||
} else if (value !== null && value !== undefined) {
|
||
pid = parseInt(String(value), 10) || 0;
|
||
}
|
||
currentMenu.value.pid = pid;
|
||
}
|
||
});
|
||
|
||
// 处理父级菜单更新
|
||
const handlePidUpdate = (value: number | number[] | null | undefined) => {
|
||
pidValue.value = value;
|
||
};
|
||
|
||
// 监听props变化,更新当前菜单
|
||
watch(() => props.menu, (newMenu) => {
|
||
if (newMenu) {
|
||
// 确保 pid 是整数,处理数组情况
|
||
let normalizedPid = 0;
|
||
if (newMenu.pid !== null && newMenu.pid !== undefined) {
|
||
if (Array.isArray(newMenu.pid)) {
|
||
normalizedPid = newMenu.pid.length > 0 ? (parseInt(String(newMenu.pid[newMenu.pid.length - 1]), 10) || 0) : 0;
|
||
} else {
|
||
normalizedPid = parseInt(String(newMenu.pid), 10) || 0;
|
||
}
|
||
}
|
||
|
||
currentMenu.value = {
|
||
...newMenu,
|
||
pid: normalizedPid,
|
||
};
|
||
} else {
|
||
// 重置表单
|
||
currentMenu.value = {
|
||
id: 0,
|
||
pid: props.dialogType === 'addSub' ? props.parentMenuOptions[0]?.id || 0 : 0,
|
||
title: '',
|
||
path: '',
|
||
component_path: '',
|
||
icon: '',
|
||
sort: 0,
|
||
status: 1,
|
||
is_visible: 1,
|
||
type: 1,
|
||
permission: '',
|
||
};
|
||
}
|
||
}, { immediate: true });
|
||
|
||
// 监听 currentMenu.value.pid 的变化,确保始终是整数(防止被直接修改为数组)
|
||
watch(
|
||
() => currentMenu.value.pid,
|
||
(newPid) => {
|
||
if (Array.isArray(newPid)) {
|
||
// 如果变成了数组,立即转换为整数(取最后一个元素,因为 el-cascader 返回的是路径数组)
|
||
const normalizedPid = newPid.length > 0 ? (parseInt(String(newPid[newPid.length - 1]), 10) || 0) : 0;
|
||
currentMenu.value.pid = normalizedPid;
|
||
} else if (typeof newPid !== 'number' && newPid !== null && newPid !== undefined) {
|
||
// 如果不是数字,转换为整数
|
||
const normalizedPid = parseInt(String(newPid), 10) || 0;
|
||
currentMenu.value.pid = normalizedPid;
|
||
}
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
// 监听props.visible变化
|
||
watch(() => props.visible, (newVisible) => {
|
||
if (newVisible && props.dialogType === 'add') {
|
||
// 新增时重置表单
|
||
currentMenu.value = {
|
||
id: 0,
|
||
pid: 0,
|
||
title: '',
|
||
path: '',
|
||
component_path: '',
|
||
icon: '',
|
||
sort: 0,
|
||
status: 1,
|
||
is_visible: 1,
|
||
type: 1,
|
||
permission: '',
|
||
};
|
||
}
|
||
});
|
||
|
||
// 对话框标题
|
||
const dialogTitle = computed(() => {
|
||
switch (props.dialogType) {
|
||
case 'add':
|
||
return '添加菜单';
|
||
case 'edit':
|
||
return '编辑菜单';
|
||
case 'addSub':
|
||
return `添加子菜单 (父菜单: ${props.parentTitle || '顶级菜单'})`;
|
||
default:
|
||
return '操作菜单';
|
||
}
|
||
});
|
||
|
||
// 表单验证规则
|
||
const formRules = ref({
|
||
title: [{ required: true, message: "请输入菜单名称", trigger: "blur" }],
|
||
path: [
|
||
{
|
||
required: true,
|
||
validator: (rule: any, value: any, callback: any) => {
|
||
if (currentMenu.value.type === 3) {
|
||
// 接口类型不需要路径
|
||
callback();
|
||
} else if (!value || value.trim() === "") {
|
||
callback(new Error("请输入路由地址"));
|
||
} else {
|
||
callback();
|
||
}
|
||
},
|
||
trigger: "blur"
|
||
}
|
||
],
|
||
component_path: [
|
||
{
|
||
required: true,
|
||
validator: (rule: any, value: any, callback: any) => {
|
||
if (currentMenu.value.type === 2) {
|
||
// 页面类型需要组件路径
|
||
if (!value || value.trim() === "") {
|
||
callback(new Error("请输入组件路径"));
|
||
} else {
|
||
callback();
|
||
}
|
||
} else {
|
||
// 目录和接口类型不需要组件路径
|
||
callback();
|
||
}
|
||
},
|
||
trigger: "blur"
|
||
}
|
||
],
|
||
sort: [{ required: true, message: "请输入排序号", trigger: "blur" }],
|
||
});
|
||
|
||
// 级联选择器配置
|
||
const cascaderProps = ref({
|
||
value: "id",
|
||
label: "title",
|
||
children: "children",
|
||
checkStrictly: true,
|
||
emitpath: false,
|
||
});
|
||
|
||
// 监听菜单类型变化,自动清空不相关的字段
|
||
watch(() => currentMenu.value.type, (newType, oldType) => {
|
||
if (newType === oldType) return; // 避免初始化时的触发
|
||
|
||
if (newType === 1) {
|
||
// 目录:清空组件路径,保留路径
|
||
currentMenu.value.component_path = "";
|
||
} else if (newType === 2) {
|
||
// 页面:保留路径和组件路径
|
||
// 不清空,保持现有值
|
||
} else if (newType === 3) {
|
||
// 接口:清空路径和组件路径
|
||
currentMenu.value.path = "";
|
||
currentMenu.value.component_path = "";
|
||
}
|
||
});
|
||
|
||
// 取消操作
|
||
const handleCancel = () => {
|
||
emit('update:visible', false);
|
||
emit('cancel');
|
||
};
|
||
|
||
// 处理对话框关闭
|
||
const handleDialogClose = (value: boolean) => {
|
||
if (!value) {
|
||
emit('update:visible', false);
|
||
emit('cancel');
|
||
}
|
||
};
|
||
|
||
// 保存菜单
|
||
const handleSave = async () => {
|
||
// 表单验证
|
||
if (!menuFormRef.value) return;
|
||
const valid = await menuFormRef.value.validate();
|
||
if (!valid) return;
|
||
|
||
// 先确保 currentMenu.value.pid 是整数(防止 el-cascader 直接修改为数组)
|
||
let rawPid = currentMenu.value.pid;
|
||
if (Array.isArray(rawPid)) {
|
||
// el-cascader 返回的是路径数组,取最后一个元素
|
||
rawPid = rawPid.length > 0 ? rawPid[rawPid.length - 1] : 0;
|
||
}
|
||
const normalizedPid = typeof rawPid === 'number' ? rawPid : (rawPid ? parseInt(String(rawPid), 10) || 0 : 0);
|
||
currentMenu.value.pid = normalizedPid;
|
||
|
||
// 解决后端时间字段问题:过滤掉不需要的字段
|
||
const payload = { ...currentMenu.value };
|
||
|
||
// 再次确保 pid 是整数类型(双重保险)
|
||
// 处理数组情况:如果 pid 是数组,取最后一个元素(el-cascader 返回的是路径数组)
|
||
let pidValue: any = payload.pid;
|
||
|
||
// 如果是数组,取最后一个元素
|
||
if (Array.isArray(pidValue)) {
|
||
pidValue = pidValue.length > 0 ? pidValue[pidValue.length - 1] : null;
|
||
}
|
||
|
||
// 强制转换为整数
|
||
if (pidValue === null || pidValue === undefined || pidValue === '') {
|
||
payload.pid = 0;
|
||
} else {
|
||
const parsedPid = parseInt(String(pidValue), 10);
|
||
// 如果转换失败(NaN),设置为 0
|
||
if (isNaN(parsedPid)) {
|
||
payload.pid = 0;
|
||
} else {
|
||
payload.pid = parsedPid;
|
||
}
|
||
}
|
||
|
||
// 最终验证:确保 payload.pid 是数字类型,不是数组
|
||
if (Array.isArray(payload.pid)) {
|
||
payload.pid = Array.isArray(payload.pid) && payload.pid.length > 0
|
||
? parseInt(String(payload.pid[payload.pid.length - 1]), 10) || 0
|
||
: 0;
|
||
}
|
||
|
||
// 确保是数字类型
|
||
if (typeof payload.pid !== 'number') {
|
||
payload.pid = parseInt(String(payload.pid), 10) || 0;
|
||
}
|
||
|
||
// 触发保存事件
|
||
emit('save', payload);
|
||
};
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
/* 对话框样式精简 */
|
||
:deep(.el-dialog__body) {
|
||
padding: 20px;
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 16px;
|
||
}
|
||
</style> |