|
@@ -0,0 +1,209 @@
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// 角色 / 用户
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+export interface RoleModel {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ menus: string[];
|
|
|
|
|
+}
|
|
|
|
|
+/**
|
|
|
|
|
+ * 将接口返回的角色数据转为前端 `RoleModel`。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param data - 原始接口对象(含 `pid`、`rolename`、`menuIds` 等)
|
|
|
|
|
+ * @returns 规范化后的角色模型
|
|
|
|
|
+ */
|
|
|
|
|
+export function fromRoleData(data: Record<string, any>): RoleModel {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: data.pid,
|
|
|
|
|
+ name: data.rolename,
|
|
|
|
|
+ menus: Array.isArray(data.menuIds) ? data.menuIds : [],
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface UserModel {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ account: string;
|
|
|
|
|
+ roles: RoleModel[];
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 需要重置密码
|
|
|
|
|
+ */
|
|
|
|
|
+ resetPassword?: boolean;
|
|
|
|
|
+}
|
|
|
|
|
+/**
|
|
|
|
|
+ * 将接口返回的用户数据转为前端 `UserModel`。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param data - 原始接口对象(含 `pid`、`username`、`userid`、`roles`、`isFirst` 等)
|
|
|
|
|
+ * @returns 规范化后的用户模型
|
|
|
|
|
+ */
|
|
|
|
|
+export function fromUserData(data: Record<string, any>): UserModel {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: data.pid,
|
|
|
|
|
+ name: data.username,
|
|
|
|
|
+ account: data.userid,
|
|
|
|
|
+ roles: Array.isArray(data.roles) ? data.roles.map(fromRoleData) : [],
|
|
|
|
|
+ resetPassword: data.isFirst === 0,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// 菜单树(类型、展平、按 id 查找 / 过滤)
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+export interface MenuModel {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ label: string;
|
|
|
|
|
+ path: string;
|
|
|
|
|
+ meta: {
|
|
|
|
|
+ title?: string;
|
|
|
|
|
+ icon?: string;
|
|
|
|
|
+ };
|
|
|
|
|
+ children?: MenuModel[];
|
|
|
|
|
+}
|
|
|
|
|
+/**
|
|
|
|
|
+ * 将接口返回的菜单树转为前端 `MenuModel[]`,并生成面包屑式 `label`、按 `meta.order` 排序。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param menus - 接口菜单数组;根调用时可不传 `parent`
|
|
|
|
|
+ * @param parent - 递归用父节点,用于拼接 `label`
|
|
|
|
|
+ * @returns 根调用返回菜单树;子调用无数据时返回 `undefined` 以便省略空 `children`
|
|
|
|
|
+ */
|
|
|
|
|
+export function fromMenusData(menus?: Record<string, any>[], parent?: MenuModel): MenuModel[] | undefined {
|
|
|
|
|
+ if (!Array.isArray(menus) || menus.length === 0) return parent ? void 0 : [];
|
|
|
|
|
+ return menus
|
|
|
|
|
+ .sort((a, b) => (a.meta?.order ?? 0) - (b.meta?.order ?? 0))
|
|
|
|
|
+ .map((item) => {
|
|
|
|
|
+ item.meta ??= {};
|
|
|
|
|
+ const menu = { ...item, label: [parent?.label, item.meta?.title].filter(Boolean).join(' / ') } as MenuModel;
|
|
|
|
|
+ menu.children = fromMenusData(item.children, menu);
|
|
|
|
|
+ return menu;
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 展平后的叶子菜单项:`parents` 为从根到直接父级的祖先链(不含自身)。
|
|
|
|
|
+ */
|
|
|
|
|
+export type MenuLeafFlat = Omit<MenuModel, 'children'> & {
|
|
|
|
|
+ parents: MenuModel[];
|
|
|
|
|
+};
|
|
|
|
|
+/**
|
|
|
|
|
+ * 从菜单树中取出所有叶子节点(无 `children` 或 `children` 为空),按深度优先顺序展平,并附带祖先链。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param menus - 根级菜单数组
|
|
|
|
|
+ * @returns 叶子项列表,每项带 `parents` 祖先路径
|
|
|
|
|
+ */
|
|
|
|
|
+export function flattenMenuLeaves(menus: MenuModel[]): MenuLeafFlat[] {
|
|
|
|
|
+ const walk = (nodes: MenuModel[], ancestors: MenuModel[]): MenuLeafFlat[] =>
|
|
|
|
|
+ nodes.flatMap((node) => {
|
|
|
|
|
+ // 非叶子:继续向下 DFS,当前节点入栈作为子树的祖先
|
|
|
|
|
+ if (node.children?.length) {
|
|
|
|
|
+ return walk(node.children, [...ancestors, node]);
|
|
|
|
|
+ }
|
|
|
|
|
+ const { children: _c, ...leaf } = node;
|
|
|
|
|
+ return [{ ...leaf, parents: [...ancestors] }];
|
|
|
|
|
+ });
|
|
|
|
|
+ return walk(menus, []);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 在菜单树中按 id 列表查找节点,返回**扁平数组**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * - 结果顺序与入参 `ids` 一致。
|
|
|
|
|
+ * - 树中不存在的 id 会被跳过,不会占位。
|
|
|
|
|
+ * - 树内同一 `id` 出现多次时,取 DFS **先遇到**的节点引用。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param menus - 完整菜单树
|
|
|
|
|
+ * @param ids - 待查找的菜单 id 列表
|
|
|
|
|
+ * @returns 命中的 `MenuModel` 数组(保持 `ids` 顺序)
|
|
|
|
|
+ */
|
|
|
|
|
+export function findMenusByIds(menus: MenuModel[], ids: string[]): MenuModel[] {
|
|
|
|
|
+ if (ids.length === 0) return [];
|
|
|
|
|
+ // DFS 建 id → 节点表;同一 id 多次出现时保留先遇到的引用
|
|
|
|
|
+ const byId = new Map<string, MenuModel>();
|
|
|
|
|
+ const walk = (nodes: MenuModel[]) => {
|
|
|
|
|
+ for (const node of nodes) {
|
|
|
|
|
+ if (!byId.has(node.id)) byId.set(node.id, node);
|
|
|
|
|
+ if (node.children?.length) walk(node.children);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ walk(menus);
|
|
|
|
|
+ return ids.map((id) => byId.get(id)).filter((n): n is MenuModel => n != null);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 按 id 集合过滤菜单树,**保持树形结构**(浅拷贝节点,不修改入参树)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 流程分三步:
|
|
|
|
|
+ * 1. **标记**:DFS 收集应保留的节点 id(可选把祖先 id 一并加入)。
|
|
|
|
|
+ * 2. **裁剪**:只克隆 `keep` 集合中的节点及其保留子分支。
|
|
|
|
|
+ * 3. **可选收缩**:`options.collapseSingleChild` 为 true 时去掉「仅有一个子节点」的中间层。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param menus - 完整菜单树
|
|
|
|
|
+ * @param ids - 权限或配置中给出的菜单 id(可仅为叶子 id)
|
|
|
|
|
+ * @param options - 可选行为配置
|
|
|
|
|
+ * @param options.includeAncestors - `true`(默认)时,任一 id 命中会把从根到该节点的父级链一并保留,得到完整子树;`false` 时只保留 id 直接命中的节点(父级不在 `ids` 中则不会出现,可能多根或零散)。
|
|
|
|
|
+ * @param options.collapseSingleChild - `true` 时,过滤后若节点只剩一个子节点则用子顶替父,可连续收缩至分叉或叶子;`false`(默认)保留原层级。
|
|
|
|
|
+ * @returns 新的菜单树根数组;`ids` 为空时返回 `[]`
|
|
|
|
|
+ */
|
|
|
|
|
+export function filterMenuTreeByIds(
|
|
|
|
|
+ menus: MenuModel[],
|
|
|
|
|
+ ids?: string[],
|
|
|
|
|
+ options?: { includeAncestors?: boolean; collapseSingleChild?: boolean },
|
|
|
|
|
+): MenuModel[] {
|
|
|
|
|
+ const idSet = new Set(ids?.filter(Boolean) ?? []);
|
|
|
|
|
+ if (idSet.size === 0) return [];
|
|
|
|
|
+
|
|
|
|
|
+ const includeAncestors = options?.includeAncestors ?? true;
|
|
|
|
|
+ const collapseSingleChild = options?.collapseSingleChild ?? false;
|
|
|
|
|
+ /** 裁剪阶段应保留的节点 id */
|
|
|
|
|
+ const keep = new Set<string>();
|
|
|
|
|
+
|
|
|
|
|
+ /** 阶段一:根据命中 id 填充 `keep` */
|
|
|
|
|
+ const mark = (nodes: MenuModel[], ancestors: MenuModel[]) => {
|
|
|
|
|
+ for (const node of nodes) {
|
|
|
|
|
+ if (idSet.has(node.id)) {
|
|
|
|
|
+ keep.add(node.id);
|
|
|
|
|
+ // 侧栏等场景:子节点被授权时,父级占位也要保留
|
|
|
|
|
+ if (includeAncestors) {
|
|
|
|
|
+ for (const a of ancestors) keep.add(a.id);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (node.children?.length) mark(node.children, [...ancestors, node]);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ mark(menus, []);
|
|
|
|
|
+
|
|
|
|
|
+ /** 阶段二:仅克隆 keep 内的子树 */
|
|
|
|
|
+ const prune = (nodes: MenuModel[]): MenuModel[] => {
|
|
|
|
|
+ const out: MenuModel[] = [];
|
|
|
|
|
+ for (const node of nodes) {
|
|
|
|
|
+ if (!keep.has(node.id)) continue;
|
|
|
|
|
+ const nextChildren = node.children?.length ? prune(node.children) : undefined;
|
|
|
|
|
+ out.push({
|
|
|
|
|
+ ...node,
|
|
|
|
|
+ children: nextChildren?.length ? nextChildren : undefined,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const pruned = prune(menus);
|
|
|
|
|
+ if (!collapseSingleChild) return pruned;
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 阶段三:子树内若存在「单子父链」,自下而上递归折叠后,再用 while 吃掉残余单层包裹。
|
|
|
|
|
+ */
|
|
|
|
|
+ const collapse = (node: MenuModel): MenuModel => {
|
|
|
|
|
+ const nextChildren = node.children?.length ? node.children.map(collapse) : undefined;
|
|
|
|
|
+ let n: MenuModel = {
|
|
|
|
|
+ ...node,
|
|
|
|
|
+ children: nextChildren?.length ? nextChildren : undefined,
|
|
|
|
|
+ };
|
|
|
|
|
+ while (n.children?.length === 1) {
|
|
|
|
|
+ n = n.children[0];
|
|
|
|
|
+ }
|
|
|
|
|
+ return n;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return pruned.map(collapse);
|
|
|
|
|
+}
|