feat(admin, admin-ui): 实现RuoYi风格后台菜单管理,优化媒体资源处理
新增服务端菜单校验与归一化逻辑,完善菜单相关Schema;前端实现条件渲染的菜单编辑器,支持搜索式图标选择器与父级树过滤。新增媒体URL统一处理工具修复管理端本地静态资源路径映射问题,更新全部相关文档、技术决策记录与测试用例。本次变更不影响WonderQ-MiniAPP端。
This commit is contained in:
1 parent
6245159e7c
commit
4c245f0d1f
28 files changed
+625
-36
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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]> = [
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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)); } }
|
||||
|
||||
Reference in new issue
Block a user