初始化项目
This commit is contained in:
171
src/views/manage/menu/index.vue
Normal file
171
src/views/manage/menu/index.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<script setup lang="tsx">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { Button, Tag } from 'ant-design-vue';
|
||||
import { SimpleScrollbar } from '~/packages/materials/src';
|
||||
import MenuOperateModal from './modules/menu-operate-modal.vue';
|
||||
|
||||
const { data, columns, loading, getData } = useTable({
|
||||
apiFn: doGetMenuList,
|
||||
columns: () => [
|
||||
{
|
||||
key: 'menuName',
|
||||
dataIndex: 'menuName',
|
||||
title: '菜单名称',
|
||||
align: 'left',
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
key: 'path',
|
||||
dataIndex: 'path',
|
||||
title: '路由地址',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
key: 'component',
|
||||
dataIndex: 'component',
|
||||
title: '组件路径',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
dataIndex: 'status',
|
||||
title: '状态',
|
||||
align: 'center',
|
||||
customRender: ({ record }) => {
|
||||
const colorTagMap: Record<string, string> = {
|
||||
0: 'success',
|
||||
1: 'warning'
|
||||
};
|
||||
|
||||
const labelMap: Record<string, string> = {
|
||||
0: '正常',
|
||||
1: '停用'
|
||||
};
|
||||
return <Tag color={colorTagMap[record.status]}>{labelMap[record.status]}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'menuType',
|
||||
dataIndex: 'menuType',
|
||||
title: '类型',
|
||||
align: 'center',
|
||||
customRender: ({ record }) => {
|
||||
const colorTagMap: Record<string, string> = {
|
||||
M: 'processing',
|
||||
C: 'success',
|
||||
F: 'default'
|
||||
};
|
||||
|
||||
const labelMap: Record<string, string> = {
|
||||
M: '目录',
|
||||
C: '菜单',
|
||||
F: '按钮'
|
||||
};
|
||||
return <Tag color={colorTagMap[record.menuType]}>{labelMap[record.menuType]}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'orderNum',
|
||||
dataIndex: 'orderNum',
|
||||
title: '排序',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
key: 'icon',
|
||||
dataIndex: 'icon',
|
||||
title: '图标',
|
||||
align: 'center',
|
||||
customRender: ({ record }) => {
|
||||
return (
|
||||
<div class="flex-center text-5">
|
||||
<Icon icon={`${record.icon}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'createTime',
|
||||
dataIndex: 'createTime',
|
||||
align: 'center',
|
||||
title: '创建时间'
|
||||
},
|
||||
{
|
||||
key: 'operate',
|
||||
title: '操作',
|
||||
align: 'center',
|
||||
width: 200,
|
||||
customRender: ({ record }) => (
|
||||
<div class="flex justify-around gap-8px">
|
||||
<Button size="small" onClick={() => edit(record.menuId)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="small" danger onClick={() => handleDelete(record.menuId)}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
rowKey: 'menuId'
|
||||
});
|
||||
const { handleEdit, handleAdd, checkedRowKeys, operateType, drawerVisible, onDeleted, editingData } = useTableOperate(
|
||||
data,
|
||||
{
|
||||
getData,
|
||||
idKey: 'menuId'
|
||||
}
|
||||
);
|
||||
|
||||
function edit(id: number) {
|
||||
handleEdit(id);
|
||||
}
|
||||
|
||||
function handleDelete(id: number) {
|
||||
$modal.confirm({
|
||||
title: '提示',
|
||||
content: '确定删除该菜单吗?',
|
||||
onOk: async () => {
|
||||
const { error } = await doDeleteMenu(id);
|
||||
if (!error) {
|
||||
$message.success('删除成功');
|
||||
onDeleted();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const treeData = computed(() => {
|
||||
return transformListToTree(data.value, 'menuId');
|
||||
});
|
||||
|
||||
function handleSubmitSuccess() {
|
||||
getData();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SimpleScrollbar>
|
||||
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||
<ACard title="菜单列表">
|
||||
<AButton mb-4 type="primary" @click="handleAdd">新增菜单</AButton>
|
||||
|
||||
<ATable
|
||||
:checked-row-keys="checkedRowKeys"
|
||||
:data-source="treeData"
|
||||
row-key="menuId"
|
||||
:columns="columns"
|
||||
:pagination="false"
|
||||
:loading="loading"
|
||||
/>
|
||||
<MenuOperateModal
|
||||
v-model:visible="drawerVisible"
|
||||
:editing-data="editingData"
|
||||
:operate-type="operateType"
|
||||
@submit-success="handleSubmitSuccess"
|
||||
/>
|
||||
</ACard>
|
||||
</div>
|
||||
</SimpleScrollbar>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
70
src/views/manage/menu/modules/form.ts
Normal file
70
src/views/manage/menu/modules/form.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
const { defaultRequiredRule } = useFormRules();
|
||||
|
||||
export type MenuModelType = Pick<
|
||||
Api.SystemManage.Menu,
|
||||
| 'menuName'
|
||||
| 'menuType'
|
||||
| 'icon'
|
||||
| 'path'
|
||||
| 'component'
|
||||
| 'orderNum'
|
||||
| 'status'
|
||||
| 'parentId'
|
||||
| 'hideInMenu'
|
||||
| 'fixedIndexInTab'
|
||||
| 'iconType'
|
||||
| 'isFrame'
|
||||
| 'perms'
|
||||
| 'isCache'
|
||||
| 'name'
|
||||
>;
|
||||
|
||||
export function resetAddForm(): MenuModelType {
|
||||
return {
|
||||
menuName: '',
|
||||
menuType: 'M',
|
||||
icon: '',
|
||||
path: '',
|
||||
component: '',
|
||||
orderNum: 0,
|
||||
status: '0',
|
||||
parentId: 0,
|
||||
iconType: '1',
|
||||
hideInMenu: '0',
|
||||
isFrame: '1',
|
||||
perms: '',
|
||||
name: '',
|
||||
isCache: '0'
|
||||
};
|
||||
}
|
||||
|
||||
export const formRules = {
|
||||
menuName: defaultRequiredRule,
|
||||
menuType: defaultRequiredRule,
|
||||
icon: defaultRequiredRule,
|
||||
path: defaultRequiredRule,
|
||||
component: defaultRequiredRule,
|
||||
status: defaultRequiredRule,
|
||||
orderNum: [
|
||||
defaultRequiredRule,
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (value < 0) {
|
||||
return Promise.reject(rule.message || '排序必须大于等于0');
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
]
|
||||
} as Record<string, App.Global.FormRule | App.Global.FormRule[]>;
|
||||
|
||||
export const menuTypeOptions = [
|
||||
{ label: '目录', value: 'M' },
|
||||
{ label: '菜单', value: 'C' },
|
||||
{ label: '按钮', value: 'F' }
|
||||
];
|
||||
|
||||
export const menuStatusOptions = [
|
||||
{ label: '正常', value: '0' },
|
||||
{ label: '停用', value: '1' }
|
||||
];
|
||||
67
src/views/manage/menu/modules/icon-select.vue
Normal file
67
src/views/manage/menu/modules/icon-select.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { SimpleScrollbar } from '~/packages/materials/src';
|
||||
import { icons } from './icon';
|
||||
const visible = ref(false);
|
||||
defineOptions({
|
||||
name: 'IconSelect'
|
||||
});
|
||||
|
||||
const value = defineModel<string>({ default: '' });
|
||||
const iconSelectRef = ref<HTMLElement | null>(null);
|
||||
const { isOutside } = useMouseInElement(iconSelectRef);
|
||||
|
||||
const blur = () => {
|
||||
if (isOutside.value) {
|
||||
visible.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
function selectIcon(icon: string) {
|
||||
value.value = icon;
|
||||
setTimeout(() => {
|
||||
visible.value = false;
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div relative>
|
||||
<div flex items-center gap-2 @click="visible = true">
|
||||
<AInput v-model:value="value" @blur="blur">
|
||||
<template #suffix>
|
||||
<Icon v-if="value" text-4 :icon="`${value}`" @click.prevent="() => {}"></Icon>
|
||||
<Icon v-if="!value" text-4 icon="carbon:apps" @click.prevent="() => {}"></Icon>
|
||||
</template>
|
||||
</AInput>
|
||||
</div>
|
||||
|
||||
<Transition>
|
||||
<template v-if="visible">
|
||||
<div
|
||||
ref="iconSelectRef"
|
||||
class="absolute left-0 top-36px z-3000 h-80 w-92 rounded-md bg-light-2 shadow-md dark:bg-dark-4"
|
||||
>
|
||||
<SimpleScrollbar>
|
||||
<div flex="~ wrap" gap-1 p-4>
|
||||
<div
|
||||
v-for="icon in icons"
|
||||
:key="icon"
|
||||
:class="
|
||||
icon === value ? 'text-dark-800 bg-gray-200 dark:text-gray-4 dark:bg-dark6' : 'text-gray-500 bg-none'
|
||||
"
|
||||
hover="bg-light-500 dark:bg-dark-500 "
|
||||
class="flex-center cursor-pointer rounded-md p-2 transition-all"
|
||||
@click="selectIcon(icon)"
|
||||
>
|
||||
<Icon class="text-5.5 font-bold" :icon="`${icon}`" />
|
||||
</div>
|
||||
</div>
|
||||
</SimpleScrollbar>
|
||||
</div>
|
||||
</template>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
39
src/views/manage/menu/modules/icon.ts
Normal file
39
src/views/manage/menu/modules/icon.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const icons = [
|
||||
'carbon:3d-cursor',
|
||||
'carbon:account',
|
||||
'carbon:ai-results-low',
|
||||
'carbon:assembly-cluster',
|
||||
'carbon:battery-charging',
|
||||
'carbon:battery-full',
|
||||
'carbon:bicycle',
|
||||
'carbon:building-insights-3',
|
||||
'carbon:calendar',
|
||||
'carbon:carbon',
|
||||
'carbon:carbon-for-ibm-product',
|
||||
'carbon:category',
|
||||
'carbon:center-to-fit',
|
||||
'carbon:chart-area-smooth',
|
||||
'carbon:chart-median',
|
||||
'carbon:chat-bot',
|
||||
'carbon:cics-system-group',
|
||||
'carbon:database-messaging',
|
||||
'carbon:document',
|
||||
'carbon:document-multiple-01',
|
||||
'carbon:earth-americas-filled',
|
||||
'carbon:folder',
|
||||
'carbon:forum',
|
||||
'carbon:group',
|
||||
'carbon:ibm-cloud-bare-metal-server',
|
||||
'carbon:ibm-telehealth',
|
||||
'carbon:image-search-alt',
|
||||
'carbon:laptop',
|
||||
'carbon:machine-learning',
|
||||
'carbon:report',
|
||||
'carbon:rocket',
|
||||
'carbon:settings',
|
||||
'carbon:settings-services',
|
||||
'carbon:shopping-cart',
|
||||
'carbon:user-avatar',
|
||||
'carbon:user-multiple',
|
||||
'carbon:volume-block-storage'
|
||||
];
|
||||
185
src/views/manage/menu/modules/menu-operate-modal.vue
Normal file
185
src/views/manage/menu/modules/menu-operate-modal.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script lang="ts" setup>
|
||||
import type {} from 'ant-design-vue';
|
||||
import { generatedRoutes } from '@/router/elegant/routes';
|
||||
import { $t } from '@/locales';
|
||||
import type { MenuModelType } from './form';
|
||||
import { formRules, menuStatusOptions, menuTypeOptions, resetAddForm } from './form';
|
||||
import IconSelect from './icon-select.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
operateType: AntDesign.TableOperateType;
|
||||
editingData?: Api.SystemManage.Menu | null;
|
||||
}>();
|
||||
const emits = defineEmits<{
|
||||
'submit-success': [];
|
||||
}>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
const { formRef, validate, resetFields } = useAntdForm();
|
||||
|
||||
const model = ref<MenuModelType>(resetAddForm());
|
||||
const treeData = ref<Api.SystemManage.MenuTree[]>([]);
|
||||
|
||||
const title = computed(() => {
|
||||
const titles: Record<AntDesign.TableOperateType, string> = {
|
||||
add: '新增菜单',
|
||||
edit: '编辑菜单'
|
||||
};
|
||||
return titles[props.operateType];
|
||||
});
|
||||
|
||||
// TODO: 根据菜单类型动态加载组件路径、目录类型只允许选择带有子元素的,菜单类型只允许选择没有子元素的
|
||||
const componentOptions = computed(() => {
|
||||
const excludePaths = ['/404', '/403', '/500'];
|
||||
function transformRoutes(routes: any[]): any[] {
|
||||
return routes.filter(route => {
|
||||
if (route.children) {
|
||||
route.children = transformRoutes(route.children);
|
||||
return true;
|
||||
}
|
||||
if (!route.hideInMenu && !excludePaths.includes(route.path) && !route.path.startsWith('/login')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return transformRoutes(generatedRoutes);
|
||||
});
|
||||
|
||||
watch(visible, val => {
|
||||
if (val) {
|
||||
if (props.operateType === 'edit' && props.editingData) {
|
||||
model.value = {
|
||||
...props.editingData,
|
||||
parentId: props.editingData.parentId || 0,
|
||||
hideInMenu: props.editingData.visible === '0' ? '1' : '0'
|
||||
};
|
||||
}
|
||||
} else {
|
||||
resetFields();
|
||||
model.value = resetAddForm();
|
||||
}
|
||||
});
|
||||
|
||||
const submitForm = async () => {
|
||||
await validate();
|
||||
const { error } = await (props.operateType === 'add' ? doAddMenu(model.value) : doEditMenu(model.value));
|
||||
if (!error) {
|
||||
$message?.success($t(props.operateType === 'add' ? 'common.addSuccess' : 'common.updateSuccess'));
|
||||
emits('submit-success');
|
||||
}
|
||||
closeModal();
|
||||
};
|
||||
|
||||
function closeModal() {
|
||||
visible.value = false;
|
||||
resetFields();
|
||||
model.value = resetAddForm();
|
||||
}
|
||||
|
||||
async function getTreeData() {
|
||||
const { data, error } = await fetchGetMenuTree();
|
||||
if (!error && data) {
|
||||
// 添加根节点
|
||||
treeData.value = [
|
||||
{
|
||||
id: 0,
|
||||
pId: -1,
|
||||
label: '根节点',
|
||||
children: data
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function handleTreeSelect(node: any) {
|
||||
model.value.component = node.component;
|
||||
model.value.name = node.name;
|
||||
}
|
||||
|
||||
getTreeData();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AModal v-model:open="visible" :title="title" :width="700">
|
||||
<AForm
|
||||
ref="formRef"
|
||||
:model="model"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 16, offset: 1 }"
|
||||
class="grid grid-cols-2 gap-3 px-4 py-4 pt-6"
|
||||
>
|
||||
<AFormItem label="上级菜单" name="parentId">
|
||||
<ATreeSelect
|
||||
v-model:value="model.parentId"
|
||||
show-search
|
||||
:field-names="{ value: 'id' }"
|
||||
allow-clear
|
||||
:tree-data="treeData"
|
||||
tree-node-filter-prop="label"
|
||||
/>
|
||||
</AFormItem>
|
||||
<AFormItem label="菜单名称" name="menuName">
|
||||
<AInput v-model:value="model.menuName" />
|
||||
</AFormItem>
|
||||
<AFormItem label="菜单类型" name="menuType">
|
||||
<ASelect v-model:value="model.menuType" :options="menuTypeOptions" />
|
||||
</AFormItem>
|
||||
<AFormItem v-if="model.menuType !== 'F'" label="菜单图标" name="icon">
|
||||
<IconSelect v-model="model.icon" />
|
||||
</AFormItem>
|
||||
<AFormItem v-if="model.menuType === 'C'" label="是否外链">
|
||||
<ARadioGroup v-model:value="model.isFrame" name="radioGroup" @change="() => (model.path = '')">
|
||||
<ARadio value="0">是</ARadio>
|
||||
<ARadio value="1">否</ARadio>
|
||||
</ARadioGroup>
|
||||
</AFormItem>
|
||||
<AFormItem v-if="model.menuType !== 'F'" label="菜单路径" name="path">
|
||||
<div>
|
||||
<!-- @vue-ignore -->
|
||||
<ATreeSelect
|
||||
v-if="model.isFrame === '1'"
|
||||
v-model:value="model.path"
|
||||
show-search
|
||||
:field-names="{ value: 'path', label: 'path' }"
|
||||
allow-clear
|
||||
:tree-data="componentOptions"
|
||||
tree-node-filter-prop="label"
|
||||
@select="(_val, node) => handleTreeSelect(node)"
|
||||
/>
|
||||
<AInput v-else v-model:value="model.path" />
|
||||
</div>
|
||||
</AFormItem>
|
||||
<AFormItem v-if="model.menuType === 'C'" label="隐藏菜单" name="hideInMenu">
|
||||
<ASwitch v-model:checked="model.hideInMenu" checked-value="0" un-checked-value="1" />
|
||||
</AFormItem>
|
||||
<AFormItem label="排序" name="orderNum">
|
||||
<AInputNumber v-model:value="model.orderNum" w-full />
|
||||
</AFormItem>
|
||||
<AFormItem v-if="model.menuType !== 'F'" label="状态" name="status">
|
||||
<ASelect v-model:value="model.status" :options="menuStatusOptions" />
|
||||
</AFormItem>
|
||||
<AFormItem v-if="model.menuType === 'C'" label="缓存" name="isCache">
|
||||
<ASwitch v-model:checked="model.isCache" checked-value="0" un-checked-value="1" />
|
||||
</AFormItem>
|
||||
<AFormItem v-if="model.menuType === 'F'" label="权限标识" name="perms">
|
||||
<AInput v-model:value="model.perms" />
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
|
||||
<template #footer>
|
||||
<AButton @click="submitForm">确定</AButton>
|
||||
<AButton @click="closeModal">取消</AButton>
|
||||
</template>
|
||||
</AModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user