feat: 优化菜单管理页面

master
luoer 2023-10-25 18:45:55 +08:00
parent 873cfac8c3
commit b490b6c9c5
19 changed files with 1758 additions and 497 deletions

2
.env
View File

@ -6,7 +6,7 @@ VITE_TITLE = 绝弹管理后台
# 网站副标题
VITE_SUBTITLE = 快速开发web应用的模板工具
# 接口前缀 说明:参见 axios 的 baseURL
VITE_API = https://nest.dev.juetan.cn/
VITE_API = http://127.0.0.1:3030/
# =====================================================================================
# 开发设置

View File

@ -10,7 +10,7 @@ const run = async () => {
const output = await generateApi({
url: "http://localhost:3030/openapi.json",
templates: path.resolve(__dirname, "./template"),
// output: path.resolve(process.cwd(), "src/api/service"),
output: path.resolve(process.cwd(), "src/api/service"),
name: "Api.ts",
singleHttpClient: false,
httpClientType: "axios",
@ -28,7 +28,6 @@ const run = async () => {
parser: "typescript",
},
});
debugger;
return output;
};

File diff suppressed because it is too large Load Diff

View File

@ -58,7 +58,7 @@ export const FormItem = (props: any, { emit }: any) => {
{{
default: () => {
if (item.component) {
return <item.component {...item.nodeProps} />;
return <item.component {...item.nodeProps} model={props.model} item={props.item} />;
}
const comp = nodeMap[item.type as NodeType]?.component;
if (!comp) {
@ -105,7 +105,7 @@ type FormItemBase = {
*
* @description FormItemlabel
*/
label?: string | ((item: IFormItem, model: Record<string, any>) => any);
label?: string | ((args: { item: IFormItem; model: Record<string, any> }) => any);
/**
* `FormItem`

View File

@ -14,23 +14,24 @@ import {
Slider,
Textarea,
TimePicker,
TreeSelect,
} from "@arco-design/web-vue";
const initOptions = ({ item, model }: any) => {
const initOptions = ({ item, model }: any, key = "options") => {
if (Array.isArray(item.options)) {
item.nodeProps.options = item.options;
item.nodeProps[key] = item.options;
}
if (typeof item.options === "function") {
const loadData = item.options;
item.nodeProps.options = reactive([]);
item.nodeProps[key] = reactive([]);
item._updateOptions = async () => {
let data = await loadData({ item, model });
if (Array.isArray(data?.data?.data)) {
data = data.data.data.map((i: any) => ({ label: i.name, value: i.id }));
}
if (Array.isArray(data)) {
item.nodeProps.options.splice(0);
item.nodeProps.options.push(...data);
item.nodeProps[key].splice(0);
item.nodeProps[key].push(...data);
}
};
item._updateOptions();
@ -104,6 +105,19 @@ export const nodeMap = {
} as InstanceType<typeof Select>["$props"],
init: initOptions,
},
/**
*
*/
treeSelect: {
component: TreeSelect,
nodeProps: {
placeholder: "请选择",
allowClear: true,
allowSearch: true,
options: [{}],
} as InstanceType<typeof TreeSelect>["$props"],
init: (arg: any) => initOptions(arg, "data"),
},
/**
*
*/

View File

@ -26,7 +26,8 @@ export type Options = {
* @see src/components/form/use-form.tsx
*/
export const useForm = (options: Options) => {
const { model = { id: undefined } } = options;
const { model: _model = {} } = options;
const model: Record<string, any> = { id: undefined, ..._model };
const items: IFormItem[] = [];
for (const item of options.items) {

View File

@ -104,6 +104,8 @@ type ExtendedFormItem = Partial<IFormItem> & {
type SearchFormItem = ExtendedFormItem & {
enableLoad?: boolean;
searchable?: boolean;
enterable?: boolean;
};
type Search = Partial<
@ -115,7 +117,7 @@ type Search = Partial<
/**
* /
*/
button?: boolean
button?: boolean;
}
>;

View File

@ -167,18 +167,9 @@ export const useTable = (optionsOrFn: UseTableOptions | (() => UseTableOptions))
continue;
}
}
const search = !item.enableLoad ? undefined : () => getTable().reloadData();
searchItems.push(
merge(
{
nodeProps: {
onSearch: search,
onPressEnter: search,
},
},
item
)
);
const onSearch = item.searchable ? () => getTable().reloadData() : undefined;
const onPressEnter = item.enterable ? () => getTable().reloadData() : undefined;
searchItems.push(merge({ nodeProps: { onSearch, onPressEnter } }, item));
}
if (options.search.button !== false) {
searchItems.push(config.searchItemSubmit);

34
src/constants/menu.ts Normal file
View File

@ -0,0 +1,34 @@
import { defineConstants } from "./defineConstants";
export enum MenuType {
/**
*
*/
MENU = 1,
/**
*
*/
PAGE = 2,
/**
*
*/
BUTTON = 3,
}
export const MenuTypes = defineConstants([
{
value: MenuType.MENU,
label: "目录",
color: 'purple'
},
{
value: MenuType.PAGE,
label: "页面",
color: 'green'
},
{
value: MenuType.BUTTON,
label: "按钮",
color: 'blue'
},
]);

View File

@ -17,7 +17,7 @@ const upload = (option: RequestOption) => {
const { fileItem, onError, onProgress, onSuccess } = option;
const source = axios.CancelToken.source();
if (fileItem.file) {
api.upload
api.file
.addFile(
{
file: fileItem.file,

View File

@ -64,7 +64,7 @@ const getIcon = (mimetype: string) => {
const table = useTable({
data: async (model, paging) => {
return api.upload.getUploads();
return api.file.getFiles();
},
columns: [
{
@ -105,7 +105,7 @@ const table = useTable({
type: "delete",
text: "删除",
onClick({ record }) {
return api.upload.delFile(record.id);
return api.file.delFile(record.id);
},
},
],

View File

@ -1,78 +1,44 @@
<template>
<bread-page class="">
<Table v-bind="table"></Table>
<Table v-bind="table">
<template #action>
<a-button type="outline">展开/折叠</a-button>
</template>
</Table>
</bread-page>
</template>
<script setup lang="tsx">
import { api } from "@/api";
import { Table, createColumn, updateColumn, useTable } from "@/components";
import { menus } from "@/router";
import { cloneDeep } from "lodash-es";
import { MenuTypes, MenuType } from "@/constants/menu";
import { flatedMenus } from "@/router";
const items = cloneDeep(menus) as any;
for (const item of items) {
item.checked = false;
// if (item.icon) {
// const icon = item.icon;
// item.icon = () => <i class={icon}></i>;
// }
item.switcherIcon = () => null;
if (item.children) {
for (const child of item.children) {
// if (child.icon) {
// const icon = child.icon;
// child.icon = () => <i class={icon}></i>;
// }
child.checked = false;
}
}
}
const state = reactive({
menus: items,
visible: true,
});
const indeter = (items: any[]) => {
if (!items) {
return false;
}
const checked = items.filter((item) => item.checked);
return checked.length > 0 && checked.length < items.length;
};
const onItemChange = (item: any, menu: any) => {
const checked = menu.children.filter((item: any) => item.checked);
if (checked === 0) {
menu.checked = false;
} else if (checked === menu.children.length) {
menu.checked = true;
}
};
const menuArr = flatedMenus.map((i) => ({ label: i.title, value: i.id }));
const table = useTable({
data: items,
data: (search, paging) => {
return api.menu.getMenus({ ...search, ...paging, tree: true });
},
columns: [
{
title: "角色名称",
dataIndex: "title",
title: "菜单名称",
dataIndex: "name",
width: 180,
render: ({ record }) => {
return (
<span>
<i class={`${record.icon} mr-1 ml-1 vertical-[-2px]`}></i>
{ record.title }
</span>
)
}
},
{
title: "类型",
dataIndex: "description",
align: 'center',
width: 80,
render: () => <a-tag color="blue">菜单</a-tag>,
align: "center",
width: 120,
render: ({ record }) => (
<a-tag color={MenuTypes.fmt(record.type, "color")}>
{{
icon: <i class={record.icon}></i>,
default: () => MenuTypes.fmt(record.type),
}}
</a-tag>
),
},
{
title: "访问路径",
@ -82,7 +48,7 @@ const table = useTable({
title: "启用",
dataIndex: "createdAt",
width: 80,
align: 'center',
align: "center",
render: ({ record }) => <a-switch size="small" checked-color="#3c9"></a-switch>,
},
createColumn,
@ -97,7 +63,8 @@ const table = useTable({
text: "修改",
},
{
text: "分配权限",
text: "新增下级",
disabled: ({ record }) => record.type === MenuType.BUTTON,
onClick: ({ record }) => {
console.log(record);
},
@ -106,14 +73,14 @@ const table = useTable({
text: "删除",
type: "delete",
onClick: ({ record }) => {
return api.role.delRole(record.id);
return api.menu.delMenu(record.id);
},
},
],
},
],
pagination: {
visible: false
visible: false,
},
search: {
items: [
@ -121,59 +88,122 @@ const table = useTable({
extend: "name",
required: false,
nodeProps: {
placeholder: "请输入角色名称",
},
itemProps: {
hideLabel: true,
placeholder: "菜单名称",
},
},
],
},
create: {
title: "新建角色",
title: "新建菜单",
modalProps: {
width: 580,
width: 732,
maskClosable: false,
},
formProps: {
layout: "vertical",
class: "!grid grid-cols-2 gap-x-4",
},
items: [
{
field: "type",
initial: 1,
label: "类型",
type: "radio",
options: MenuTypes.raw,
nodeProps: {
type: "button",
class: "w-full",
},
},
{
field: "parentId",
initial: 0,
label: "父级",
type: "treeSelect",
async options(arg) {
const res = await api.menu.getMenus({ size: 0, tree: true });
const data = res.data.data;
console.log(arg);
return [
{
id: 0,
name: "主类目",
children: data,
},
];
},
nodeProps: {
fieldNames: {
icon: undefined,
key: "id",
title: "name",
},
},
},
{
field: "name",
label: "角色名称",
label: "名称",
type: "input",
required: true,
},
{
field: "slug",
label: "角色标识",
field: "code",
label: "标识",
type: "input",
required: true,
},
{
field: "icon",
label: "图标",
type: "input",
required: true,
visible: ({ model }) => model.type !== MenuType.BUTTON,
},
{
field: "path",
label: "路径",
type: "input",
required: true,
visible: ({ model }) => model.type !== MenuType.BUTTON,
nodeProps: {
placeholder: "内链请以 / 开头,外链请以 http 开头",
},
rules: [
{
match: /^(\/|http)/,
message: "请以 / 或 http 开头",
},
],
},
{
field: "component",
label: "关联组件",
type: "select",
required: true,
visible: ({ model }) => model.type === MenuType.PAGE,
options: menuArr,
nodeProps: {
placeholder: "当前页面对应的前端组件",
},
},
{
field: "description",
label: "个人描述",
label: "菜单描述",
type: "textarea",
},
{
field: "permissionIds",
label: "关联权限",
type: "select",
options: () => api.permission.getPermissions(),
nodeProps: {
multiple: true,
itemProps: {
class: "col-span-2",
},
},
],
submit: ({ model }) => {
return api.role.addRole(model);
return api.menu.addMenu(model);
},
},
modify: {
extend: true,
title: "修改角色",
title: "修改菜单",
submit: ({ model }) => {
return api.role.updateRole(model.id, model);
return api.menu.setMenu(model.id, model);
},
},
});

View File

@ -0,0 +1,91 @@
<template>
<div class="flex gap-2 items-center">
<a-avatar>
<img v-show="file?.url" :src="file?.url" alt="" />
</a-avatar>
<div>
<a-upload
action="/"
accept=".png,.jpg,.webp"
:fileList="file ? [file] : []"
:show-file-list="false"
:custom-request="upload"
@change="onChange"
@progress="onProgress"
>
<template #upload-button>
<a-link>选择文件...</a-link>
</template>
</a-upload>
<div class="text-gray-400 text-xs">请选择不超过5MB.png, .jpg, .webp格式的图片</div>
</div>
</div>
</template>
<script setup lang="ts">
import { RequestParams, api } from "@/api";
import { FileItem, RequestOption } from "@arco-design/web-vue";
import axios from "axios";
import { ref } from "vue";
const file = ref();
const props = defineProps({
modelValue: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:modelValue"]);
watch(
() => props.modelValue,
(value) => {
file.value = { url: value };
}
);
const upload = (option: RequestOption) => {
const { fileItem, onError, onProgress, onSuccess } = option;
const source = axios.CancelToken.source();
const send = async (file: File) => {
const data = { file };
const params: RequestParams = {
onUploadProgress(e) {
let percent = 0;
if (e.total && e.total > 0) {
percent = e.loaded / e.total;
}
onProgress(percent, e as any);
},
cancelToken: source.token,
};
try {
const res = await api.file.addFile(data, params);
onSuccess(res);
console.log(res.data.data);
emit("update:modelValue", res.data.data?.path);
} catch (e) {
onError(e);
}
};
if (fileItem.file) {
send(fileItem.file);
}
return {
abort() {
source.cancel();
},
};
};
const onChange = (_: any, currentFile: FileItem) => {
file.value = {
...currentFile,
};
};
const onProgress = (currentFile: FileItem) => {
file.value = currentFile;
};
</script>

View File

@ -1,12 +1,18 @@
<template>
<BreadPage>
<Table v-bind="table"> </Table>
<pass-modal></pass-modal>
</BreadPage>
</template>
<script setup lang="tsx">
import { api } from "@/api";
import { Table, createColumn, updateColumn, useTable } from "@/components";
import InputAvatar from "./avatar.vue";
import { usePassworModal } from "./password";
import { MenuType } from "@/constants/menu";
const [passModal, passCtx] = usePassworModal();
const table = useTable({
data: async (model, paging) => {
@ -20,7 +26,7 @@ const table = useTable({
render: ({ record }) => (
<div class="flex items-center">
<a-avatar size={32}>
<img src={`https://picsum.photos/200?${Math.random()}`} alt="" />
<img src={record.avatar} alt="" />
</a-avatar>
<span class="ml-2 flex-1 flex flex-col overflow-hidden">
<span>{record.nickname}</span>
@ -51,6 +57,9 @@ const table = useTable({
},
{
text: "设置密码",
onClick({ record }) {
passCtx.open(record);
},
},
{
type: "delete",
@ -63,13 +72,14 @@ const table = useTable({
},
],
search: {
button: false,
button: true,
items: [
{
extend: "nickname",
required: false,
type: 'search',
enableLoad: true,
field: "nickname",
label: "用户昵称",
type: "search",
searchable: true,
enterable: true,
itemProps: {
hideLabel: true,
},
@ -89,23 +99,41 @@ const table = useTable({
layout: "vertical",
class: "!grid grid-cols-2 gap-x-6",
},
model: {},
items: [
{
field: "avatar",
label: "用户头像",
type: "custom",
itemProps: {
class: "col-span-2",
},
component({ model }) {
return <InputAvatar v-model={model.avatar}></InputAvatar>;
},
},
{
field: "username",
label: "登录账号",
type: "input",
required: true,
nodeProps: {
placeholder: "英文字母+数组组成5~10位",
},
},
{
field: "password",
label: "登陆密码",
type: "input",
nodeProps: {
placeholder: "包含大小写长度6 ~ 12位",
},
},
{
field: "nickname",
label: "用户昵称",
type: "input",
},
{
field: "password",
label: "密码",
type: "input",
},
{
field: "roleIds",
label: "关联角色",
@ -123,8 +151,8 @@ const table = useTable({
class: "col-span-2",
},
nodeProps: {
class: 'h-[96px]'
}
class: "h-[96px]",
},
},
],
submit: ({ model }) => {
@ -135,7 +163,7 @@ const table = useTable({
extend: true,
title: "修改用户",
submit: ({ model }) => {
return api.user.updateUser(model.id, model);
return api.user.setUser(model.id, model);
},
},
});

View File

@ -0,0 +1,31 @@
import { api } from "@/api";
import { useAniFormModal } from "@/components";
export const usePassworModal = () => {
return useAniFormModal({
title: "重置密码",
trigger: false,
modalProps: {
width: 432,
},
model: {
id: undefined,
nickname: undefined,
},
items: [
{
field: "password",
label: ({ model }) => (
<span>
<span class="text-brand-500 font-semibold">{model.nickname}</span>
</span>
),
type: "input",
},
],
submit: async ({ model }) => {
return api.user.setUser(model.id, model);
},
});
};

View File

@ -114,9 +114,19 @@ function transformToMenuItems(routes: RouteRecordRaw[]) {
}
/**
*
*
*/
const menus = transformToMenuItems(appRoutes);
const flatedMenus = routesToItems(appRoutes);
export { menus };
/**
*
*/
const treeMenus = listToTree(flatedMenus);
/**
*
*/
const menus = sort(treeMenus);
export { menus, treeMenus, flatedMenus };
export type { MenuItem };

View File

@ -44,6 +44,7 @@ declare module '@vue/runtime-core' {
AModal: typeof import('@arco-design/web-vue')['Modal']
APagination: typeof import('@arco-design/web-vue')['Pagination']
APopover: typeof import('@arco-design/web-vue')['Popover']
AProgress: typeof import('@arco-design/web-vue')['Progress']
ARadio: typeof import('@arco-design/web-vue')['Radio']
ARadioGroup: typeof import('@arco-design/web-vue')['RadioGroup']
AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar']

View File

@ -9,3 +9,12 @@ export const listToTree = (list: any[], id = "id", pid = "parentId", cid = "chil
return !item[pid];
});
};
export function treeEach(tree: any[], fn: (item: any) => void) {
for (const item of tree) {
fn(item);
if (item.children) {
treeEach(item.children, fn);
}
}
}

View File

@ -105,6 +105,11 @@ export default defineConfig(({ mode }) => {
server: {
host,
port,
proxy: {
"/upload": {
target: "http://127.0.0.1:3030",
},
},
},
css: {
preprocessorOptions: {