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

新增服务端菜单校验与归一化逻辑,完善菜单相关Schema;前端实现条件渲染的菜单编辑器,支持搜索式图标选择器与父级树过滤。新增媒体URL统一处理工具修复管理端本地静态资源路径映射问题,更新全部相关文档、技术决策记录与测试用例。本次变更不影响WonderQ-MiniAPP端。
This commit is contained in:
duanshuwen committed 2026-08-27 09:08:05 +08:00
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",
]);
});
});
+19
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) ?? "");
}
+54 -2
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]> = [
@@ -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>
+2 -1
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>
+2 -1
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>
+2 -1
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>
+11
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)); } }