|
@@ -0,0 +1,330 @@
|
|
|
|
|
+import type {
|
|
|
|
|
+ AuditRecordDTO,
|
|
|
|
|
+ AuditRecordVO,
|
|
|
|
|
+} from '#/request/schema/audit-record';
|
|
|
|
|
+
|
|
|
|
|
+import { $t } from '@vben/locales';
|
|
|
|
|
+
|
|
|
|
|
+import { z } from '#/adapter/form';
|
|
|
|
|
+import { decodeAuditRecord } from '#/request/schema/audit-record';
|
|
|
|
|
+import {
|
|
|
|
|
+ decodeZeroFlag,
|
|
|
|
|
+ encodeZeroFlag,
|
|
|
|
|
+ encodeZeroFlagOptional,
|
|
|
|
|
+} from '#/request/schema/wire-flag';
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// 字典 — 菜单类型
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+export const MenuTypeOptions = [
|
|
|
|
|
+ { label: '目录', value: 'catalog', color: 'purple' },
|
|
|
|
|
+ { label: '菜单', value: 'menu', color: 'blue' },
|
|
|
|
|
+ { label: '按钮', value: 'button', color: 'cyan' },
|
|
|
|
|
+ { label: '内嵌', value: 'embedded', color: 'red' },
|
|
|
|
|
+ { label: '外链', value: 'link', color: 'pink' },
|
|
|
|
|
+] as const;
|
|
|
|
|
+
|
|
|
|
|
+export type MenuTypeVO = (typeof MenuTypeOptions)[number]['value'];
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// 类型 — DTO / VO
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+/** 菜单 DTO,对应 OpenAPI `SysMenu` */
|
|
|
|
|
+export interface MenuDTO extends AuditRecordDTO {
|
|
|
|
|
+ /** 菜单 ID */
|
|
|
|
|
+ menuId?: number | string;
|
|
|
|
|
+ /** 菜单名称 */
|
|
|
|
|
+ menuName: string;
|
|
|
|
|
+ /** 父菜单名称 */
|
|
|
|
|
+ parentName?: string;
|
|
|
|
|
+ /** 父菜单 ID */
|
|
|
|
|
+ parentId?: number | string;
|
|
|
|
|
+ /** 显示顺序 */
|
|
|
|
|
+ orderNum: number;
|
|
|
|
|
+ /** 路由地址 */
|
|
|
|
|
+ path?: string;
|
|
|
|
|
+ /** 组件路径 */
|
|
|
|
|
+ component?: string;
|
|
|
|
|
+ /** 路由参数 */
|
|
|
|
|
+ query?: string;
|
|
|
|
|
+ /** 路由名称 */
|
|
|
|
|
+ routeName?: string;
|
|
|
|
|
+ /** 是否为外链(0 是 1 否) */
|
|
|
|
|
+ isFrame?: '0' | '1';
|
|
|
|
|
+ /** 是否缓存(0 缓存 1 不缓存) */
|
|
|
|
|
+ isCache?: '0' | '1';
|
|
|
|
|
+ /** 类型(M 目录 C 菜单 F 按钮) */
|
|
|
|
|
+ menuType: 'C' | 'F' | 'M';
|
|
|
|
|
+ /** 显示状态(0 显示 1 隐藏) */
|
|
|
|
|
+ visible?: '0' | '1';
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 菜单状态
|
|
|
|
|
+ * - 0 正常
|
|
|
|
|
+ * - 1 停用
|
|
|
|
|
+ */
|
|
|
|
|
+ status?: '0' | '1';
|
|
|
|
|
+ /** 权限字符串 */
|
|
|
|
|
+ perms?: string;
|
|
|
|
|
+ /** 菜单图标 */
|
|
|
|
|
+ icon?: string;
|
|
|
|
|
+ /** 子菜单 */
|
|
|
|
|
+ children?: MenuDTO[];
|
|
|
|
|
+ /** 备注 */
|
|
|
|
|
+ remark?: string;
|
|
|
|
|
+ /** 请求参数 */
|
|
|
|
|
+ params?: Record<string, unknown>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** @internal 菜单 VO 公共字段 */
|
|
|
|
|
+interface Menu {
|
|
|
|
|
+ /** 父菜单 ID,-> `parentId` */
|
|
|
|
|
+ pid?: string;
|
|
|
|
|
+ /** 菜单 ID,-> `menuId` */
|
|
|
|
|
+ id?: string;
|
|
|
|
|
+ /** 菜单名称,-> `routeName` */
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ /** 路由地址,-> `path` */
|
|
|
|
|
+ path?: string;
|
|
|
|
|
+ /** 组件路径,-> `component` */
|
|
|
|
|
+ component?: string;
|
|
|
|
|
+ /** 重定向 */
|
|
|
|
|
+ redirect?: string;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 菜单类型,-> `menuType` -> `isFrame`
|
|
|
|
|
+ * @description
|
|
|
|
|
+ * - catalog -> M
|
|
|
|
|
+ * - menu -> C
|
|
|
|
|
+ * - button -> F
|
|
|
|
|
+ * - embedded -> F 1 (外链 1-否)
|
|
|
|
|
+ * - link -> F 0 (外链 0-是)
|
|
|
|
|
+ */
|
|
|
|
|
+ type: MenuTypeVO;
|
|
|
|
|
+ /** 权限字符串,-> `perms` */
|
|
|
|
|
+ authCode?: string;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Vben 框架创建的标记
|
|
|
|
|
+ */
|
|
|
|
|
+ isVben?: boolean;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 菜单 VO(路由 / 表单 meta 结构) */
|
|
|
|
|
+export interface MenuVO extends Menu {
|
|
|
|
|
+ meta: {
|
|
|
|
|
+ /** 菜单图标,-> `icon` */
|
|
|
|
|
+ icon?: string;
|
|
|
|
|
+ /** 内嵌 Iframe 的 URL,-> `path` */
|
|
|
|
|
+ iframeSrc?: string;
|
|
|
|
|
+ /** 是否缓存页面,-> `isCache` */
|
|
|
|
|
+ keepAlive?: boolean;
|
|
|
|
|
+ /** 外链页面的 URL,-> `path` */
|
|
|
|
|
+ link?: string;
|
|
|
|
|
+ /** 菜单排序,-> `orderNum` */
|
|
|
|
|
+ order?: number;
|
|
|
|
|
+ /** 路由参数,-> `query` */
|
|
|
|
|
+ query?: string;
|
|
|
|
|
+ /** 菜单标题,-> `menuName` */
|
|
|
|
|
+ title?: string;
|
|
|
|
|
+ };
|
|
|
|
|
+ /** 子菜单,-> `children` */
|
|
|
|
|
+ children?: MenuVO[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 菜单列表 VO
|
|
|
|
|
+ *
|
|
|
|
|
+ * @remarks formSchema 无法稳定读取 `meta.*`,字段展平为顶层属性
|
|
|
|
|
+ */
|
|
|
|
|
+export interface MenuRecordVO extends AuditRecordVO, Menu {
|
|
|
|
|
+ /** 菜单标题,-> `menuName` */
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ /** 菜单状态,-> `status` */
|
|
|
|
|
+ status: boolean;
|
|
|
|
|
+ /** 菜单排序,-> `orderNum` */
|
|
|
|
|
+ order?: number;
|
|
|
|
|
+ /** 备注,-> `remark` */
|
|
|
|
|
+ remark?: string;
|
|
|
|
|
+ /** 菜单图标,-> `icon` */
|
|
|
|
|
+ icon?: string;
|
|
|
|
|
+ /** 外链页面的 URL,-> `path` */
|
|
|
|
|
+ link?: string;
|
|
|
|
|
+ /** 是否缓存页面,-> `isCache` */
|
|
|
|
|
+ keepAlive?: boolean;
|
|
|
|
|
+ /** 子菜单,-> `children` */
|
|
|
|
|
+ children?: MenuRecordVO[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// Schema
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+export const MenuVOSchema = z.object({
|
|
|
|
|
+ name: z
|
|
|
|
|
+ .string()
|
|
|
|
|
+ .min(2, $t('ui.formRules.minLength', ['标题', 2]))
|
|
|
|
|
+ .max(30, $t('ui.formRules.maxLength', ['标题', 30])),
|
|
|
|
|
+ path: z
|
|
|
|
|
+ .string()
|
|
|
|
|
+ .min(2, $t('ui.formRules.minLength', ['路由地址', 2]))
|
|
|
|
|
+ .max(100, $t('ui.formRules.maxLength', ['路由地址', 100]))
|
|
|
|
|
+ .refine(
|
|
|
|
|
+ (value) => value.startsWith('/'),
|
|
|
|
|
+ $t('ui.formRules.startWith', ['路由地址', '/']),
|
|
|
|
|
+ ),
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// 编解码
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+export const decodeMenu = (dto: MenuDTO): MenuVO => {
|
|
|
|
|
+ const type = decodeMenuType(dto);
|
|
|
|
|
+ const path = isLinkMenuType(type) ? void 0 : dto.path;
|
|
|
|
|
+ return {
|
|
|
|
|
+ pid: dto.parentId?.toString(),
|
|
|
|
|
+ id: dto.menuId?.toString(),
|
|
|
|
|
+ name: dto.routeName || pathToRouteName(path) || dto.menuName,
|
|
|
|
|
+ type,
|
|
|
|
|
+ path,
|
|
|
|
|
+ component: dto.component,
|
|
|
|
|
+ authCode: dto.perms,
|
|
|
|
|
+ meta: {
|
|
|
|
|
+ icon: decodeMenuIcon(dto.icon),
|
|
|
|
|
+ order: dto.orderNum === 0 ? void 0 : dto.orderNum,
|
|
|
|
|
+ title: dto.menuName,
|
|
|
|
|
+ keepAlive: decodeZeroFlag(dto.isCache, true),
|
|
|
|
|
+ query: dto.query,
|
|
|
|
|
+ ...(type === 'link' ? { link: dto.path } : {}),
|
|
|
|
|
+ ...(type === 'embedded' ? { iframeSrc: dto.path } : {}),
|
|
|
|
|
+ },
|
|
|
|
|
+ children: dto.children?.map((item) => decodeMenu(item)),
|
|
|
|
|
+ isVben: dto.remark?.toLowerCase().includes('[vben]') ?? false,
|
|
|
|
|
+ };
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+export const decodeMenuRecord = (dto: MenuDTO): MenuRecordVO => {
|
|
|
|
|
+ const { meta, ...menu } = decodeMenu(dto);
|
|
|
|
|
+ let link: string | undefined;
|
|
|
|
|
+ if (menu.type === 'link') link = meta.link;
|
|
|
|
|
+ if (menu.type === 'embedded') link = meta.iframeSrc;
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...decodeAuditRecord(dto),
|
|
|
|
|
+ ...menu,
|
|
|
|
|
+ ...meta,
|
|
|
|
|
+ link,
|
|
|
|
|
+ title: meta.title ?? menu.name,
|
|
|
|
|
+ status: decodeZeroFlag(dto.status, true),
|
|
|
|
|
+ remark: dto.remark,
|
|
|
|
|
+ children: dto.children?.map((item) => decodeMenuRecord(item)),
|
|
|
|
|
+ };
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+export const encodeMenuRecord = (vo: MenuRecordVO): MenuDTO => {
|
|
|
|
|
+ const { menuType, isFrame } = encodeMenuType(vo.type);
|
|
|
|
|
+ const isLink = isLinkMenuType(vo.type);
|
|
|
|
|
+ return {
|
|
|
|
|
+ parentId: vo.pid,
|
|
|
|
|
+ menuId: vo.id,
|
|
|
|
|
+ menuName: vo.title,
|
|
|
|
|
+ menuType,
|
|
|
|
|
+ isFrame,
|
|
|
|
|
+ perms: vo.authCode,
|
|
|
|
|
+ component: vo.component,
|
|
|
|
|
+ path: isLink ? vo.link : vo.path,
|
|
|
|
|
+ orderNum: vo.order ?? 0,
|
|
|
|
|
+ routeName: vo.name || pathToRouteName(isLink ? void 0 : vo.path),
|
|
|
|
|
+ status: encodeZeroFlag(vo.status, true),
|
|
|
|
|
+ isCache: encodeZeroFlag(vo.keepAlive, true),
|
|
|
|
|
+ icon: vo.icon,
|
|
|
|
|
+ remark: vo.remark,
|
|
|
|
|
+ };
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+export const encodeMenuQuery = (
|
|
|
|
|
+ vo: Partial<MenuRecordVO>,
|
|
|
|
|
+): Partial<MenuDTO> => {
|
|
|
|
|
+ return {
|
|
|
|
|
+ menuName: vo.title,
|
|
|
|
|
+ routeName: vo.name,
|
|
|
|
|
+ ...(vo.type == null ? {} : encodeMenuType(vo.type)),
|
|
|
|
|
+ status: encodeZeroFlagOptional(vo.status),
|
|
|
|
|
+ };
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// 内部工具
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+const HTTP_URL_RE = /^https?:\/\//i;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * @internal 路由 path → 路由名称(PascalCase 拼接)
|
|
|
|
|
+ *
|
|
|
|
|
+ * @example pathToRouteName('/system/dept') // => 'SystemDept'
|
|
|
|
|
+ * @example pathToRouteName('/zhcc/p/') // => 'ZhccP'
|
|
|
|
|
+ */
|
|
|
|
|
+function pathToRouteName(path?: string): string | undefined {
|
|
|
|
|
+ if (!path?.trim() || !path.startsWith('/')) {
|
|
|
|
|
+ return undefined;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const pathname = path.split('?')[0]?.split('#')[0] ?? '';
|
|
|
|
|
+ const segments = pathname
|
|
|
|
|
+ .split('/')
|
|
|
|
|
+ .filter(Boolean)
|
|
|
|
|
+ .filter((segment) => !segment.startsWith(':'))
|
|
|
|
|
+ .map(
|
|
|
|
|
+ (segment) =>
|
|
|
|
|
+ segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase(),
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ return segments.length > 0 ? segments.join('') : undefined;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** @internal DTO `menuType` + `isFrame` → VO `type` */
|
|
|
|
|
+function decodeMenuType(
|
|
|
|
|
+ dto: Pick<MenuDTO, 'component' | 'isFrame' | 'menuType' | 'path'>,
|
|
|
|
|
+): MenuTypeVO {
|
|
|
|
|
+ if (dto.menuType === 'M') return 'catalog';
|
|
|
|
|
+ if (dto.menuType === 'C') return 'menu';
|
|
|
|
|
+ if (dto.isFrame === '0') return 'link';
|
|
|
|
|
+
|
|
|
|
|
+ const path = dto.path ?? '';
|
|
|
|
|
+ if (HTTP_URL_RE.test(path)) return 'embedded';
|
|
|
|
|
+
|
|
|
|
|
+ const component = (dto.component ?? '').toLowerCase();
|
|
|
|
|
+ const isEmbedded =
|
|
|
|
|
+ component.includes('iframelayout') || component.includes('iframe');
|
|
|
|
|
+ return isEmbedded ? 'embedded' : 'button';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** @internal VO `type` → DTO `menuType` + `isFrame` */
|
|
|
|
|
+function encodeMenuType(
|
|
|
|
|
+ type: MenuTypeVO,
|
|
|
|
|
+): Pick<MenuDTO, 'isFrame' | 'menuType'> {
|
|
|
|
|
+ switch (type) {
|
|
|
|
|
+ case 'button': {
|
|
|
|
|
+ return { menuType: 'F', isFrame: '1' };
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'catalog': {
|
|
|
|
|
+ return { menuType: 'M', isFrame: '1' };
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'embedded': {
|
|
|
|
|
+ return { menuType: 'F', isFrame: '1' };
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'link': {
|
|
|
|
|
+ return { menuType: 'F', isFrame: '0' };
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'menu': {
|
|
|
|
|
+ return { menuType: 'C', isFrame: '1' };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isLinkMenuType(type: MenuTypeVO): boolean {
|
|
|
|
|
+ return type === 'link' || type === 'embedded';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function decodeMenuIcon(icon?: string): string | undefined {
|
|
|
|
|
+ return icon?.startsWith('#') ? undefined : icon;
|
|
|
|
|
+}
|