feat(admin, admin-ui): 实现RuoYi风格后台菜单管理,优化媒体资源处理
新增服务端菜单校验与归一化逻辑,完善菜单相关Schema;前端实现条件渲染的菜单编辑器,支持搜索式图标选择器与父级树过滤。新增媒体URL统一处理工具修复管理端本地静态资源路径映射问题,更新全部相关文档、技术决策记录与测试用例。本次变更不影响WonderQ-MiniAPP端。
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
**/public/
|
||||
apps/*/dist/
|
||||
.pnpm-store
|
||||
.env
|
||||
|
||||
@@ -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 图标,不在图标迁移范围内。
|
||||
|
||||
## 验证
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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-form-item label="显示状态"><el-switch v-model="form.isVisible" active-text="显示" inactive-text="隐藏" /></el-form-item>
|
||||
<el-form-item label="启用状态"><el-switch v-model="form.isActive" active-text="启用" inactive-text="停用" /></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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
21
WonderQ-Admin-UI-Vue/src/lib/media-url.test.ts
Normal file
21
WonderQ-Admin-UI-Vue/src/lib/media-url.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
19
WonderQ-Admin-UI-Vue/src/lib/media-url.ts
Normal file
19
WonderQ-Admin-UI-Vue/src/lib/media-url.ts
Normal 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) ?? "");
|
||||
}
|
||||
@@ -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]> = [
|
||||
|
||||
42
WonderQ-Admin-UI-Vue/src/lib/system-menu-form.test.ts
Normal file
42
WonderQ-Admin-UI-Vue/src/lib/system-menu-form.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
61
WonderQ-Admin-UI-Vue/src/lib/system-menu-form.ts
Normal file
61
WonderQ-Admin-UI-Vue/src/lib/system-menu-form.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)); } }
|
||||
|
||||
@@ -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` 不存在时执行:
|
||||
|
||||
54
WonderQ-Admin/app/menu_rules.py
Normal file
54
WonderQ-Admin/app/menu_rules.py
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -31,7 +31,7 @@ MiniAPP 或 Public API 联调:
|
||||
| `detail-api.md` | 路线详情管理 API | 后端、管理前端 |
|
||||
| `concierge-api.md` | 管家顾问管理 API | 后端、管理前端 |
|
||||
| `public-api.md` | MiniAPP 使用的 Public API 唯一契约 | 后端、MiniAPP |
|
||||
| `decisions/` | 当前重要技术决策记录 | 全部 |
|
||||
| `decisions/` | 当前重要技术决策记录,包括菜单父子层级和编辑器约束 | 全部 |
|
||||
|
||||
## 文档边界
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
25
docs/decisions/0003-admin-menu-editor.md
Normal file
25
docs/decisions/0003-admin-menu-editor.md
Normal 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`:不使用后台菜单接口,不需要改动。
|
||||
Reference in New Issue
Block a user