feat(admin, admin-ui): 实现RuoYi风格后台菜单管理,优化媒体资源处理

新增服务端菜单校验与归一化逻辑,完善菜单相关Schema;前端实现条件渲染的菜单编辑器,支持搜索式图标选择器与父级树过滤。新增媒体URL统一处理工具修复管理端本地静态资源路径映射问题,更新全部相关文档、技术决策记录与测试用例。本次变更不影响WonderQ-MiniAPP端。
This commit is contained in:
duanshuwen
2026-08-27 09:08:05 +08:00
parent 6245159e7c
commit 4c245f0d1f
28 changed files with 625 additions and 36 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules/
dist/
**/public/
apps/*/dist/
.pnpm-store
.env

View File

@@ -13,6 +13,8 @@ yarn dev
默认通过 Vite `/api` 代理访问 `http://localhost:4000`,可使用 `VITE_API_PROXY_TARGET` 覆盖。生产构建使用 `yarn build`,静态产物由反向代理挂载到 `/admin`
管理端会将后端返回的本地兜底图片 `/assets/...` 映射到管理端静态资源 `/admin/assets/...`首页配置所需的本地兜底图片已随管理端构建提供。OSS 图片仍使用后端返回的临时可访问 URL。
## 认证约定
- Access Token 只保存在运行时内存,不写入 localStorage。
@@ -20,6 +22,7 @@ yarn dev
- 401 会自动调用 `/api/admin/auth/refresh` 并重试一次;刷新失败后返回登录页。
- 菜单和按钮权限来自 `/api/admin/system/profile`,前端只加载预注册组件。
- 管理端界面图标统一使用 `@element-plus/icons-vue`;菜单图标通过 `src/components/layout/LayoutIcon.vue` 的白名单适配器映射,未知图标回退为默认图标。
- 菜单管理按 RuoYi 风格提供目录、页面、按钮三类编辑字段;父级菜单使用服务端树,排除按钮和当前编辑分支,图标选择器支持搜索、清空和 Element Plus 图标网格。
- `ConciergeDetail.icon` 等接口字段是业务内容数据,不属于 UI 图标,不在图标迁移范围内。
## 验证

View File

@@ -3,20 +3,63 @@ import { computed, type Component } from "vue";
import {
ArrowDown,
Back as BackIcon,
Bell,
Calendar,
ChatLineRound,
CircleCheck,
CircleClose,
Clock,
Close as CloseIcon,
Collection as CollectionIcon,
CollectionTag,
Connection,
DataAnalysis,
DataBoard,
Delete,
Document,
Download,
Edit,
FullScreen as FullScreenIcon,
Folder,
FolderOpened,
Grid as GridIcon,
Guide,
Help,
House,
Key,
List,
Location,
MapLocation,
Menu as MenuIcon,
Message,
Monitor,
Moon as MoonIcon,
More as MoreIcon,
Notification,
Odometer,
Operation,
Paperclip,
Picture,
PieChart,
Plus,
Rank,
Reading,
Refresh as RefreshIcon,
Right as RightIcon,
Search as SearchIcon,
Setting as SettingIcon,
Share,
ShoppingBag,
Sunny as SunnyIcon,
Tickets,
Tools,
TrendCharts,
Upload,
User as UserIcon,
UserFilled,
View,
Warning,
ZoomIn,
} from "@element-plus/icons-vue";
import type { LayoutIconName } from "@/lib/menu-display";
@@ -46,6 +89,49 @@ const iconRegistry: Record<LayoutIconName, Component> = {
Back: BackIcon,
Right: RightIcon,
Chevron: ArrowDown,
Bell,
Calendar,
ChatLineRound,
CircleCheck,
CircleClose,
Clock,
CollectionTag,
Connection,
DataAnalysis,
DataBoard,
Delete,
Document,
Download,
Edit,
Folder,
FolderOpened,
Help,
House,
Key,
List,
Location,
MapLocation,
Message,
Monitor,
Notification,
Odometer,
Operation,
Paperclip,
Picture,
PieChart,
Plus,
Rank,
Reading,
Share,
ShoppingBag,
Tickets,
Tools,
TrendCharts,
Upload,
UserFilled,
View,
Warning,
ZoomIn,
};
const iconComponent = computed(() => iconRegistry[props.name] ?? MenuIcon);

View File

@@ -2,6 +2,7 @@
import { ref } from "vue";
import { uploadMediaAsset } from "@/api/client";
import { validateMediaFile } from "@/lib/media";
import { resolveAdminMediaUrl } from "@/lib/media-url";
const props = defineProps<{
modelValue: string;
@@ -13,6 +14,11 @@ const emit = defineEmits<{ (event: "update:modelValue", value: string): void }>(
const input = ref<HTMLInputElement | null>(null);
const uploading = ref(false);
const error = ref("");
const previewError = ref(false);
function previewUrl(value: string) {
return resolveAdminMediaUrl(value);
}
function openPicker() { input.value?.click(); }
@@ -30,6 +36,7 @@ async function upload(event: Event) {
error.value = "";
try {
const asset = await uploadMediaAsset(file, props.group);
previewError.value = false;
emit("update:modelValue", asset.url);
} catch (uploadError) {
error.value = uploadError instanceof Error ? uploadError.message : "图片上传失败,请重试";
@@ -38,7 +45,10 @@ async function upload(event: Event) {
}
}
function clear() { emit("update:modelValue", ""); }
function clear() {
previewError.value = false;
emit("update:modelValue", "");
}
</script>
<template>
@@ -46,7 +56,8 @@ function clear() { emit("update:modelValue", ""); }
<div class="single-media-heading"><strong>{{ label }}</strong><el-button type="primary" plain :disabled="disabled || uploading" :loading="uploading" @click="openPicker">{{ modelValue ? "更换图片" : "上传图片" }}</el-button></div>
<input ref="input" class="media-file-input" type="file" accept="image/jpeg,image/png,image/webp,image/gif" :disabled="disabled || uploading" @change="upload" />
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<el-image v-if="modelValue" class="single-media-preview" :src="modelValue" fit="cover" :preview-src-list="[modelValue]" />
<el-image v-if="modelValue" class="single-media-preview" :src="previewUrl(modelValue)" fit="cover" :preview-src-list="[previewUrl(modelValue) ?? modelValue]" @error="previewError = true" />
<el-alert v-if="previewError" title="图片加载失败,请重新上传" type="warning" :closable="false" show-icon />
<el-empty v-else :image-size="48" description="暂无图片" />
<el-button v-if="modelValue" link type="danger" :disabled="disabled || uploading" @click="clear">移除图片</el-button>
</div>

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from "vue";
import LayoutIcon from "@/components/layout/LayoutIcon.vue";
import { getMenuIconName, MENU_ICON_OPTIONS, type LayoutIconName } from "@/lib/menu-display";
import { buildMenuParentTree, getMenuEditorFields, normalizeMenuEditorInput } from "@/lib/system-menu-form";
import { buildSystemTree } from "@/lib/system-resource";
import type {
SystemDeptItem,
@@ -82,9 +85,19 @@ const form = reactive<FormState>(blankForm());
const formRef = ref<{ validate: () => Promise<boolean> } | null>(null);
const roleMenuTreeRef = ref<{ setCheckedKeys: (keys: string[]) => void } | null>(null);
const roleDeptTreeRef = ref<{ setCheckedKeys: (keys: string[]) => void } | null>(null);
const menuIconPopover = ref<{ hide: () => void } | null>(null);
const menuIconSearch = ref("");
const title = computed(() => `${props.mode === "create" ? "新增" : "编辑"}${({ users: "用户", roles: "角色", menus: "菜单", depts: "部门" } as Record<SystemResourceName, string>)[props.resource]}`);
const menuTree = computed(() => buildSystemTree(props.menus));
const menuParentTree = computed(() => buildMenuParentTree(props.menus, props.resource === "menus" && props.mode === "edit" ? props.item?.id : undefined));
const deptTree = computed(() => buildSystemTree(props.depts.filter((item) => item.id !== props.item?.id)));
const menuFields = computed(() => getMenuEditorFields(form.type));
const menuIconPreview = computed(() => getMenuIconName({ path: null, component: null, icon: form.icon }));
const filteredMenuIconOptions = computed(() => {
const keyword = menuIconSearch.value.trim().toLowerCase();
if (!keyword) return MENU_ICON_OPTIONS;
return MENU_ICON_OPTIONS.filter((icon) => icon.toLowerCase().includes(keyword));
});
const formRules = computed(() => {
const rules: Record<string, Array<{ required: boolean; message: string; trigger: string }>> = {
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
@@ -94,6 +107,11 @@ const formRules = computed(() => {
rules.password = props.mode === "create" ? [{ required: true, message: "请输入登录密码", trigger: "blur" }] : [];
}
if (props.resource === "roles") rules.code = [{ required: true, message: "请输入角色编码", trigger: "blur" }];
if (props.resource === "menus") {
if (menuFields.value.path) rules.path = [{ required: true, message: "请输入路由地址", trigger: "blur" }];
if (menuFields.value.component) rules.componentKey = [{ required: true, message: "请输入组件路径", trigger: "blur" }];
if (menuFields.value.permission) rules.permissionCode = [{ required: true, message: "请输入权限标识", trigger: "blur" }];
}
return rules;
});
@@ -143,6 +161,27 @@ function selectAllRoleDepts() {
roleDeptTreeRef.value?.setCheckedKeys(form.deptIds);
}
function handleMenuTypeChange(type: SystemMenuItem["type"]) {
if (type === "button") {
form.path = "";
form.componentKey = "";
form.icon = "";
} else if (type === "directory") {
form.componentKey = "";
form.permissionCode = "";
}
}
function selectMenuIcon(icon: LayoutIconName) {
form.icon = icon;
menuIconSearch.value = "";
menuIconPopover.value?.hide();
}
function clearMenuIcon() {
form.icon = "";
}
watch(() => [props.item, props.resource, props.mode, props.initialParentId, props.menus, props.depts], async () => {
syncForm(props.item);
await nextTick();
@@ -157,10 +196,18 @@ async function submit() {
if (props.resource === "users" && !String(input.password || "").trim()) delete input.password;
}
if (props.resource === "menus") {
input.componentKey = form.componentKey || null;
input.permissionCode = form.permissionCode || null;
input.path = form.path || null;
input.parentId = form.parentId || null;
Object.assign(input, normalizeMenuEditorInput({
parentId: form.parentId,
name: form.name,
type: form.type,
path: form.path,
componentKey: form.componentKey,
permissionCode: form.permissionCode,
icon: form.icon,
sortOrder: form.sortOrder,
isVisible: form.isVisible,
isActive: form.isActive,
}));
}
if (props.resource === "depts") input.parentId = form.parentId || null;
if (props.resource === "roles") input.description = form.description || null;
@@ -201,16 +248,68 @@ async function submit() {
</template>
<template v-else-if="resource === 'menus'">
<el-form-item label="菜单名称" required><el-input v-model="form.name" maxlength="80" /></el-form-item>
<el-form-item label="菜单类型" required><el-radio-group v-model="form.type"><el-radio value="directory">目录</el-radio><el-radio value="page">页面</el-radio><el-radio value="button">按钮</el-radio></el-radio-group></el-form-item>
<el-form-item label="父级菜单"><el-tree-select v-model="form.parentId" :data="menuTree.filter((menu) => menu.type !== 'button')" node-key="id" check-strictly clearable filterable class="full-width" :props="{ label: 'name', children: 'children' }" placeholder="选择上级菜单" /></el-form-item>
<el-form-item label="路由地址"><el-input v-model="form.path" placeholder="/example" /></el-form-item>
<el-form-item label="组件路径"><el-input v-model="form.componentKey" placeholder="Dashboard" /></el-form-item>
<el-form-item label="权限标识"><el-input v-model="form.permissionCode" placeholder="system:example:list" /></el-form-item>
<el-form-item label="菜单图标"><el-input v-model="form.icon" placeholder="House" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" /></el-form-item>
<el-row :gutter="24">
<el-col :span="24">
<el-form-item label="上级菜单">
<el-tree-select v-model="form.parentId" :data="menuParentTree" node-key="id" check-strictly clearable filterable class="full-width" :props="{ value: 'id', label: 'name', children: 'children' }" placeholder="选择上级菜单(不选为顶级菜单)" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="菜单类型" prop="type" required>
<el-radio-group v-model="form.type" @change="handleMenuTypeChange">
<el-radio value="directory">目录</el-radio><el-radio value="page">页面</el-radio><el-radio value="button">按钮</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="menuFields.icon" :span="12">
<el-form-item label="菜单图标">
<el-popover ref="menuIconPopover" placement="bottom-start" :width="360" trigger="click">
<template #reference>
<el-input :model-value="form.icon" placeholder="点击选择图标" readonly>
<template #prefix><LayoutIcon :name="form.icon ? menuIconPreview : 'Search'" :size="16" /></template>
<template #suffix>
<el-button v-if="form.icon" link type="info" aria-label="清空菜单图标" @click.stop="clearMenuIcon">
<LayoutIcon name="Close" :size="14" />
</el-button>
</template>
</el-input>
</template>
<div class="menu-icon-picker" role="listbox" aria-label="菜单图标">
<el-input v-model="menuIconSearch" clearable placeholder="请输入图标名称" class="menu-icon-picker__search">
<template #prefix><LayoutIcon name="Search" :size="14" /></template>
</el-input>
<div class="menu-icon-picker__grid">
<button v-for="icon in filteredMenuIconOptions" :key="icon" type="button" class="menu-icon-picker__option" :class="{ 'is-active': form.icon === icon }" :aria-label="`选择${icon}图标`" @click="selectMenuIcon(icon)">
<LayoutIcon :name="icon" :size="18" /><span>{{ icon }}</span>
</button>
</div>
<el-empty v-if="!filteredMenuIconOptions.length" :image-size="36" description="没有匹配的图标" />
</div>
</el-popover>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="显示排序" prop="sortOrder" required><el-input-number v-model="form.sortOrder" :min="0" controls-position="right" class="full-width" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="菜单名称" prop="name" required><el-input v-model="form.name" maxlength="80" placeholder="请输入菜单名称" /></el-form-item>
</el-col>
<el-col v-if="menuFields.path" :span="12">
<el-form-item label="路由地址" prop="path" required><el-input v-model="form.path" placeholder="请输入路由地址,如 /system/users" /></el-form-item>
</el-col>
<el-col v-if="menuFields.component" :span="12">
<el-form-item label="组件路径" prop="componentKey" required><el-input v-model="form.componentKey" placeholder="请输入预注册组件名,如 SystemUsers" /></el-form-item>
</el-col>
<el-col v-if="menuFields.permission" :span="12">
<el-form-item label="权限标识" prop="permissionCode" required><el-input v-model="form.permissionCode" maxlength="120" placeholder="请输入权限标识,如 system:user:list" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="显示状态"><el-switch v-model="form.isVisible" active-text="显示" inactive-text="隐藏" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="启用状态"><el-switch v-model="form.isActive" active-text="启用" inactive-text="停用" /></el-form-item>
</el-col>
</el-row>
</template>
<template v-else>

View File

@@ -2,6 +2,7 @@
import { ref } from "vue";
import { ElMessage } from "element-plus";
import { uploadMediaAsset } from "@/api/client";
import { resolveAdminMediaUrl, resolveAdminMediaUrls } from "@/lib/media-url";
const props = defineProps<{ modelValue: string[]; disabled: boolean }>();
const emit = defineEmits<{ (event: "update:modelValue", value: string[]): void }>();
@@ -55,7 +56,7 @@ function move(index: number, direction: -1 | 1) {
<el-empty v-if="!modelValue.length" :image-size="56" description="暂无详情图片" />
<div v-else class="gallery-grid">
<div v-for="(image, index) in modelValue" :key="`${image}-${index}`" class="gallery-item">
<el-image :src="image" fit="cover" :preview-src-list="modelValue" />
<el-image :src="resolveAdminMediaUrl(image)" fit="cover" :preview-src-list="resolveAdminMediaUrls(modelValue)" />
<small>{{ index + 1 }}. {{ image }}</small>
<div class="gallery-actions"><el-button link size="small" :disabled="disabled || uploading || index === 0" @click="move(index, -1)">上移</el-button><el-button link size="small" :disabled="disabled || uploading || index === modelValue.length - 1" @click="move(index, 1)">下移</el-button><el-button link size="small" type="danger" :disabled="disabled || uploading" @click="remove(index)">移除</el-button></div>
</div>

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { resolveAdminMediaUrl, resolveAdminMediaUrls } from "@/lib/media-url";
describe("resolveAdminMediaUrl", () => {
it("将公共本地资源挂载到管理端 base 下", () => {
expect(resolveAdminMediaUrl("/assets/guizhou/wanfenglin.jpg", "/admin/")).toBe("/admin/assets/guizhou/wanfenglin.jpg");
});
it("不修改 OSS 地址并兼容 Markdown 自动链接文本", () => {
const url = "https://example.com/image.webp?Expires=123";
expect(resolveAdminMediaUrl(url, "/admin/")).toBe(url);
expect(resolveAdminMediaUrl(`[图片](${url})`, "/admin/")).toBe(url);
});
it("批量转换详情图片地址", () => {
expect(resolveAdminMediaUrls(["/assets/a.jpg", "https://example.com/b.jpg"], "/admin/")).toEqual([
"/admin/assets/a.jpg",
"https://example.com/b.jpg",
]);
});
});

View File

@@ -0,0 +1,19 @@
/**
* 将后端返回的媒体地址转换为管理端可访问的预览地址。
*
* Public API 的本地兜底图片使用根路径 `/assets/...`,而管理端静态资源
* 挂载在 `/admin/` 下。OSS 或其他 HTTP(S) 地址保持原样。
*/
export function resolveAdminMediaUrl(value: string | null | undefined, baseUrl = import.meta.env.BASE_URL): string | undefined {
if (!value) return value ?? undefined;
const markdownUrl = value.match(/^\[[^\]]*\]\((https?:\/\/[^)]+)\)$/)?.[1];
const url = markdownUrl ?? value;
if (!url.startsWith("/assets/") || baseUrl === "/") return url;
return `${baseUrl.replace(/\/$/, "")}${url}`;
}
export function resolveAdminMediaUrls(values: string[], baseUrl = import.meta.env.BASE_URL): string[] {
return values.map((value) => resolveAdminMediaUrl(value, baseUrl) ?? "");
}

