289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
/** 菜单管理 DTO/模型(任务 53):扁平 PermissionMenuItemVo → 可编辑节点 + 创建/更新请求体。 */
|
||
import { unwrap } from '../../api/envelope.ts'
|
||
|
||
/** 后台 Vue 控制台菜单类型。 */
|
||
export const ADMIN_MENU_TYPE = 'admin'
|
||
|
||
export interface MenuManageNode {
|
||
id: number
|
||
name: string
|
||
/** column_key,稳定权限标识。 */
|
||
columnKey: string
|
||
parentId: number | null
|
||
rootColumnKey?: string
|
||
menuType: string
|
||
routePath: string
|
||
sortOrder: number
|
||
createdAt?: string
|
||
children?: MenuManageNode[]
|
||
}
|
||
|
||
/** 菜单类型展示文案(对齐 admin.js:6824:admin → 后台,其余一律 → 软件)。 */
|
||
export function menuTypeLabel(type: string | null | undefined): string {
|
||
return (type || '').toLowerCase() === 'admin' ? '后台' : '软件'
|
||
}
|
||
|
||
/** 菜单类型下拉选项(对齐 admin.html:5931-5934 后台/软件)。 */
|
||
export const MENU_TYPE_OPTIONS: Array<{ value: 'admin' | 'app'; label: string }> = [
|
||
{ value: 'admin', label: '后台' },
|
||
{ value: 'app', label: '软件' },
|
||
]
|
||
|
||
export interface MenuManageForm {
|
||
name: string
|
||
columnKey: string
|
||
parentId: number | null
|
||
menuType: string
|
||
routePath: string
|
||
sortOrder: number
|
||
}
|
||
|
||
/** 请求体字段与 Java PermissionMenuCreate/UpdateRequest 对齐(camelCase)。
|
||
* 权限标识/路由由后台自动生成,不随表单提交,故为可选字段。 */
|
||
export interface MenuManagePayload {
|
||
name: string
|
||
columnKey?: string
|
||
parentId: number | null
|
||
menuType: string
|
||
routePath?: string
|
||
sortOrder: number
|
||
}
|
||
|
||
export interface MenuManageFormErrors {
|
||
name?: string
|
||
columnKey?: string
|
||
menuType?: string
|
||
routePath?: string
|
||
}
|
||
|
||
function numberOrNull(value: unknown): number | null {
|
||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
|
||
}
|
||
|
||
function text(value: unknown): string {
|
||
return typeof value === 'string' ? value.trim() : ''
|
||
}
|
||
|
||
/** 排序归一:非负整数,非法回 0。 */
|
||
export function normalizeMenuSort(value: unknown): number {
|
||
const n = numberOrNull(value)
|
||
return n !== null && n >= 0 ? n : 0
|
||
}
|
||
|
||
/** 把单条 PermissionMenuItemVo(snake_case) 归一为可编辑节点;缺 id 视为无效。 */
|
||
export function parseMenuManageItem(raw: unknown): MenuManageNode | null {
|
||
if (!raw || typeof raw !== 'object') return null
|
||
const record = raw as Record<string, unknown>
|
||
const id = numberOrNull(record.id)
|
||
if (id === null) return null
|
||
const parentId = numberOrNull(record.parent_id)
|
||
const node: MenuManageNode = {
|
||
id,
|
||
name: text(record.name),
|
||
columnKey: text(record.column_key),
|
||
parentId: parentId === null ? null : parentId,
|
||
menuType: text(record.menu_type) || ADMIN_MENU_TYPE,
|
||
routePath: text(record.route_path),
|
||
sortOrder: normalizeMenuSort(record.sort_order),
|
||
}
|
||
const rootColumnKey = text(record.root_column_key)
|
||
if (rootColumnKey) node.rootColumnKey = rootColumnKey
|
||
const createdAt = text(record.created_at)
|
||
if (createdAt) node.createdAt = createdAt
|
||
return node
|
||
}
|
||
|
||
/** 归一化 permission-menus 响应(信封或裸数组)为可编辑节点列表。 */
|
||
export function parseMenuManageList(payload: unknown): MenuManageNode[] {
|
||
const data = unwrap<unknown>(payload)
|
||
if (!Array.isArray(data)) return []
|
||
return data
|
||
.map((raw) => parseMenuManageItem(raw))
|
||
.filter((node): node is MenuManageNode => node !== null)
|
||
}
|
||
|
||
/** 按 parentId 组装展示树;同级按 (sortOrder,name) 升序,父缺失按根处理。 */
|
||
export function buildMenuManageTree(items: MenuManageNode[]): MenuManageNode[] {
|
||
const nodes = new Map<number, MenuManageNode>()
|
||
for (const item of items) {
|
||
nodes.set(item.id, { ...item, children: [] })
|
||
}
|
||
const roots: MenuManageNode[] = []
|
||
for (const item of items) {
|
||
const node = nodes.get(item.id)
|
||
if (!node) continue
|
||
const parent = item.parentId === null ? undefined : nodes.get(item.parentId)
|
||
if (parent) {
|
||
parent.children!.push(node)
|
||
} else {
|
||
roots.push(node)
|
||
}
|
||
}
|
||
const order = (a: MenuManageNode, b: MenuManageNode) =>
|
||
a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)
|
||
const sortRecursive = (nodes: MenuManageNode[]) => {
|
||
nodes.sort(order)
|
||
for (const node of nodes) node.children?.length && sortRecursive(node.children)
|
||
}
|
||
sortRecursive(roots)
|
||
for (const item of items) {
|
||
const node = nodes.get(item.id)
|
||
if (node && !node.children?.length) delete node.children
|
||
}
|
||
return roots
|
||
}
|
||
|
||
/** 展平树为列表(前序),供父级下拉/统计复用。 */
|
||
export function flattenMenuNodes(nodes: MenuManageNode[]): MenuManageNode[] {
|
||
const out: MenuManageNode[] = []
|
||
const walk = (list: MenuManageNode[]) => {
|
||
for (const node of list) {
|
||
out.push(node)
|
||
if (node.children?.length) walk(node.children)
|
||
}
|
||
}
|
||
walk(nodes)
|
||
return out
|
||
}
|
||
|
||
/** 同级建议排序:该父下最大 sortOrder + 1;无同级返回 0。 */
|
||
export function nextSiblingSort(items: MenuManageNode[], parentId: number | null): number {
|
||
let max = -1
|
||
for (const item of items) {
|
||
if (item.parentId === parentId && item.sortOrder > max) max = item.sortOrder
|
||
}
|
||
return max + 1
|
||
}
|
||
|
||
/** 新建菜单表单工厂:指定父节点与建议排序,其余默认(menuType=admin)。 */
|
||
export function createMenuForm(parentId: number | null, sortOrder: number): MenuManageForm {
|
||
return {
|
||
...emptyMenuManageForm(),
|
||
parentId: typeof parentId === 'number' ? parentId : null,
|
||
sortOrder: normalizeMenuSort(sortOrder),
|
||
}
|
||
}
|
||
|
||
/** 编辑表单回填:从可编辑节点还原表单字段。 */
|
||
export function menuFormFromNode(node: MenuManageNode): MenuManageForm {
|
||
return {
|
||
name: node.name,
|
||
columnKey: node.columnKey,
|
||
parentId: node.parentId,
|
||
menuType: node.menuType,
|
||
routePath: node.routePath,
|
||
sortOrder: node.sortOrder,
|
||
}
|
||
}
|
||
|
||
/** 编辑时父节点防环校验:不可选自身或其子孙为父;返回可操作原因,否则 undefined。 */
|
||
export function menuParentChangeReason(node: MenuManageNode, parentId: number | null): string | undefined {
|
||
if (parentId === null || parentId === undefined) return undefined
|
||
if (parentId === node.id) return '不能选择自身作为父菜单'
|
||
const descendants = flattenMenuNodes(node.children ?? [])
|
||
if (descendants.some((item) => item.id === parentId)) return '不能选择自己的子菜单作为父菜单'
|
||
return undefined
|
||
}
|
||
|
||
/** 删除前置校验(镜像后端:仅叶子可删,避免孤儿层级);可删返回 undefined。 */
|
||
export function menuDeleteReason(node: MenuManageNode | null | undefined): string | undefined {
|
||
const children = node?.children
|
||
if (Array.isArray(children) && children.length > 0) return '请先删除该菜单下的子菜单'
|
||
return undefined
|
||
}
|
||
|
||
/** 同级拖拽/上下移:把 targetId 移动 dir 位(±1),返回新的同级 id 顺序;越界/缺失则原序。 */
|
||
export function siblingOrderAfterMove(group: number[], targetId: number, dir: number): number[] {
|
||
if (!Array.isArray(group) || group.length === 0) return []
|
||
const next = [...group]
|
||
const idx = next.indexOf(targetId)
|
||
if (idx < 0) return next
|
||
const to = idx + (dir === -1 || dir === 1 ? dir : 0)
|
||
if (to < 0 || to >= next.length) return next
|
||
const [moved] = next.splice(idx, 1)
|
||
next.splice(to, 0, moved)
|
||
return next
|
||
}
|
||
|
||
/** 拖拽分组键:同"菜单类型 + 父级"才算同级(对齐 admin.js columnSiblingGroupKey)。 */
|
||
export function menuSiblingGroupKey(node: MenuManageNode): string {
|
||
return `${node.menuType || 'app'}|${node.parentId ?? ''}`
|
||
}
|
||
|
||
/** 拖放排序:把 draggedId 插到 targetId 前/后,返回新的同级 id 顺序;任一 id 缺失或同 id 则原序。 */
|
||
export function siblingOrderAfterDrop(
|
||
group: number[],
|
||
draggedId: number,
|
||
targetId: number,
|
||
placeAfter: boolean,
|
||
): number[] {
|
||
if (!Array.isArray(group) || group.length === 0) return []
|
||
if (draggedId === targetId) return [...group]
|
||
const next = [...group]
|
||
const from = next.indexOf(draggedId)
|
||
if (from < 0) return next
|
||
const target = next.indexOf(targetId)
|
||
if (target < 0) return next
|
||
next.splice(from, 1)
|
||
const insertAt = next.indexOf(targetId) + (placeAfter ? 1 : 0)
|
||
next.splice(insertAt, 0, draggedId)
|
||
return next
|
||
}
|
||
|
||
/** 父菜单候选:仅同菜单类型的节点(对齐 admin 的类型联动父级下拉)。 */
|
||
export function menuParentCandidates(nodes: MenuManageNode[], menuType: string): MenuManageNode[] {
|
||
const type = (menuType || '').trim()
|
||
return flattenMenuNodes(nodes).filter((node) => (node.menuType || '') === type)
|
||
}
|
||
|
||
/** 同级重排请求体(对齐 admin.js persistColumnOrder:{ menu_type, ordered_ids })。 */
|
||
export function toMenuReorderRequest(
|
||
menuType: string,
|
||
orderedIds: number[],
|
||
): { menu_type: string; ordered_ids: number[] } {
|
||
return { menu_type: text(menuType) || 'app', ordered_ids: dedupeOrderIds(orderedIds) }
|
||
}
|
||
|
||
function dedupeOrderIds(ids: unknown[]): number[] {
|
||
if (!Array.isArray(ids)) return []
|
||
const set = new Set<number>()
|
||
for (const v of ids) {
|
||
const n = numberOrNull(v)
|
||
if (n !== null && n > 0) set.add(n)
|
||
}
|
||
return [...set]
|
||
}
|
||
|
||
/** 新建/编辑表单空值工厂:默认菜单类型为 admin、排序 0、根父节点。 */
|
||
export function emptyMenuManageForm(): MenuManageForm {
|
||
return { name: '', columnKey: '', parentId: null, menuType: ADMIN_MENU_TYPE, routePath: '', sortOrder: 0 }
|
||
}
|
||
|
||
/** 表单校验(镜像后端 NotBlank 文案);返回逐字段错误。权限标识/路由由后台自动生成,不参与校验。 */
|
||
export function validateMenuManageForm(form: MenuManageForm): { valid: boolean; errors: MenuManageFormErrors } {
|
||
const errors: MenuManageFormErrors = {}
|
||
if (!text(form.name)) errors.name = '菜单名称不能为空'
|
||
if (!text(form.menuType)) errors.menuType = '菜单类型不能为空'
|
||
return { valid: Object.keys(errors).length === 0, errors }
|
||
}
|
||
|
||
function toMenuManagePayload(form: MenuManageForm): MenuManagePayload {
|
||
// 权限标识(column_key)/路由(route_path) 不随表单提交:由后台自动生成(创建)或保持原值(编辑)。
|
||
return {
|
||
name: text(form.name),
|
||
parentId: typeof form.parentId === 'number' ? form.parentId : null,
|
||
menuType: text(form.menuType),
|
||
sortOrder: normalizeMenuSort(form.sortOrder),
|
||
}
|
||
}
|
||
|
||
/** 表单 → POST /api/admin/permission-menus 请求体。 */
|
||
export function toMenuCreateRequest(form: MenuManageForm): MenuManagePayload {
|
||
return toMenuManagePayload(form)
|
||
}
|
||
|
||
/** 表单 → PUT /api/admin/permission-menus/{id} 请求体。 */
|
||
export function toMenuUpdateRequest(form: MenuManageForm): MenuManagePayload {
|
||
return toMenuManagePayload(form)
|
||
}
|