View File

@@ -21,11 +21,63 @@ export type LayoutIconName =
| "More"
| "Back"
| "Right"
| "Chevron";
| "Chevron"
| "Bell"
| "Calendar"
| "ChatLineRound"
| "CircleCheck"
| "CircleClose"
| "Clock"
| "CollectionTag"
| "Connection"
| "DataAnalysis"
| "DataBoard"
| "Delete"
| "Document"
| "Download"
| "Edit"
| "Folder"
| "FolderOpened"
| "Help"
| "House"
| "Key"
| "List"
| "Location"
| "MapLocation"
| "Message"
| "Monitor"
| "Notification"
| "Odometer"
| "Operation"
| "Paperclip"
| "Picture"
| "PieChart"
| "Plus"
| "Rank"
| "Reading"
| "Share"
| "ShoppingBag"
| "Tickets"
| "Tools"
| "TrendCharts"
| "UserFilled"
| "Upload"
| "View"
| "Warning"
| "ZoomIn";
export const MENU_ICON_OPTIONS: readonly LayoutIconName[] = [
"Menu", "Grid", "Collection", "Route", "House", "Folder", "FolderOpened", "Document",
"List", "Operation", "Setting", "Search", "User", "UserFilled", "Bell", "Notification",
"Calendar", "Clock", "Location", "MapLocation", "Picture", "Paperclip", "ChatLineRound", "Message",
"Connection", "DataBoard", "DataAnalysis", "TrendCharts", "PieChart", "Reading", "Tickets", "ShoppingBag",
"Tools", "Key", "Monitor", "View", "Share", "Rank", "Edit", "Delete", "Download", "Upload", "Warning",
"Help", "CircleCheck", "CircleClose", "Plus", "Refresh", "FullScreen", "Moon", "Sunny",
];
const supportedIconNames = new Set<LayoutIconName>([
"Menu", "Grid", "Route", "User", "Collection", "Setting", "Search", "FullScreen",
"Moon", "Sunny", "Refresh", "Close", "More", "Back", "Right", "Chevron",
"Moon", "Sunny", "Refresh", "Close", "More", "Back", "Right", "Chevron", ...MENU_ICON_OPTIONS,
]);
const pathIconMap: Array<[string, LayoutIconName]> = [

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { buildMenuParentTree, getMenuEditorFields, normalizeMenuEditorInput } from "./system-menu-form";
import type { SystemMenuItem } from "@/types";
describe("system menu editor", () => {
it("shows only fields supported by each menu type", () => {
expect(getMenuEditorFields("directory")).toEqual({ path: true, component: false, permission: false, icon: true });
expect(getMenuEditorFields("page")).toEqual({ path: true, component: true, permission: true, icon: true });
expect(getMenuEditorFields("button")).toEqual({ path: false, component: false, permission: true, icon: false });
});
it("clears route-only values when saving a button menu", () => {
expect(normalizeMenuEditorInput({
type: "button",
path: "/users",
componentKey: "SystemUsers",
permissionCode: "system:user:update",
icon: "User",
})).toMatchObject({
path: null,
componentKey: null,
permissionCode: "system:user:update",
icon: null,
});
});
it("does not offer buttons or the edited branch as a parent", () => {
const menus = [
{ id: "root", name: "系统管理", type: "directory", parentId: null, sortOrder: 0 },
{ id: "users", name: "用户管理", type: "page", parentId: "root", sortOrder: 1 },
{ id: "user-edit", name: "编辑用户", type: "button", parentId: "users", sortOrder: 1 },
{ id: "roles", name: "角色管理", type: "page", parentId: "root", sortOrder: 2 },
] as SystemMenuItem[];
const parentTree = buildMenuParentTree(menus, "root");
expect(parentTree.map((item) => item.id)).toEqual([]);
const editParentTree = buildMenuParentTree(menus, "users");
expect(editParentTree[0].id).toBe("root");
expect(editParentTree[0].children?.map((item) => item.id)).toEqual(["roles"]);
});
});

View File

@@ -0,0 +1,61 @@
import { buildSystemTree, type SystemTreeNode } from "@/lib/system-resource";
import type { SystemMenuItem } from "@/types";
export type MenuEditorFields = {
path: boolean;
component: boolean;
permission: boolean;
icon: boolean;
};
export function getMenuEditorFields(type: SystemMenuItem["type"]): MenuEditorFields {
return {
path: type !== "button",
component: type === "page",
permission: type !== "directory",
icon: type !== "button",
};
}
export function normalizeMenuEditorInput(input: Record<string, unknown>): Record<string, unknown> {
const normalized = { ...input };
for (const field of ["name", "parentId", "path", "componentKey", "permissionCode", "icon"]) {
if (typeof normalized[field] === "string") normalized[field] = normalized[field].trim() || null;
}
if (normalized.type === "button") {
normalized.path = null;
normalized.componentKey = null;
normalized.icon = null;
} else if (normalized.type === "directory") {
normalized.componentKey = null;
normalized.permissionCode = null;
}
normalized.parentId = normalized.parentId || null;
return normalized;
}
function withoutMenuBranch(nodes: Array<SystemTreeNode<SystemMenuItem>>, excludedIds: Set<string>): Array<SystemTreeNode<SystemMenuItem>> {
return nodes.reduce<Array<SystemTreeNode<SystemMenuItem>>>((result, node) => {
if (excludedIds.has(node.id) || node.type === "button") return result;
result.push({ ...node, children: withoutMenuBranch(node.children, excludedIds) });
return result;
}, []);
}
export function buildMenuParentTree(menus: SystemMenuItem[], currentId?: string): Array<SystemTreeNode<SystemMenuItem>> {
const excludedIds = new Set<string>();
if (currentId) {
const childrenByParent = new Map<string, string[]>();
for (const menu of menus) {
if (menu.parentId) childrenByParent.set(menu.parentId, [...(childrenByParent.get(menu.parentId) ?? []), menu.id]);
}
const pending = [currentId];
while (pending.length) {
const id = pending.pop()!;
if (excludedIds.has(id)) continue;
excludedIds.add(id);
pending.push(...(childrenByParent.get(id) ?? []));
}
}
return withoutMenuBranch(buildSystemTree(menus), excludedIds);
}

View File

@@ -9,6 +9,7 @@ import {
updateConciergeAdvisor,
} from "@/api/client";
import ConciergeAdvisorEditor from "@/components/concierge/ConciergeAdvisorEditor.vue";
import { resolveAdminMediaUrl } from "@/lib/media-url";
import { useAuthStore } from "@/stores/auth";
import type { ConciergeAdvisor, ConciergeAdvisorCreate } from "@/types";
@@ -114,7 +115,7 @@ onMounted(load);
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false"><template #default><el-button link type="primary" @click="load">重试</el-button></template></el-alert>
<el-skeleton v-if="loading" :rows="7" animated />
<el-empty v-else-if="!advisors.length" description="暂无管家顾问"><el-button v-permission="'admin:concierge:read'" type="primary" @click="openCreate">创建第一位顾问</el-button></el-empty>
<el-card v-else shadow="never" class="panel-card"><div class="concierge-list"><div v-for="(advisor, index) in advisors" :key="advisor.id" class="concierge-row"><div class="concierge-summary"><el-avatar :size="48" :src="advisor.avatar">{{ advisor.name.slice(0, 1) }}</el-avatar><div><strong>{{ advisor.name }}</strong><span>{{ advisor.role }} · {{ advisor.details?.length ?? 0 }} 条服务详情</span></div></div><div class="concierge-actions"><el-tag :type="advisor.isActive ? 'success' : 'info'" effect="plain">{{ advisor.isActive ? "启用" : "停用" }}</el-tag><el-button v-permission="'admin:concierge:read'" link :disabled="index === 0 || Boolean(savingKey)" @click="move(advisor, -1)">上移</el-button><el-button v-permission="'admin:concierge:read'" link :disabled="index === advisors.length - 1 || Boolean(savingKey)" @click="move(advisor, 1)">下移</el-button><el-button v-permission="'admin:concierge:read'" link type="primary" @click="openEdit(advisor)">编辑</el-button><el-button v-permission="'admin:concierge:read'" link type="danger" :disabled="Boolean(savingKey)" @click="remove(advisor)">删除</el-button></div></div></div></el-card>
<el-card v-else shadow="never" class="panel-card"><div class="concierge-list"><div v-for="(advisor, index) in advisors" :key="advisor.id" class="concierge-row"><div class="concierge-summary"><el-avatar :size="48" :src="resolveAdminMediaUrl(advisor.avatar)">{{ advisor.name.slice(0, 1) }}</el-avatar><div><strong>{{ advisor.name }}</strong><span>{{ advisor.role }} · {{ advisor.details?.length ?? 0 }} 条服务详情</span></div></div><div class="concierge-actions"><el-tag :type="advisor.isActive ? 'success' : 'info'" effect="plain">{{ advisor.isActive ? "启用" : "停用" }}</el-tag><el-button v-permission="'admin:concierge:read'" link :disabled="index === 0 || Boolean(savingKey)" @click="move(advisor, -1)">上移</el-button><el-button v-permission="'admin:concierge:read'" link :disabled="index === advisors.length - 1 || Boolean(savingKey)" @click="move(advisor, 1)">下移</el-button><el-button v-permission="'admin:concierge:read'" link type="primary" @click="openEdit(advisor)">编辑</el-button><el-button v-permission="'admin:concierge:read'" link type="danger" :disabled="Boolean(savingKey)" @click="remove(advisor)">删除</el-button></div></div></div></el-card>
<el-drawer v-model="editorOpen" size="min(720px, 100%)" :with-header="false" :show-close="false" :close-on-press-escape="!savingKey" @close="closeEditor"><ConciergeAdvisorEditor v-if="editorOpen" :advisor="editing" :editing="Boolean(editing)" :saving="savingKey === 'advisor'" @save="save" @cancel="closeEditor" /></el-drawer>
</div>
</template>

View File

@@ -28,6 +28,7 @@ import {
import HomeContentEditor, { type HomeContentDraft, type HomeContentKind } from "@/components/home/HomeContentEditor.vue";
import SiteConfigEditor from "@/components/site-config/SiteConfigEditor.vue";
import { compactSiteItemPayload, createEmptySiteItemDraft, getSiteModuleDefinition, siteItemDraft, validateSiteItemPayload } from "@/lib/site-config";
import { resolveAdminMediaUrl } from "@/lib/media-url";
import { useAuthStore } from "@/stores/auth";
import type { HomeTeamBuilding, HomeWanfaRecommendation, HomeWildArchive, SiteConfigItem, SiteItemPatch, WanfaCategory } from "@/types";
@@ -240,7 +241,7 @@ onMounted(loadConfig);
<div class="panel-heading"><div><strong>{{ isSiteModule ? activeDefinition.label : ({ recommendation: "玩法推荐", team: "团队共创", wild: "极境视界" } as Record<HomeContentKind, string>)[activeHomeKind] }}</strong><span>{{ activeItems.length }} 条配置</span></div><el-button v-permission="'admin:site-config'" type="primary" plain @click="startCreate">新增配置</el-button></div>
<el-empty v-if="!activeItems.length" description="暂无配置记录"><el-button v-permission="'admin:site-config'" type="primary" @click="startCreate">创建第一条配置</el-button></el-empty>
<el-table v-else :data="activeItems" stripe>
<el-table-column label="内容" min-width="300"><template #default="{ row }"><div class="site-item-content"><el-image v-if="itemImage(row)" class="site-item-thumb" :src="itemImage(row) ?? undefined" fit="cover" /><div><strong>{{ itemTitle(row) }}</strong><small>{{ itemDescription(row) }}</small></div></div></template></el-table-column>
<el-table-column label="内容" min-width="300"><template #default="{ row }"><div class="site-item-content"><el-image v-if="itemImage(row)" class="site-item-thumb" :src="resolveAdminMediaUrl(itemImage(row))" fit="cover" /><div><strong>{{ itemTitle(row) }}</strong><small>{{ itemDescription(row) }}</small></div></div></template></el-table-column>
<el-table-column v-if="activeModule === 'recommendation'" prop="routeCount" label="路线数" width="90" />
<el-table-column v-if="activeModule !== 'recommendation'" prop="sortOrder" label="排序" width="80" />
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.isActive ? 'success' : 'info'" effect="plain">{{ row.isActive ? "启用中" : "已停用" }}</el-tag></template></el-table-column>

View File

@@ -3,6 +3,7 @@ import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { getMediaAssets, uploadMediaAsset } from "@/api/client";
import { formatMediaSize, validateMediaFile } from "@/lib/media";
import { resolveAdminMediaUrl } from "@/lib/media-url";
import type { MediaAsset } from "@/types";
const assets = ref<MediaAsset[]>([]);
@@ -60,6 +61,6 @@ onMounted(load);
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false"><template #default><el-button link type="primary" @click="load">重试</el-button></template></el-alert>
<el-alert v-if="uploadError" :title="uploadError" type="error" show-icon :closable="false" />
<el-skeleton v-if="loading" :rows="8" animated />
<el-card v-else shadow="never" class="panel-card"><el-empty v-if="!assets.length" description="暂无媒体素材"><el-button v-permission="'admin:media:read'" type="primary" :loading="uploading" @click="openPicker">上传第一张图片</el-button></el-empty><div v-else class="media-grid"><article v-for="asset in assets" :key="asset.id" class="media-card"><el-image :src="asset.url" fit="cover" :preview-src-list="[asset.url]" /><div class="media-card-content"><strong>{{ asset.name || "未命名素材" }}</strong><span>{{ asset.group || "general" }} · {{ formatMediaSize(asset.sizeBytes) }}</span><small>{{ asset.url }}</small></div></article></div></el-card>
<el-card v-else shadow="never" class="panel-card"><el-empty v-if="!assets.length" description="暂无媒体素材"><el-button v-permission="'admin:media:read'" type="primary" :loading="uploading" @click="openPicker">上传第一张图片</el-button></el-empty><div v-else class="media-grid"><article v-for="asset in assets" :key="asset.id" class="media-card"><el-image :src="resolveAdminMediaUrl(asset.url)" fit="cover" :preview-src-list="[resolveAdminMediaUrl(asset.url) ?? asset.url]" /><div class="media-card-content"><strong>{{ asset.name || "未命名素材" }}</strong><span>{{ asset.group || "general" }} · {{ formatMediaSize(asset.sizeBytes) }}</span><small>{{ asset.url }}</small></div></article></div></el-card>
</div>
</template>

View File

@@ -20,6 +20,7 @@ import {
import WanfaCategoryEditor from "@/components/wanfa/WanfaCategoryEditor.vue";
import WanfaRouteEditor from "@/components/wanfa/WanfaRouteEditor.vue";
import { useAuthStore } from "@/stores/auth";
import { resolveAdminMediaUrl } from "@/lib/media-url";
import type { ConciergeAdvisor, DetailCreate, DetailRecord, WanfaCategory, WanfaRoute, WanfaRouteCreate } from "@/types";
const auth = useAuthStore();
@@ -268,7 +269,7 @@ onMounted(loadData);
<el-empty v-if="!selectedCategory" description="请选择玩法分类" />
<el-empty v-else-if="!selectedCategory.routes.length" description="该分类暂无路线"><el-button v-permission="'admin:wanfa:read'" type="primary" @click="openRouteCreate">创建第一条路线</el-button></el-empty>
<el-table v-else :data="selectedCategory.routes" stripe>
<el-table-column label="路线" min-width="240"><template #default="{ row }"><div class="site-item-content"><el-image v-if="row.image" class="site-item-thumb" :src="row.image" fit="cover" /><div><strong>{{ row.title }}</strong><small>{{ row.subtitle }}</small></div></div></template></el-table-column>
<el-table-column label="路线" min-width="240"><template #default="{ row }"><div class="site-item-content"><el-image v-if="row.image" class="site-item-thumb" :src="resolveAdminMediaUrl(row.image)" fit="cover" /><div><strong>{{ row.title }}</strong><small>{{ row.subtitle }}</small></div></div></template></el-table-column>
<el-table-column prop="demandKeyword" label="需求关键词" width="130" />
<el-table-column prop="routeCount" label="数量" width="80" />
<el-table-column label="详情" width="90"><template #default="{ row }"><el-tag :type="details.some((detail) => detail.key === row.id) ? 'success' : 'warning'" effect="plain">{{ details.some((detail) => detail.key === row.id) ? "已配置" : "待配置" }}</el-tag></template></el-table-column>

View File

@@ -166,12 +166,23 @@ button, input { font: inherit; }
.system-tree-panel .el-tree { max-height: 260px; overflow: auto; padding: 8px 12px; }
.system-tree-panel .el-tree-node__content { height: 32px; }
.system-editor-form .el-radio-group { display: flex; gap: 18px; }
.menu-icon-picker { display: flex; flex-direction: column; gap: 8px; }
.menu-icon-picker__search { width: 100%; }
.menu-icon-picker__grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; max-height: 300px; overflow-y: auto; padding-right: 2px; }
.menu-icon-picker__option { display: inline-flex; min-height: 58px; align-items: center; justify-content: center; flex-direction: column; gap: 4px; border: 1px solid transparent; border-radius: 4px; color: #606266; background: transparent; cursor: pointer; font-family: inherit; font-size: 11px; }
.menu-icon-picker__option:hover, .menu-icon-picker__option:focus-visible, .menu-icon-picker__option.is-active { color: var(--primary-color, #409eff); border-color: #b3d8ff; background: #ecf5ff; outline: none; }
.menu-icon-picker .el-empty { padding: 8px 0; }
.system-editor-form .el-input-number { width: 100%; }
@media (max-width: 760px) {
.system-table-heading { align-items: flex-start; flex-direction: column; }
.system-action-bar { justify-content: flex-start; width: 100%; }
.system-filter-card .el-form-item:last-child { margin-left: 0; }
.system-filter-card .el-input { width: min(230px, 100%); }
}
@media (max-width: 640px) {
.system-editor-form .el-row { row-gap: 0; }
.system-editor-form .el-col-12 { width: 100%; max-width: 100%; flex: 0 0 100%; }
}
@media (max-width: 880px) { .wanfa-workspace { grid-template-columns: 1fr; } }
@media (max-width: 980px) { .media-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .lead-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 760px) { .concierge-row { align-items: flex-start; flex-direction: column; } .concierge-actions { justify-content: flex-start; } .lead-filters { grid-template-columns: 1fr 1fr; } .lead-filters .el-input { grid-column: 1 / -1; } .operations-grid { grid-template-columns: 1fr; } .media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }

View File

@@ -24,6 +24,8 @@ Copy-Item .env.example .env
- `ADMIN_ACCESS_EXPIRES_MINUTES``ADMIN_REFRESH_EXPIRES_DAYS`:管理员访问令牌和刷新令牌有效期。
- `OSS_ACCESS_KEY_ID``OSS_ACCESS_KEY_SECRET``OSS_ENDPOINT``OSS_BUCKET_NAME`:填写实际 OSS 配置;真实密钥只放在 `.env` 或部署平台密钥中,不提交到 Git。
菜单管理接口 `GET/POST/PATCH/DELETE /api/admin/system/menus` 维护目录、页面和按钮的父子树。服务端校验父级存在、禁止按钮作为父级、禁止循环归属和重复权限标识;具体字段规则以 [`docs/admin-api-requirements.md`](../docs/admin-api-requirements.md) 为准。
### 2. 创建并启用 Python 虚拟环境
首次启动或 `.venv` 不存在时执行:

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from typing import Any
MENU_FIELDS = (
"parentId",
"name",
"type",
"path",
"componentKey",
"permissionCode",
"icon",
"sortOrder",
"isVisible",
"isActive",
)
def _clean_optional(value: Any) -> Any:
if isinstance(value, str):
return value.strip() or None
return value
def normalize_menu_values(values: dict[str, Any], *, partial: bool = False) -> dict[str, Any]:
"""Normalize menu form values and enforce type-specific fields."""
normalized = dict(values)
for field in ("parentId", "name", "path", "componentKey", "permissionCode", "icon"):
if field in normalized:
normalized[field] = _clean_optional(normalized[field])
if "name" in normalized and not normalized["name"]:
raise ValueError("菜单名称不能为空")
if partial or "type" not in normalized:
return normalized
menu_type = normalized["type"]
if menu_type == "button":
if not normalized.get("permissionCode"):
raise ValueError("按钮菜单必须填写权限标识")
if normalized.get("path") or normalized.get("componentKey"):
raise ValueError("按钮菜单不能填写路由地址或组件路径")
normalized["icon"] = None
else:
if not normalized.get("path"):
raise ValueError("目录或页面必须填写路由地址")
if menu_type == "page" and not normalized.get("componentKey"):
raise ValueError("页面菜单必须填写组件路径")
if menu_type == "directory":
normalized["permissionCode"] = None
return normalized

View File

@@ -22,6 +22,7 @@ def build_menu_tree(menus: Iterable[AdminMenu], visible_only: bool = True) -> li
nodes: dict[str, dict[str, Any]] = {
menu.id: {
"id": menu.id,
"parentId": menu.parentId,
"name": menu.name,
"type": menu.type,
"path": menu.path,
@@ -30,6 +31,8 @@ def build_menu_tree(menus: Iterable[AdminMenu], visible_only: bool = True) -> li
"icon": menu.icon,
"children": [],
"sortOrder": menu.sortOrder,
"isVisible": menu.isVisible,
"isActive": menu.isActive,
}
for menu in active_menus
}

View File

@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from ..auth import get_actor_id, hash_password, require_admin
from ..database import get_db
from ..menu_rules import MENU_FIELDS, normalize_menu_values
from ..redis_session import AdminSessionStore, RedisUnavailableError, get_admin_session_store
from ..models import (
AdminDepartment,
@@ -87,6 +88,43 @@ def _ensure_ids_exist(db: Session, model, ids: list[str], label: str) -> None:
raise HTTPException(status_code=400, detail={"msg": f"{label}不存在", "errorCode": "REFERENCE_NOT_FOUND", "details": {"ids": missing}})
def _ensure_menu_parent(db: Session, menu_id: str | None, parent_id: str | None) -> None:
if not parent_id:
return
parent = db.get(AdminMenu, parent_id)
if not parent:
raise HTTPException(status_code=400, detail="父级菜单不存在")
if parent.type == "button":
raise HTTPException(status_code=400, detail="按钮菜单不能作为父级菜单")
seen: set[str] = set()
current: AdminMenu | None = parent
while current:
if current.id == menu_id:
raise HTTPException(status_code=400, detail="菜单不能移动到自己的子菜单下")
if current.id in seen:
break
seen.add(current.id)
current = db.get(AdminMenu, current.parentId) if current.parentId else None
def _normalized_menu_or_422(values: dict) -> dict:
try:
return normalize_menu_values(values)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
def _ensure_permission_available(db: Session, permission_code: str | None, menu_id: str | None = None) -> None:
if not permission_code:
return
stmt = select(AdminMenu.id).where(AdminMenu.permissionCode == permission_code)
if menu_id:
stmt = stmt.where(AdminMenu.id != menu_id)
if db.scalar(stmt):
raise HTTPException(status_code=409, detail="权限标识已存在")
def _invalidate_permissions(store: AdminSessionStore, user_ids: list[str] | None = None) -> None:
try:
store.invalidate_permission_cache(user_ids)
@@ -335,10 +373,10 @@ def delete_role(
@router.post("/menus", status_code=status.HTTP_201_CREATED)
def create_menu(body: AdminMenuCreate, request: Request, _user: AdminUser = Depends(require_permission("system:menu:create")), db: Session = Depends(get_db)):
if body.parentId and not db.get(AdminMenu, body.parentId):
raise HTTPException(status_code=400, detail="父级菜单不存在")
menu = AdminMenu(**body.model_dump())
menu.componentKey = body.componentKey
values = _normalized_menu_or_422(body.model_dump())
_ensure_menu_parent(db, None, values["parentId"])
_ensure_permission_available(db, values["permissionCode"])
menu = AdminMenu(**values)
db.add(menu)
db.flush()
_audit(db, request, "create", "admin_menu", menu.id, after=_menu_dict(menu))
@@ -351,13 +389,13 @@ def update_menu(menu_id: str, body: AdminMenuPatch, request: Request, _user: Adm
menu = db.get(AdminMenu, menu_id)
if not menu:
raise HTTPException(status_code=404, detail="菜单不存在")
if body.parentId == menu.id:
raise HTTPException(status_code=400, detail="菜单不能将自身设置为父级")
for field, value in body.model_dump(exclude_unset=True).items():
if field == "componentKey":
setattr(menu, "componentKey", value)
else:
setattr(menu, field, value)
values = {field: getattr(menu, field) for field in MENU_FIELDS}
values.update(body.model_dump(exclude_unset=True))
values = _normalized_menu_or_422(values)
_ensure_menu_parent(db, menu.id, values["parentId"])
_ensure_permission_available(db, values["permissionCode"], menu.id)
for field in MENU_FIELDS:
setattr(menu, field, values[field])
db.flush()
_audit(db, request, "update", "admin_menu", menu.id, after=_menu_dict(menu))
db.commit()

View File

@@ -3,6 +3,8 @@ from typing import Literal
from urllib.parse import urlsplit
from pydantic import BaseModel, EmailStr, Field, ValidationInfo, field_validator, model_validator
from .menu_rules import normalize_menu_values
LeadStatus = Literal["new", "assigned", "contacted", "planning", "won", "invalid"]
LeadType = Literal["general", "vehicle"]
@@ -662,6 +664,13 @@ class AdminMenuCreate(BaseModel):
isVisible: bool = True
isActive: bool = True
@model_validator(mode="after")
def validate_menu_fields(self):
normalized = normalize_menu_values(self.model_dump())
for field, value in normalized.items():
setattr(self, field, value)
return self
class AdminMenuPatch(BaseModel):
parentId: str | None = None
@@ -675,6 +684,13 @@ class AdminMenuPatch(BaseModel):
isVisible: bool | None = None
isActive: bool | None = None
@model_validator(mode="after")
def normalize_menu_fields(self):
normalized = normalize_menu_values(self.model_dump(exclude_unset=True), partial=True)
for field, value in normalized.items():
setattr(self, field, value)
return self
class AdminDepartmentCreate(BaseModel):
parentId: str | None = None

View File

@@ -28,7 +28,7 @@ def media_model_dict(instance) -> dict:
def resolve_media_fields(data: dict) -> dict:
result = dict(data)
for field in ("image", "avatar", "qrImage"):
for field in ("image", "avatar", "qrImage", "url"):
if field in result:
result[field] = resolve_media_url(result[field])
for field in ("gallery", "images"):

View File

@@ -302,6 +302,8 @@ def test_dynamic_menu_tree_preserves_parent_order_and_button_permissions():
assert DATA_SCOPE_VALUES == {"all", "dept", "dept_and_children", "custom_dept", "self"}
assert [item["id"] for item in tree] == ["root"]
assert tree[0]["children"][0]["permission"] == "home:edit"
assert tree[0]["isVisible"] is True
assert tree[0]["isActive"] is True
def test_dynamic_menu_tree_keeps_granted_menu_when_parent_is_not_in_scope():

View File

@@ -68,6 +68,7 @@ def test_resolve_media_fields_signs_common_image_fields(monkeypatch):
"image": "oss-image",
"avatar": "oss-avatar",
"qrImage": "oss-qr",
"url": "oss-url",
"gallery": ["oss-gallery-1", "oss-gallery-2"],
"label": "unchanged",
}
@@ -77,6 +78,7 @@ def test_resolve_media_fields_signs_common_image_fields(monkeypatch):
"image": "signed:oss-image",
"avatar": "signed:oss-avatar",
"qrImage": "signed:oss-qr",
"url": "signed:oss-url",
"gallery": ["signed:oss-gallery-1", "signed:oss-gallery-2"],
"label": "unchanged",
}

View File

@@ -1,8 +1,12 @@
import json
from datetime import datetime
import pytest
from pydantic import ValidationError
from app.models import AdminDepartment, AdminMenu, AdminRole
from app.routers.system import _dept_dict, _menu_dict, _role_dict
from app.schemas import AdminMenuCreate
def test_role_audit_snapshot_serializes_datetime_fields():
@@ -42,3 +46,21 @@ def test_menu_and_department_audit_snapshots_serialize_datetime_fields():
json.dumps(_menu_dict(menu))
json.dumps(_dept_dict(dept))
def test_menu_create_validates_fields_by_menu_type():
with pytest.raises(ValidationError):
AdminMenuCreate(name=" ", type="page", path="/users", componentKey="SystemUsers")
with pytest.raises(ValidationError):
AdminMenuCreate(name="按钮", type="button")
with pytest.raises(ValidationError):
AdminMenuCreate(name="页面", type="page", path="/users")
button = AdminMenuCreate(name="编辑用户", type="button", permissionCode="system:user:update")
assert button.path is None
assert button.componentKey is None
page = AdminMenuCreate(name="用户管理", type="page", path="/users", componentKey="SystemUsers")
assert page.path == "/users"

View File

@@ -31,7 +31,7 @@ MiniAPP 或 Public API 联调:
| `detail-api.md` | 路线详情管理 API | 后端、管理前端 |
| `concierge-api.md` | 管家顾问管理 API | 后端、管理前端 |
| `public-api.md` | MiniAPP 使用的 Public API 唯一契约 | 后端、MiniAPP |
| `decisions/` | 当前重要技术决策记录 | 全部 |
| `decisions/` | 当前重要技术决策记录,包括菜单父子层级和编辑器约束 | 全部 |
## 文档边界

View File

@@ -180,6 +180,20 @@ type SiteModule =
后端先按当前管理员角色计算可见菜单,再过滤停用菜单与不可见目录/页面;按钮菜单作为页面节点的 `children` 返回,但不会被前端注册为页面路由。`profile.menus` 继续保留以兼容旧管理端,`routers` 才是 `WonderQ-Admin-UI-Vue` 动态导航和动态路由注册的权威来源。前端只能将 `component` 映射到预注册组件白名单,未知组件不得执行或加载。
### 菜单管理新增/编辑
`GET /api/admin/system/menus` 返回平铺 `items` 和按 `parentId` 组装的 `tree`。每个节点包含 `parentId``name``type``path``component``permission``icon``sortOrder``isVisible``isActive``children`,用于菜单工作台、角色授权树和新增/编辑菜单的父级选择。
`POST /api/admin/system/menus``PATCH /api/admin/system/menus/{id}` 使用以下菜单类型规则:
- `directory`(目录):必须有路由地址;不使用组件路径和权限标识。
- `page`(页面):必须有路由地址和预注册组件路径;可填写权限标识。
- `button`(按钮):必须填写权限标识;路由地址、组件路径和菜单图标必须为空,按钮不能作为其他菜单的父级。
顶级菜单的 `parentId``null`。父级必须存在且为目录或页面;更新菜单时不能选择自身或其子孙节点,服务端也会重复校验该规则。名称和文本字段会去除首尾空格;权限标识重复返回 `409`,类型字段缺失或不符合规则返回 `422`。菜单新增、编辑、删除和排序继续写入审计日志并清理权限菜单缓存。
Vue 管理端的父级树只展示目录/页面,编辑当前菜单时排除当前分支;图标选择器使用 `@element-plus/icons-vue` 的受控白名单,支持名称搜索、清空和未知值回退默认图标,不执行服务端返回的组件或图标路径。
角色数据范围使用以下五个编码:`all`(全部)、`dept`(当前部门)、`dept_and_children`(当前部门及子部门)、`custom_dept`(自定义部门)、`self`(本人)。运营资源通过 `deptId``createdById` 归属字段执行查询过滤。
登录按 IP 与账号组合执行 Redis 限流,默认 60 秒最多 5 次;权限菜单缓存默认 300 秒。Redis 故障不能放行权限检查,缓存不可用时只能重新读取数据库,认证会话和限流不可用时返回 `503`

View File

@@ -0,0 +1,25 @@
# 0003 后台菜单父子树与 RuoYi 风格编辑器
## 状态
已采纳。
## 决策
WonderQ 菜单继续使用服务端 `AdminMenu.parentId` 自引用关系,并由 `GET /api/admin/system/menus` 同时提供平铺数据和树形数据。管理端新增/编辑菜单按 RuoYi 的目录、页面、按钮模式展示条件字段:目录只维护路由,页面维护路由、预注册组件和权限标识,按钮只维护权限标识。
父级菜单只能选择目录或页面;编辑时前端从选择树中移除当前分支,后端再次校验父级存在、父级类型和循环归属。权限标识冲突返回 `409`,非法类型字段返回 `422`,避免仅依赖前端校验造成脏数据。
菜单图标使用 `@element-plus/icons-vue` 的静态白名单映射。编辑器提供可搜索图标网格和清空操作;服务端或历史数据中的未知图标统一回退默认图标,不能动态执行任意组件或图标路径。
## 原因
- 保留 RuoYi 用户熟悉的菜单层级、条件字段和操作流程。
- 用服务端树约束保证角色授权、动态路由和菜单编辑看到同一套父子关系。
- 将图标输入限制在 Element Plus 组件白名单,降低动态组件注入和版本漂移风险。
## 影响范围
- `WonderQ-Admin`:菜单请求 schema、父子关系/类型/权限冲突校验、动态菜单树字段。
- `WonderQ-Admin-UI-Vue`:菜单编辑器条件字段、父级树和可搜索图标选择器。
- `WonderQ-MiniAPP`:不使用后台菜单接口,不需要改动。