feat(theme): 皮肤包管理收敛平台级+命名表单-新顶层路由/theme-manage(meta.superOnly守卫+页面watch双兜底),ThemeManage升级全屏页复用新抽WorkbenchHeader(工作台同款深色顶栏backTo=/workbench),工作台超管工具行加皮肤包管理按钮,删SiteManage兜底按钮,旧/theme重定向新页;上传/添加皮肤包改表单弹窗(zip+显示名/描述;添加=源包+标识名+命名一步),内置包可改显示名/描述(无参数区+恢复默认名称);openEdit未注册主题保留原值并告警(themeEdit util,不再静默归一default);mock删modId=7菜单节点;SiteWizard预览内容源v-model对齐path语义(W4遗留);vitest 62/62+build通过
build / build (push) Successful in 2m17s
build / build (push) Successful in 2m17s
This commit is contained in:
+1
-1
@@ -479,7 +479,7 @@ async function handleApi(req: IncomingMessage, res: ServerResponse, path: string
|
|||||||
mod(2106, 4, "友情链接", "/link"),
|
mod(2106, 4, "友情链接", "/link"),
|
||||||
mod(2107, 5, "附件管理", "/attachment"),
|
mod(2107, 5, "附件管理", "/attachment"),
|
||||||
mod(2108, 6, "站点管理", "/site"),
|
mod(2108, 6, "站点管理", "/site"),
|
||||||
mod(2109, 7, "皮肤包管理", "/theme"),
|
// 「皮肤包管理」已收敛为平台级页面 /theme-manage(超管工作台按钮进入),不再挂左侧菜单
|
||||||
]));
|
]));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveEditableTheme } from "@/utils/themeEdit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务三(2026-09-08):站点编辑弹窗主题归一纯函数。
|
||||||
|
* 旧行为把「不在皮肤包列表中的主题」静默归一 default,保存即悄悄改写站点换肤;
|
||||||
|
* 新行为保留原值交服务端拒绝,仅标记告警。
|
||||||
|
*/
|
||||||
|
describe("resolveEditableTheme 站点编辑主题归一(任务三)", () => {
|
||||||
|
it("注册中的主题原值保留且不告警", () => {
|
||||||
|
expect(resolveEditableTheme("mint-breeze", ["default", "mint-breeze"])).toEqual({
|
||||||
|
theme: "mint-breeze",
|
||||||
|
unregistered: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未注册主题保留原值并标记 unregistered(不再归一 default)", () => {
|
||||||
|
expect(resolveEditableTheme("ghost-theme", ["default", "mint-breeze"])).toEqual({
|
||||||
|
theme: "ghost-theme",
|
||||||
|
unregistered: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空主题归一 default(站点未选过皮肤的场景)", () => {
|
||||||
|
expect(resolveEditableTheme(undefined, ["default", "mint-breeze"])).toEqual({
|
||||||
|
theme: "default",
|
||||||
|
unregistered: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("列表为空(加载失败)时保留原值且不误报,交服务端校验兜底", () => {
|
||||||
|
expect(resolveEditableTheme("mint-breeze", [])).toEqual({
|
||||||
|
theme: "mint-breeze",
|
||||||
|
unregistered: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
/**
|
/**
|
||||||
* W1 皮肤包管理页组件行为(方案 §5.3 T-W1-22..24):
|
* W1 皮肤包管理页组件行为(方案 §5.3 T-W1-22..24 + 任务二/四 2026-09-08):
|
||||||
* ① 内置卡无删除/编辑按钮(仅"预览");
|
* ① 内置卡按钮为"预览+编辑"(任务四:内置包可改显示名/描述);
|
||||||
* ② 被用外部包删除禁用且 tooltip 含站点名;
|
* ② 被用外部包删除禁用且 tooltip 含站点名;
|
||||||
* ③ 上传成功刷新列表(list 被再次调用)+ Message.success。
|
* ③ 上传改为表单弹窗(zip + 命名),提交走手动上传通道;
|
||||||
|
* ④ 内置编辑弹窗无参数区、保存不带 params(后端强校验拒改);
|
||||||
|
* ⑤ 添加皮肤包表单标识名为空时拦截。
|
||||||
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 categoryWorkbench.spec.ts 一致)。
|
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 categoryWorkbench.spec.ts 一致)。
|
||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -11,26 +13,30 @@ import { createApp, defineComponent, h, nextTick } from "vue";
|
|||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
import ViewUIPlus, { Message } from "view-ui-plus";
|
import ViewUIPlus, { Message } from "view-ui-plus";
|
||||||
|
|
||||||
const { listMock, uploadMock, removeMock } = vi.hoisted(() => ({
|
const { listMock, uploadMock, removeMock, deriveMock, updateMetaMock } = vi.hoisted(() => ({
|
||||||
listMock: vi.fn(),
|
listMock: vi.fn(),
|
||||||
uploadMock: vi.fn(),
|
uploadMock: vi.fn(),
|
||||||
removeMock: vi.fn(),
|
removeMock: vi.fn(),
|
||||||
|
deriveMock: vi.fn(),
|
||||||
|
updateMetaMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const themes = [
|
const themes = [
|
||||||
{
|
{
|
||||||
name: "default",
|
name: "default",
|
||||||
title: "云端默认",
|
title: "云端默认",
|
||||||
|
description: "",
|
||||||
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||||
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
|
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
|
||||||
source: "builtin",
|
source: "builtin",
|
||||||
inUseCount: 2,
|
inUseCount: 2,
|
||||||
editable: false,
|
editable: true,
|
||||||
usedBySiteNames: ["第一中学门户", "机电学院"],
|
usedBySiteNames: ["第一中学门户", "机电学院"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "my-skin",
|
name: "my-skin",
|
||||||
title: "我的皮肤",
|
title: "我的皮肤",
|
||||||
|
description: "",
|
||||||
screenshotUrl: "/api/theme/my-skin/asset/screenshot.png",
|
screenshotUrl: "/api/theme/my-skin/asset/screenshot.png",
|
||||||
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#b01a2e" }],
|
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#b01a2e" }],
|
||||||
source: "external",
|
source: "external",
|
||||||
@@ -41,6 +47,7 @@ const themes = [
|
|||||||
{
|
{
|
||||||
name: "free-skin",
|
name: "free-skin",
|
||||||
title: "空闲皮肤",
|
title: "空闲皮肤",
|
||||||
|
description: "",
|
||||||
screenshotUrl: "/api/theme/free-skin/asset/screenshot.png",
|
screenshotUrl: "/api/theme/free-skin/asset/screenshot.png",
|
||||||
params: [],
|
params: [],
|
||||||
source: "external",
|
source: "external",
|
||||||
@@ -53,16 +60,22 @@ const themes = [
|
|||||||
vi.mock("@/api/module/cms", () => ({
|
vi.mock("@/api/module/cms", () => ({
|
||||||
themeApi: {
|
themeApi: {
|
||||||
list: () => ({ send: listMock }),
|
list: () => ({ send: listMock }),
|
||||||
upload: (file: File) => {
|
upload: (file: File, meta?: unknown) => {
|
||||||
uploadMock(file);
|
uploadMock(file, meta);
|
||||||
return { send: async () => ({}) };
|
return { send: async () => ({}) };
|
||||||
},
|
},
|
||||||
remove: (name: string) => {
|
remove: (name: string) => {
|
||||||
removeMock(name);
|
removeMock(name);
|
||||||
return { send: async () => ({}) };
|
return { send: async () => ({}) };
|
||||||
},
|
},
|
||||||
derive: () => ({ send: async () => ({}) }),
|
derive: (source: string, name: string, meta?: unknown) => {
|
||||||
updateMeta: () => ({ send: async () => ({}) }),
|
deriveMock(source, name, meta);
|
||||||
|
return { send: async () => ({}) };
|
||||||
|
},
|
||||||
|
updateMeta: (name: string, data: unknown) => {
|
||||||
|
updateMetaMock(name, data);
|
||||||
|
return { send: async () => ({}) };
|
||||||
|
},
|
||||||
reload: () => ({ send: async () => ({}) }),
|
reload: () => ({ send: async () => ({}) }),
|
||||||
},
|
},
|
||||||
siteApi: { list: () => ({ send: async () => [] }) },
|
siteApi: { list: () => ({ send: async () => [] }) },
|
||||||
@@ -101,6 +114,17 @@ function mountPage() {
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clickButtonByText(root: ParentNode, text: string): HTMLElement | undefined {
|
||||||
|
const btn = [...root.querySelectorAll("button")].find(b => b.textContent?.includes(text));
|
||||||
|
btn?.click();
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页面有多个 Modal 常驻 DOM,按特征文案定位目标弹窗 */
|
||||||
|
function modalContaining(text: string): HTMLElement | undefined {
|
||||||
|
return [...document.querySelectorAll(".ivu-modal")].find(m => m.textContent?.includes(text));
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
listMock.mockResolvedValue(JSON.parse(JSON.stringify(themes)));
|
listMock.mockResolvedValue(JSON.parse(JSON.stringify(themes)));
|
||||||
@@ -111,8 +135,8 @@ afterEach(() => {
|
|||||||
cleanups = [];
|
cleanups = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ThemeManage 皮肤包管理(T-W1-22..24)", () => {
|
describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
|
||||||
it("T-W1-22: 内置卡无删除/编辑按钮(仅'预览')", async () => {
|
it("T-W1-22: 内置卡按钮为'预览+编辑'(任务四起内置可改元数据),无删除", async () => {
|
||||||
const el = mountPage();
|
const el = mountPage();
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
@@ -121,7 +145,7 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24)", () => {
|
|||||||
const builtin = cards[0];
|
const builtin = cards[0];
|
||||||
expect(builtin.textContent).toContain("内置");
|
expect(builtin.textContent).toContain("内置");
|
||||||
const btns = [...builtin.querySelectorAll(".actions button")].map(b => b.textContent?.trim());
|
const btns = [...builtin.querySelectorAll(".actions button")].map(b => b.textContent?.trim());
|
||||||
expect(btns).toEqual(["预览"]);
|
expect(btns).toEqual(["预览", "编辑"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("T-W1-23: 被用外部包删除禁用且 tooltip 含站点名;空闲外部包可删", async () => {
|
it("T-W1-23: 被用外部包删除禁用且 tooltip 含站点名;空闲外部包可删", async () => {
|
||||||
@@ -144,31 +168,76 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24)", () => {
|
|||||||
expect(freeDelete!.getAttribute("disabled")).toBeNull();
|
expect(freeDelete!.getAttribute("disabled")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("T-W1-24: 上传成功刷新列表(list 再次调用)+ Message.success", async () => {
|
it("T-W1-24: 上传表单弹窗——未选文件时上传禁用;选文件提交调用 upload 并刷新列表", async () => {
|
||||||
const el = mountPage();
|
const el = mountPage();
|
||||||
await flush();
|
await flush();
|
||||||
expect(listMock).toHaveBeenCalledTimes(1);
|
expect(listMock).toHaveBeenCalledTimes(1);
|
||||||
const successSpy = vi.spyOn(Message, "success");
|
const successSpy = vi.spyOn(Message, "success");
|
||||||
|
|
||||||
// Upload 组件经 before-upload 手动上传:直接调内部通道(beforeUpload 闭包不可达,
|
clickButtonByText(el, "上传皮肤包");
|
||||||
// 经组件 props 不可得 → 以 uploadMock + list 重查语义等价驱动:触发 Upload 的 change?
|
await flush();
|
||||||
// 简化:直接验证上传成功路径——页面暴露 beforeUpload 经 Upload 组件,stub 层以事件驱动)
|
|
||||||
const file = new File(["zip"], "pkg.zip", { type: "application/zip" });
|
|
||||||
// 通过 Upload 的 before-upload 触发:dispatch 到上传按钮内部 input 不稳定,
|
|
||||||
// 这里改为验证「上传成功 → Message.success + list 重查」的最小可观测路径:
|
|
||||||
(el as any).__themeUpload = (f: File) => f; // 占位,防误用
|
|
||||||
const uploadBtn = [...el.querySelectorAll(".toolbar button")].find(b => b.textContent?.includes("上传皮肤包"));
|
|
||||||
expect(uploadBtn).toBeTruthy();
|
|
||||||
|
|
||||||
// 直接调用组件内通道的等价方式:Upload before-upload 经 ivu Upload 的 input change
|
// 弹窗渲染到 body:未选文件时上传按钮禁用
|
||||||
const input = el.querySelector<HTMLInputElement>('input[type="file"]');
|
const uploadModal = modalContaining("皮肤包 zip");
|
||||||
|
expect(uploadModal).toBeTruthy();
|
||||||
|
const submit = [...uploadModal!.querySelectorAll(".ivu-modal-footer button")].find(b =>
|
||||||
|
b.textContent?.includes("上传")
|
||||||
|
);
|
||||||
|
expect(submit).toBeTruthy();
|
||||||
|
expect(submit!.getAttribute("disabled")).not.toBeNull();
|
||||||
|
|
||||||
|
// 选择文件(Upload before-upload 通道)→ 按钮解锁 → 提交
|
||||||
|
const input = uploadModal!.querySelector<HTMLInputElement>('input[type="file"]');
|
||||||
expect(input).toBeTruthy();
|
expect(input).toBeTruthy();
|
||||||
|
const file = new File(["zip"], "pkg.zip", { type: "application/zip" });
|
||||||
Object.defineProperty(input!, "files", { value: [file] });
|
Object.defineProperty(input!, "files", { value: [file] });
|
||||||
input!.dispatchEvent(new Event("change"));
|
input!.dispatchEvent(new Event("change"));
|
||||||
await flush();
|
await flush();
|
||||||
|
expect(document.body.textContent).toContain("已选择:pkg.zip");
|
||||||
|
|
||||||
expect(uploadMock).toHaveBeenCalled();
|
submit!.click();
|
||||||
|
await flush();
|
||||||
|
expect(uploadMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(uploadMock.mock.calls[0][0]).toBe(file);
|
||||||
expect(successSpy).toHaveBeenCalled();
|
expect(successSpy).toHaveBeenCalled();
|
||||||
expect(listMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
expect(listMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("任务四: 内置包编辑弹窗无参数区,保存调用 updateMeta 不带 params", async () => {
|
||||||
|
const el = mountPage();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const builtin = [...el.querySelectorAll(".card")][0];
|
||||||
|
clickButtonByText(builtin, "编辑");
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// 内置提示条存在、无 ColorPicker(参数 schema 区不渲染)
|
||||||
|
const metaModal = modalContaining("内置包内容(模板/资产)不可修改");
|
||||||
|
expect(metaModal).toBeTruthy();
|
||||||
|
expect(document.body.textContent).toContain("内置包内容(模板/资产)不可修改");
|
||||||
|
expect(metaModal!.querySelectorAll(".ivu-color-picker").length).toBe(0);
|
||||||
|
|
||||||
|
clickButtonByText(metaModal!, "保存");
|
||||||
|
await flush();
|
||||||
|
expect(updateMetaMock).toHaveBeenCalledWith(
|
||||||
|
"default",
|
||||||
|
expect.objectContaining({ title: "云端默认", params: undefined })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("任务四: 添加皮肤包表单——标识名为空被拦截,不调用 derive", async () => {
|
||||||
|
const el = mountPage();
|
||||||
|
await flush();
|
||||||
|
const warnSpy = vi.spyOn(Message, "warning");
|
||||||
|
|
||||||
|
clickButtonByText(el, "添加皮肤包");
|
||||||
|
await flush();
|
||||||
|
const addModal = modalContaining("复制自");
|
||||||
|
expect(addModal).toBeTruthy();
|
||||||
|
clickButtonByText(addModal!, "添加");
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
expect(deriveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-7
@@ -213,20 +213,26 @@ class ThemeApi {
|
|||||||
return get<ThemeVo[]>(`${THEME}/list`);
|
return get<ThemeVo[]>(`${THEME}/list`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** W1:上传合规 zip(multipart,超管;免重启立即注册) */
|
/** W1:上传合规 zip(multipart,超管;免重启立即注册)。
|
||||||
upload(file: File) {
|
* 任务四:meta.title/description 选填,落位前写入包内 theme.json(留空沿用包内声明) */
|
||||||
|
upload(file: File, meta?: { title?: string; description?: string }) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
|
if (meta?.title) fd.append("title", meta.title);
|
||||||
|
if (meta?.description !== undefined && meta.description !== null) {
|
||||||
|
fd.append("description", meta.description);
|
||||||
|
}
|
||||||
return alovaInstance.Post<ThemeVo>(`${THEME}/upload`, fd);
|
return alovaInstance.Post<ThemeVo>(`${THEME}/upload`, fd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** W1:从现有包派生新皮肤(复制包体+改名) */
|
/** W1:从现有包派生新皮肤(复制包体+改名);任务四支持同步命名(title/description 选填) */
|
||||||
derive(source: string, name: string) {
|
derive(source: string, name: string, meta?: { title?: string; description?: string }) {
|
||||||
return postJSON<ThemeVo>(`${THEME}/derive`, { source, name });
|
return postJSON<ThemeVo>(`${THEME}/derive`, { source, name, ...meta });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** W1:编辑外部包 theme.json 的 title/参数 schema */
|
/** W1 + 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema),
|
||||||
updateMeta(name: string, data: { title?: string; params?: unknown[] }) {
|
* 内置包显示名/描述走 .meta 覆盖层(title/description 全空=恢复默认) */
|
||||||
|
updateMeta(name: string, data: { title?: string; description?: string; params?: unknown[] }) {
|
||||||
return alovaInstance.Put<ThemeVo>(`${THEME}/${name}/meta`, data, {
|
return alovaInstance.Put<ThemeVo>(`${THEME}/${name}/meta`, data, {
|
||||||
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useCmsStore } from "@/store";
|
||||||
|
import config from "@/assets/config/host.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作台同款深色顶栏(超管平台级页面共用):
|
||||||
|
* 标题 + 可选「返回」图标 + 头像/姓名(用户中心)+ 退出登录。
|
||||||
|
* 使用方:SchoolWorkbench(无返回)、ThemeManage(backTo=/workbench)。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string;
|
||||||
|
/** 传入则渲染返回图标按钮(如返回工作台) */
|
||||||
|
backTo?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useCmsStore();
|
||||||
|
|
||||||
|
const userInfo = computed(() => ({
|
||||||
|
name: store.session.baseUserName || "管理员",
|
||||||
|
avatar: store.session.baseUserAvatar,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (props.backTo) {
|
||||||
|
router.push(props.backTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const linkUserInfo = () => {
|
||||||
|
window.location.href = config.baseHost + "/v/userinfo";
|
||||||
|
};
|
||||||
|
const logout = () => {
|
||||||
|
const url = "/cas/logout?service=" + window.location.href;
|
||||||
|
window.location.href = config.ssoHost + url;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="wb-header">
|
||||||
|
<h4>{{ title }}</h4>
|
||||||
|
<div class="wb-header-aside">
|
||||||
|
<a v-if="backTo" href="javascript:void(0);" class="back" title="返回工作台" @click="goBack">
|
||||||
|
<Icon type="ios-arrow-back" :size="20" />
|
||||||
|
</a>
|
||||||
|
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="wb-avatar" @click="linkUserInfo" />
|
||||||
|
<a class="name" href="javascript:void(0);" @click="linkUserInfo">{{ userInfo.name }}</a>
|
||||||
|
<span class="delimiter"></span>
|
||||||
|
<a href="javascript:;" class="logout" @click="logout" title="退出登陆">
|
||||||
|
<i class="iconfont icon-logout"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.wb-header {
|
||||||
|
height: 56px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #1f2a3a;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
h4 {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.wb-header-aside {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
.name {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
color: #fff;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.delimiter {
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.wb-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.logout {
|
||||||
|
color: #fff;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+6
-3
@@ -8,17 +8,20 @@ const router = createRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学校上下文守卫(docs/09 §3.3):
|
* 路由守卫(docs/09 §3.3 + 任务二超管收敛):
|
||||||
|
* - meta.superOnly 页面仅超管可入(皮肤包管理),非超管回工作台;启动引导期(user 未装载)放行由页面兜底;
|
||||||
* - /sch/:schCode 要求该主站编码在当前用户可管理站点内;
|
* - /sch/:schCode 要求该主站编码在当前用户可管理站点内;
|
||||||
* - 深链直达且上下文未对准时,按 user.sites 自动恢复学校/站点上下文;
|
* - 深链直达且上下文未对准时,按 user.sites 自动恢复学校/站点上下文;
|
||||||
* - 启动引导期(App.vue 未装配 user)放行,由 SchoolLayout 数据就绪后兜底校验;
|
|
||||||
* - 校级用户不可管理的学校一律回工作台。
|
* - 校级用户不可管理的学校一律回工作台。
|
||||||
*/
|
*/
|
||||||
router.beforeEach(to => {
|
router.beforeEach(to => {
|
||||||
|
const store = useCmsStore();
|
||||||
|
if (to.meta.superOnly && store.user && !store.isSuperAdmin) {
|
||||||
|
return "/workbench";
|
||||||
|
}
|
||||||
if (!to.path.startsWith("/sch/")) {
|
if (!to.path.startsWith("/sch/")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const store = useCmsStore();
|
|
||||||
const schCode = to.params.schCode as string;
|
const schCode = to.params.schCode as string;
|
||||||
if (!schCode) {
|
if (!schCode) {
|
||||||
return "/workbench";
|
return "/workbench";
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/cms/SchoolWorkbench.vue"),
|
component: () => import("@/views/cms/SchoolWorkbench.vue"),
|
||||||
meta: { title: "学校工作台" },
|
meta: { title: "学校工作台" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 平台级:皮肤包管理(任务二收敛:仅超管,工作台按钮进入,不挂左侧菜单)
|
||||||
|
path: "/theme-manage",
|
||||||
|
name: "ThemePackageManage",
|
||||||
|
component: () => import("@/views/cms/ThemeManage.vue"),
|
||||||
|
meta: { title: "皮肤包管理", superOnly: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// 二级:学校专属管理壳,:schCode = 主站编码(URL 即学校上下文)
|
// 二级:学校专属管理壳,:schCode = 主站编码(URL 即学校上下文)
|
||||||
// (懒加载引入,避免 routes → SchoolLayout → menuRoute → routes 循环依赖)
|
// (懒加载引入,避免 routes → SchoolLayout → menuRoute → routes 循环依赖)
|
||||||
@@ -99,13 +106,6 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/cms/SiteManage.vue"),
|
component: () => import("@/views/cms/SiteManage.vue"),
|
||||||
meta: { title: "站点管理", modId: 6 },
|
meta: { title: "站点管理", modId: 6 },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// W1:皮肤包管理(平台模块表注册前由站点管理按钮兜底可达,R3)
|
|
||||||
path: "theme",
|
|
||||||
name: "ThemeManage",
|
|
||||||
component: () => import("@/views/cms/ThemeManage.vue"),
|
|
||||||
meta: { title: "皮肤包管理", modId: 7 },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
// 门户预览(SPA 内嵌简版门户;真实门户由后端 Thymeleaf 提供)
|
// 门户预览(SPA 内嵌简版门户;真实门户由后端 Thymeleaf 提供)
|
||||||
path: "portal-preview",
|
path: "portal-preview",
|
||||||
@@ -123,7 +123,7 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
{ path: "/link", redirect: legacyRedirect("link") },
|
{ path: "/link", redirect: legacyRedirect("link") },
|
||||||
{ path: "/attachment", redirect: legacyRedirect("attachment") },
|
{ path: "/attachment", redirect: legacyRedirect("attachment") },
|
||||||
{ path: "/site", redirect: legacyRedirect("site") },
|
{ path: "/site", redirect: legacyRedirect("site") },
|
||||||
{ path: "/theme", redirect: legacyRedirect("theme") },
|
{ path: "/theme", redirect: "/theme-manage" },
|
||||||
{ path: "/portal-preview", redirect: legacyRedirect("portal-preview") },
|
{ path: "/portal-preview", redirect: legacyRedirect("portal-preview") },
|
||||||
{
|
{
|
||||||
path: "/:pathMatch(.*)*",
|
path: "/:pathMatch(.*)*",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 站点编辑弹窗的主题归一(任务三,2026-09-08):
|
||||||
|
* 旧行为把「不在皮肤包列表中的主题」静默归一为 default,一保存就悄悄改写站点的换肤结果
|
||||||
|
* (皮肤包列表加载失败、或站点现用外部包未注册时必现,表现为"改了没变/被改回默认")。
|
||||||
|
* 新行为:保留原值交服务端注册表校验(保存未注册主题会被明确拒绝),仅提示告警。
|
||||||
|
*/
|
||||||
|
export interface EditableTheme {
|
||||||
|
/** 编辑表单主题值(未注册时保留原值,不再偷偷归一 default) */
|
||||||
|
theme: string;
|
||||||
|
/** 主题未在注册列表中(保存将被服务端拒绝,需提示用户改选或先上传) */
|
||||||
|
unregistered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEditableTheme(rowTheme: string | undefined, themeNames: string[]): EditableTheme {
|
||||||
|
const value = rowTheme || "default";
|
||||||
|
if (!themeNames.length) {
|
||||||
|
// 列表加载失败/为空:无法判定注册状态,保留原值且不误报(由服务端校验兜底)
|
||||||
|
return { theme: value, unregistered: false };
|
||||||
|
}
|
||||||
|
return { theme: value, unregistered: !themeNames.includes(value) };
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import config from "@/assets/config/host.config";
|
|||||||
import { SchoolCard, SchoolOption, ThemeVo } from "@/types/cms";
|
import { SchoolCard, SchoolOption, ThemeVo } from "@/types/cms";
|
||||||
import { portalUrlOf } from "@/utils/sitePath";
|
import { portalUrlOf } from "@/utils/sitePath";
|
||||||
import ThemePicker from "@/components/theme/ThemePicker.vue";
|
import ThemePicker from "@/components/theme/ThemePicker.vue";
|
||||||
|
import WorkbenchHeader from "@/components/WorkbenchHeader.vue";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学校工作台(一级页,docs/09 §3.3 方案A):全屏无壳。
|
* 学校工作台(一级页,docs/09 §3.3 方案A):全屏无壳。
|
||||||
@@ -23,10 +24,6 @@ const loading = ref(false);
|
|||||||
const themes = ref<ThemeVo[]>([]);
|
const themes = ref<ThemeVo[]>([]);
|
||||||
|
|
||||||
const isSuper = computed(() => store.isSuperAdmin);
|
const isSuper = computed(() => store.isSuperAdmin);
|
||||||
const userInfo = computed(() => ({
|
|
||||||
name: store.session.baseUserName || "管理员",
|
|
||||||
avatar: store.session.baseUserAvatar,
|
|
||||||
}));
|
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -74,6 +71,11 @@ function goAllSites() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 超管平台级皮肤包管理(不再挂左侧菜单,工作台直达) */
|
||||||
|
function goThemeManage() {
|
||||||
|
router.push("/theme-manage");
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== 添加学校(超管):绑定已有学校 + 初始化主站 ===== */
|
/* ===== 添加学校(超管):绑定已有学校 + 初始化主站 ===== */
|
||||||
const modalVisible = ref(false);
|
const modalVisible = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
@@ -131,7 +133,9 @@ function onSchoolPick(id: number) {
|
|||||||
form.value.schName = opt?.name || "";
|
form.value.schName = opt?.name || "";
|
||||||
if (opt) {
|
if (opt) {
|
||||||
form.value.name = `${opt.name}门户网站`;
|
form.value.name = `${opt.name}门户网站`;
|
||||||
form.value.code = `sch-${opt.id}`;
|
// R1 学校主站编码规则:1 + 学校ID左补零至4位(总长5位,如 id=13 → 10013);
|
||||||
|
// id≥10000 时 LPAD 不截断、编码自然超5位(当前学校量级不会触达)
|
||||||
|
form.value.code = `1${String(opt.id).padStart(4, "0")}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,30 +163,12 @@ async function create() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const linkUserInfo = () => {
|
|
||||||
window.location.href = config.baseHost + "/v/userinfo";
|
|
||||||
};
|
|
||||||
const logout = () => {
|
|
||||||
const url = "/cas/logout?service=" + window.location.href;
|
|
||||||
window.location.href = config.ssoHost + url;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(run);
|
onMounted(run);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="workbench">
|
<div class="workbench">
|
||||||
<header class="wb-header">
|
<WorkbenchHeader title="云海CMS · 学校工作台" />
|
||||||
<h4>云海CMS · 学校工作台</h4>
|
|
||||||
<div class="wb-header-aside">
|
|
||||||
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="wb-avatar" @click="linkUserInfo" />
|
|
||||||
<a class="name" href="javascript:void(0);" @click="linkUserInfo">{{ userInfo.name }}</a>
|
|
||||||
<span class="delimiter"></span>
|
|
||||||
<a href="javascript:;" class="logout" @click="logout" title="退出登陆">
|
|
||||||
<i class="iconfont icon-logout"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- 操作工具行(Notice search-wrap 同款灰底条):按钮居左,账号区保持在深色 header 右侧 -->
|
<!-- 操作工具行(Notice search-wrap 同款灰底条):按钮居左,账号区保持在深色 header 右侧 -->
|
||||||
<div class="wb-toolbar" v-if="isSuper">
|
<div class="wb-toolbar" v-if="isSuper">
|
||||||
@@ -190,6 +176,10 @@ onMounted(run);
|
|||||||
<Icon type="ios-globe" />
|
<Icon type="ios-globe" />
|
||||||
全部站点总览
|
全部站点总览
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button class="wb-btn" @click="goThemeManage">
|
||||||
|
<Icon type="ios-brush" />
|
||||||
|
皮肤包管理
|
||||||
|
</Button>
|
||||||
<Button type="primary" class="wb-btn" @click="openAdd">
|
<Button type="primary" class="wb-btn" @click="openAdd">
|
||||||
<Icon type="ios-add" />
|
<Icon type="ios-add" />
|
||||||
添加学校
|
添加学校
|
||||||
@@ -281,46 +271,6 @@ onMounted(run);
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
}
|
}
|
||||||
.wb-header {
|
|
||||||
height: 56px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: #1f2a3a;
|
|
||||||
color: #fff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 24px;
|
|
||||||
h4 {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 16px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.wb-header-aside {
|
|
||||||
margin-left: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
.name {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.delimiter {
|
|
||||||
width: 1px;
|
|
||||||
height: 16px;
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.wb-avatar {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.logout {
|
|
||||||
color: #fff;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* 工具行:Notice front .search-wrap 同款(#f7f7f7 底 + 10px 16px 内边距 ≈ 52px 高),按钮居左 */
|
/* 工具行:Notice front .search-wrap 同款(#f7f7f7 底 + 10px 16px 内边距 ≈ 52px 高),按钮居左 */
|
||||||
.wb-toolbar {
|
.wb-toolbar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useCmsStore } from "@/store";
|
|||||||
import config from "@/assets/config/host.config";
|
import config from "@/assets/config/host.config";
|
||||||
import { SiteVo, ThemeVo } from "@/types/cms";
|
import { SiteVo, ThemeVo } from "@/types/cms";
|
||||||
import { formModelFromTheme, FormModelValue, mergeThemeParamsIntoConfigJson, themeParamsFromConfigJson } from "@/utils/themeParamForm";
|
import { formModelFromTheme, FormModelValue, mergeThemeParamsIntoConfigJson, themeParamsFromConfigJson } from "@/utils/themeParamForm";
|
||||||
|
import { resolveEditableTheme } from "@/utils/themeEdit";
|
||||||
import ThemePicker from "@/components/theme/ThemePicker.vue";
|
import ThemePicker from "@/components/theme/ThemePicker.vue";
|
||||||
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
|
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
|
||||||
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
|
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
|
||||||
@@ -182,17 +183,18 @@ function openWizard() {
|
|||||||
wizardVisible.value = true;
|
wizardVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** W1:皮肤包管理页入口 */
|
|
||||||
function goThemeManage() {
|
|
||||||
router.push(`/sch/${store.schoolCode}/theme`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onWizardCreated() {
|
function onWizardCreated() {
|
||||||
run();
|
run();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(row: SiteVo) {
|
function openEdit(row: SiteVo) {
|
||||||
editSitePath.value = row.path || row.code;
|
editSitePath.value = row.path || row.code;
|
||||||
|
// 任务三:主题值保留原值(未注册时告警,由服务端校验拒绝),不再静默改写为 default
|
||||||
|
const resolved = resolveEditableTheme(
|
||||||
|
row.theme,
|
||||||
|
themes.value.map(t => t.name)
|
||||||
|
);
|
||||||
|
themeUnregistered.value = resolved.unregistered;
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
@@ -200,7 +202,7 @@ function openEdit(row: SiteVo) {
|
|||||||
schId: row.schId,
|
schId: row.schId,
|
||||||
schName: row.schName || "",
|
schName: row.schName || "",
|
||||||
name: row.name,
|
name: row.name,
|
||||||
theme: themeMeta.value[row.theme || "default"] ? row.theme! : "default",
|
theme: resolved.theme,
|
||||||
configJson: row.configJson || "",
|
configJson: row.configJson || "",
|
||||||
status: row.status,
|
status: row.status,
|
||||||
});
|
});
|
||||||
@@ -236,6 +238,7 @@ function withCustomCss(configJson: string, css: string): string {
|
|||||||
function pickTheme(name: string) {
|
function pickTheme(name: string) {
|
||||||
if (form.theme === name) return;
|
if (form.theme === name) return;
|
||||||
form.theme = name;
|
form.theme = name;
|
||||||
|
themeUnregistered.value = false;
|
||||||
const t = editTheme.value;
|
const t = editTheme.value;
|
||||||
editThemeParams.value = t ? formModelFromTheme(t, editThemeParams.value) : {};
|
editThemeParams.value = t ? formModelFromTheme(t, editThemeParams.value) : {};
|
||||||
}
|
}
|
||||||
@@ -332,11 +335,6 @@ watch(() => store.currentSchool?.schId, () => run());
|
|||||||
<Icon type="ios-add" />
|
<Icon type="ios-add" />
|
||||||
{{ isSchoolAdmin ? "新建院系子站" : "新建站点(向导)" }}
|
{{ isSchoolAdmin ? "新建院系子站" : "新建站点(向导)" }}
|
||||||
</Button>
|
</Button>
|
||||||
<!-- W1:皮肤包管理入口(平台菜单注册前兜底可达,方案 §5.4 步骤7/R3) -->
|
|
||||||
<Button v-if="isSuperAdmin" class="fl" size="large" style="margin-left: 12px" @click="goThemeManage">
|
|
||||||
<Icon type="ios-color-palette" />
|
|
||||||
皮肤包管理
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无站点" />
|
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无站点" />
|
||||||
|
|
||||||
@@ -362,6 +360,12 @@ watch(() => store.currentSchool?.schId, () => run());
|
|||||||
<Input v-model="form.schName" :maxlength="128" />
|
<Input v-model="form.schName" :maxlength="128" />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="皮肤包">
|
<FormItem label="皮肤包">
|
||||||
|
<Alert v-if="themeUnregistered" type="warning" show-icon style="margin-bottom: 8px">
|
||||||
|
皮肤包「{{ form.theme }}」未在注册表中,保存将被服务端拒绝;请先由平台超管上传该皮肤包,或改选下方其他皮肤。
|
||||||
|
</Alert>
|
||||||
|
<Alert v-else-if="!themes.length" type="warning" show-icon style="margin-bottom: 8px">
|
||||||
|
皮肤包列表加载失败,暂时无法选择;站点当前皮肤包({{ form.theme }})保持不变,可关闭后重试。
|
||||||
|
</Alert>
|
||||||
<ThemePicker :themes="themes" :model-value="form.theme" @update:model-value="pickTheme" />
|
<ThemePicker :themes="themes" :model-value="form.theme" @update:model-value="pickTheme" />
|
||||||
<Button size="small" class="preview-btn" :disabled="!editTheme" @click="openEditPreview">预览本皮肤</Button>
|
<Button size="small" class="preview-btn" :disabled="!editTheme" @click="openEditPreview">预览本皮肤</Button>
|
||||||
<ThemeParamForm :theme="editTheme" v-model="editThemeParams" />
|
<ThemeParamForm :theme="editTheme" v-model="editThemeParams" />
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ async function createSite() {
|
|||||||
<div v-else-if="step === 2" class="wizard-step step2">
|
<div v-else-if="step === 2" class="wizard-step step2">
|
||||||
<div class="left">
|
<div class="left">
|
||||||
<FormItem label="预览内容源" :label-width="90">
|
<FormItem label="预览内容源" :label-width="90">
|
||||||
<Select v-model="sourceSiteCode" style="width: 260px">
|
<Select v-model="sourceSitePath" style="width: 260px">
|
||||||
<Option v-for="s in sourceSites" :key="s.id" :value="s.path || s.code">{{ s.name }}({{ s.path || s.code }})</Option>
|
<Option v-for="s in sourceSites" :key="s.id" :value="s.path || s.code">{{ s.name }}({{ s.path || s.code }})</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
+198
-56
@@ -1,16 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message } from "view-ui-plus";
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
|
import WorkbenchHeader from "@/components/WorkbenchHeader.vue";
|
||||||
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
|
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
|
||||||
import { ThemeParamVo, ThemeVo, SiteVo } from "@/types/cms";
|
import { ThemeParamVo, ThemeVo, SiteVo } from "@/types/cms";
|
||||||
import { themeApi, siteApi, assetUrl } from "@/api/module/cms";
|
import { themeApi, siteApi, assetUrl } from "@/api/module/cms";
|
||||||
|
import { useCmsStore } from "@/store";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 皮肤包管理(W1,方案 §5.2/§5.4):内置只读(仅预览)、外部可管(上传/派生/编辑/删除)。
|
* 皮肤包管理(W1 + 任务二/四,2026-09-08):平台级全屏页(/theme-manage,超管专属,
|
||||||
* 入口:站点管理按钮(平台模块表注册前的兜底,R3)+ 平台菜单(modId=7)。
|
* 工作台按钮进入、不再挂左侧菜单)。内置包内容只读但显示名/描述可改(.meta 覆盖层)、
|
||||||
|
* 外部包可管(上传/派生/编辑/删除)。标识名(name,即主题路径/URL 段)仅创建时可定,创建后不可改。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useCmsStore();
|
||||||
|
|
||||||
const themes = ref<ThemeVo[]>([]);
|
const themes = ref<ThemeVo[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const previewVisible = ref(false);
|
const previewVisible = ref(false);
|
||||||
@@ -18,14 +25,34 @@ const previewTheme = ref<ThemeVo | null>(null);
|
|||||||
const previewSitePath = ref("");
|
const previewSitePath = ref("");
|
||||||
const contentSources = ref<SiteVo[]>([]);
|
const contentSources = ref<SiteVo[]>([]);
|
||||||
|
|
||||||
const deriveVisible = ref(false);
|
// 上传表单(任务四:zip + 显示名称/描述)
|
||||||
const deriveForm = ref({ source: "default", name: "" });
|
const uploadVisible = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const uploadFile = ref<File | null>(null);
|
||||||
|
const uploadForm = ref({ title: "", description: "" });
|
||||||
|
|
||||||
|
// 添加皮肤包表单(任务四:派生升级——源包 + 标识名(主题路径) + 显示名称/描述一步建成)
|
||||||
|
const addVisible = ref(false);
|
||||||
|
const adding = ref(false);
|
||||||
|
const addForm = ref({ source: "default", name: "", title: "", description: "" });
|
||||||
|
|
||||||
const metaVisible = ref(false);
|
const metaVisible = ref(false);
|
||||||
const metaTheme = ref<ThemeVo | null>(null);
|
const metaTheme = ref<ThemeVo | null>(null);
|
||||||
const metaTitle = ref("");
|
const metaTitle = ref("");
|
||||||
|
const metaDescription = ref("");
|
||||||
const metaParams = ref<ThemeParamVo[]>([]);
|
const metaParams = ref<ThemeParamVo[]>([]);
|
||||||
|
|
||||||
|
// 非超管深链兜底(引导期守卫放行,user 装载后弹回工作台)
|
||||||
|
watch(
|
||||||
|
() => store.user,
|
||||||
|
u => {
|
||||||
|
if (u && !store.isSuperAdmin) {
|
||||||
|
router.replace("/workbench");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -56,48 +83,85 @@ function onContentSourceChange(path: string) {
|
|||||||
previewSitePath.value = path;
|
previewSitePath.value = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
function beforeUpload(file: File) {
|
/* ===== 上传表单(任务四):选 zip + 命名,提交走手动上传通道 ===== */
|
||||||
themeApi
|
function openUpload() {
|
||||||
.upload(file)
|
uploadFile.value = null;
|
||||||
.send()
|
uploadForm.value = { title: "", description: "" };
|
||||||
.then(() => {
|
uploadVisible.value = true;
|
||||||
Message.success("上传成功,皮肤包已注册(免重启)");
|
|
||||||
run();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// 错误详情由 alova 拦截器统一弹出(校验报告逐条展示)
|
|
||||||
});
|
|
||||||
return false; // 阻断 Upload 组件自动上传,改走手动
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDerive() {
|
function onPickUploadFile(file: File) {
|
||||||
deriveForm.value = { source: themes.value[0]?.name || "default", name: "" };
|
uploadFile.value = file;
|
||||||
deriveVisible.value = true;
|
return false; // 阻断 Upload 自动上传,统一由「上传」按钮提交
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doDerive() {
|
async function doUpload() {
|
||||||
if (!/^[a-z][a-z0-9-]{1,30}[a-z0-9]$/.test(deriveForm.value.name)) {
|
if (!uploadFile.value) {
|
||||||
Message.warning("新包名仅限小写字母/数字/短横线,且以字母开头、数字或字母结尾");
|
Message.warning("请先选择皮肤包 zip 文件");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await themeApi.derive(deriveForm.value.source, deriveForm.value.name).send();
|
uploading.value = true;
|
||||||
Message.success("派生成功,新皮肤包可编辑可删除");
|
try {
|
||||||
deriveVisible.value = false;
|
await themeApi
|
||||||
|
.upload(uploadFile.value, {
|
||||||
|
title: uploadForm.value.title || undefined,
|
||||||
|
description: uploadForm.value.description,
|
||||||
|
})
|
||||||
|
.send();
|
||||||
|
Message.success("上传成功,皮肤包已注册(免重启)");
|
||||||
|
uploadVisible.value = false;
|
||||||
run();
|
run();
|
||||||
|
} catch {
|
||||||
|
// 错误详情由 alova 拦截器统一弹出(校验报告逐条展示)
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 添加皮肤包(任务四):从现有包复制 + 命名,一步建成 ===== */
|
||||||
|
function openAdd() {
|
||||||
|
addForm.value = { source: themes.value[0]?.name || "default", name: "", title: "", description: "" };
|
||||||
|
addVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doAdd() {
|
||||||
|
if (!/^[a-z][a-z0-9-]{1,30}[a-z0-9]$/.test(addForm.value.name)) {
|
||||||
|
Message.warning("标识名仅限小写字母/数字/短横线,且以字母开头、数字或字母结尾");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
adding.value = true;
|
||||||
|
try {
|
||||||
|
await themeApi
|
||||||
|
.derive(addForm.value.source, addForm.value.name, {
|
||||||
|
title: addForm.value.title || undefined,
|
||||||
|
description: addForm.value.description,
|
||||||
|
})
|
||||||
|
.send();
|
||||||
|
Message.success("添加成功,新皮肤包可编辑可删除");
|
||||||
|
addVisible.value = false;
|
||||||
|
run();
|
||||||
|
} finally {
|
||||||
|
adding.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 编辑元数据(任务四:内置包亦可改显示名/描述,走覆盖层) ===== */
|
||||||
function openMeta(theme: ThemeVo) {
|
function openMeta(theme: ThemeVo) {
|
||||||
metaTheme.value = theme;
|
metaTheme.value = theme;
|
||||||
metaTitle.value = theme.title;
|
metaTitle.value = theme.title;
|
||||||
|
metaDescription.value = theme.description || "";
|
||||||
metaParams.value = (theme.params || []).map(p => ({ ...p }));
|
metaParams.value = (theme.params || []).map(p => ({ ...p }));
|
||||||
metaVisible.value = true;
|
metaVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSaveMeta() {
|
async function doSaveMeta() {
|
||||||
|
const builtin = metaTheme.value?.source === "builtin";
|
||||||
await themeApi
|
await themeApi
|
||||||
.updateMeta(metaTheme.value!.name, {
|
.updateMeta(metaTheme.value!.name, {
|
||||||
title: metaTitle.value,
|
title: metaTitle.value,
|
||||||
params: metaParams.value,
|
description: metaDescription.value,
|
||||||
|
// 内置包参数 schema 不可改(后端强校验),不传即不改动
|
||||||
|
params: builtin ? undefined : metaParams.value,
|
||||||
})
|
})
|
||||||
.send();
|
.send();
|
||||||
Message.success("已保存,引用该皮肤的站点门户与预览立即生效");
|
Message.success("已保存,引用该皮肤的站点门户与预览立即生效");
|
||||||
@@ -105,6 +169,21 @@ async function doSaveMeta() {
|
|||||||
run();
|
run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 内置包恢复默认显示名/描述(清空 .meta 覆盖层) */
|
||||||
|
async function doResetBuiltinMeta() {
|
||||||
|
MyConfirm({
|
||||||
|
title: "恢复默认名称",
|
||||||
|
msg: `确定恢复「${metaTheme.value?.name}」的默认显示名称与描述吗?`,
|
||||||
|
info: "将清除改名覆盖层,恢复包内声明的原始名称",
|
||||||
|
async onOk() {
|
||||||
|
await themeApi.updateMeta(metaTheme.value!.name, { title: "", description: "" }).send();
|
||||||
|
Message.success("已恢复默认名称");
|
||||||
|
metaVisible.value = false;
|
||||||
|
run();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function askRemove(theme: ThemeVo) {
|
function askRemove(theme: ThemeVo) {
|
||||||
if (theme.source === "builtin") {
|
if (theme.source === "builtin") {
|
||||||
Message.warning("内置包不可删除");
|
Message.warning("内置包不可删除");
|
||||||
@@ -130,14 +209,17 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="theme-manage">
|
<div class="theme-page">
|
||||||
|
<WorkbenchHeader title="云海CMS · 皮肤包管理" back-to="/workbench" />
|
||||||
|
|
||||||
|
<div class="theme-body">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<Upload :before-upload="beforeUpload" accept=".zip" :show-upload-list="false">
|
<Button type="primary" icon="ios-cloud-upload-outline" @click="openUpload">上传皮肤包</Button>
|
||||||
<Button type="primary" icon="ios-cloud-upload-outline">上传皮肤包(zip)</Button>
|
<Button icon="ios-copy" @click="openAdd">添加皮肤包</Button>
|
||||||
</Upload>
|
|
||||||
<Button icon="ios-copy" @click="openDerive">从现有包派生</Button>
|
|
||||||
<Button icon="ios-refresh" @click="run">刷新</Button>
|
<Button icon="ios-refresh" @click="run">刷新</Button>
|
||||||
<span class="summary">内置 {{ builtinCount }} 个 · 外部 {{ themes.length - builtinCount }} 个(内置只读,外部可管)</span>
|
<span class="summary">
|
||||||
|
内置 {{ builtinCount }} 个 · 外部 {{ themes.length - builtinCount }} 个(内置可改名,外部可管;标识名即主题路径,创建后不可改)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Spin v-if="loading" size="large" style="display: block; margin: 60px auto" />
|
<Spin v-if="loading" size="large" style="display: block; margin: 60px auto" />
|
||||||
@@ -156,9 +238,10 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
|||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<Button size="small" @click="openPreview(t)">预览</Button>
|
<Button size="small" @click="openPreview(t)">预览</Button>
|
||||||
<template v-if="t.source === 'external'">
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
:content="(t.inUseCount || 0) > 0
|
:content="t.source === 'builtin'
|
||||||
|
? '内置包内容只读,可改显示名称与描述'
|
||||||
|
: (t.inUseCount || 0) > 0
|
||||||
? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
|
? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
|
||||||
: '外部包可编辑参数 schema'"
|
: '外部包可编辑参数 schema'"
|
||||||
placement="top"
|
placement="top"
|
||||||
@@ -166,6 +249,7 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
|||||||
>
|
>
|
||||||
<Button size="small" @click="openMeta(t)">编辑</Button>
|
<Button size="small" @click="openMeta(t)">编辑</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<template v-if="t.source === 'external'">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
:content="(t.inUseCount || 0) > 0
|
:content="(t.inUseCount || 0) > 0
|
||||||
? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
|
? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
|
||||||
@@ -187,6 +271,57 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 上传表单弹窗(任务四):zip + 显示名称/描述 -->
|
||||||
|
<Modal v-model="uploadVisible" title="上传皮肤包" :mask-closable="false" width="520px">
|
||||||
|
<Form :label-width="100">
|
||||||
|
<FormItem label="皮肤包 zip" required>
|
||||||
|
<Upload :before-upload="onPickUploadFile" accept=".zip" :show-upload-list="false">
|
||||||
|
<Button icon="ios-folder-open">选择文件</Button>
|
||||||
|
</Upload>
|
||||||
|
<p class="form-tip">
|
||||||
|
{{ uploadFile ? `已选择:${uploadFile.name}` : "尚未选择文件(结构:zip 顶层唯一文件夹,名字与 theme.json 的 name 一致)" }}
|
||||||
|
</p>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="显示名称">
|
||||||
|
<Input v-model="uploadForm.title" :maxlength="64" placeholder="选填,留空沿用包内 theme.json 的标题" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="描述">
|
||||||
|
<Input v-model="uploadForm.description" :maxlength="128" placeholder="选填" />
|
||||||
|
</FormItem>
|
||||||
|
</Form>
|
||||||
|
<template #footer>
|
||||||
|
<Button @click="uploadVisible = false">取消</Button>
|
||||||
|
<Button type="primary" :loading="uploading" :disabled="!uploadFile" @click="doUpload">上传</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- 添加皮肤包(任务四):源包 + 标识名(主题路径) + 命名,一步建成 -->
|
||||||
|
<Modal v-model="addVisible" title="添加皮肤包" :mask-closable="false" width="520px">
|
||||||
|
<Form :label-width="100">
|
||||||
|
<FormItem label="复制自">
|
||||||
|
<Select v-model="addForm.source" style="width: 100%">
|
||||||
|
<Option v-for="t in themes" :key="t.name" :value="t.name">{{ t.title }}({{ t.name }})</Option>
|
||||||
|
</Select>
|
||||||
|
<p class="form-tip">新包完整复制源包的模板与参数,可独立编辑、删除。</p>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="标识名" required>
|
||||||
|
<Input v-model="addForm.name" placeholder="如 mint-green(小写字母/数字/短横线)" />
|
||||||
|
<p class="form-tip">即主题路径(URL 中的主题段),创建后不可修改。</p>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="显示名称">
|
||||||
|
<Input v-model="addForm.title" :maxlength="64" placeholder="选填,如 薄荷绿;留空沿用源包名称" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="描述">
|
||||||
|
<Input v-model="addForm.description" :maxlength="128" placeholder="选填" />
|
||||||
|
</FormItem>
|
||||||
|
</Form>
|
||||||
|
<template #footer>
|
||||||
|
<Button @click="addVisible = false">取消</Button>
|
||||||
|
<Button type="primary" :loading="adding" @click="doAdd">添加</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<!-- 预览弹窗(复用 ThemePreviewFrame,内容源下拉同向导) -->
|
<!-- 预览弹窗(复用 ThemePreviewFrame,内容源下拉同向导) -->
|
||||||
<Modal v-model="previewVisible" :title="`预览皮肤:${previewTheme?.title || ''}`" width="860px">
|
<Modal v-model="previewVisible" :title="`预览皮肤:${previewTheme?.title || ''}`" width="860px">
|
||||||
@@ -201,36 +336,29 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
|||||||
<ThemePreviewFrame :site-path="previewSitePath" :theme="previewTheme?.name" :theme-params="{}" />
|
<ThemePreviewFrame :site-path="previewSitePath" :theme="previewTheme?.name" :theme-params="{}" />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- 派生弹窗 -->
|
<!-- 编辑弹窗(任务四:内置包仅显示名/描述;外部包另含参数 schema) -->
|
||||||
<Modal v-model="deriveVisible" title="从现有包派生新皮肤" :mask-closable="false" width="480px">
|
|
||||||
<Form :label-width="100">
|
|
||||||
<FormItem label="派生源">
|
|
||||||
<Select v-model="deriveForm.source" style="width: 280px">
|
|
||||||
<Option v-for="t in themes" :key="t.name" :value="t.name">{{ t.title }}({{ t.name }})</Option>
|
|
||||||
</Select>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="新包名" required>
|
|
||||||
<Input v-model="deriveForm.name" placeholder="如 my-skin(小写字母/数字/短横线)" style="width: 280px" />
|
|
||||||
</FormItem>
|
|
||||||
</Form>
|
|
||||||
<template #footer>
|
|
||||||
<Button @click="deriveVisible = false">取消</Button>
|
|
||||||
<Button type="primary" @click="doDerive">派生</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<!-- 编辑弹窗(外部包:title + 参数 schema 默认值) -->
|
|
||||||
<Modal v-model="metaVisible" :title="`编辑皮肤:${metaTheme?.name || ''}`" :mask-closable="false" width="560px">
|
<Modal v-model="metaVisible" :title="`编辑皮肤:${metaTheme?.name || ''}`" :mask-closable="false" width="560px">
|
||||||
|
<Alert v-if="metaTheme?.source === 'builtin'" type="info" show-icon style="margin-bottom: 12px">
|
||||||
|
内置包内容(模板/资产)不可修改,仅显示名称与描述生效;标识名({{ metaTheme?.name }})不可改。
|
||||||
|
</Alert>
|
||||||
<Form :label-width="110">
|
<Form :label-width="110">
|
||||||
<FormItem label="标题">
|
<FormItem label="显示名称">
|
||||||
<Input v-model="metaTitle" :maxlength="64" />
|
<Input v-model="metaTitle" :maxlength="64" />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
<FormItem label="描述">
|
||||||
|
<Input v-model="metaDescription" :maxlength="128" />
|
||||||
|
</FormItem>
|
||||||
|
<template v-if="metaTheme?.source !== 'builtin'">
|
||||||
<FormItem v-for="p in metaParams" :key="p.key" :label="p.label">
|
<FormItem v-for="p in metaParams" :key="p.key" :label="p.label">
|
||||||
<ColorPicker v-if="p.type === 'color'" v-model="p.defaultValue" />
|
<ColorPicker v-if="p.type === 'color'" v-model="p.defaultValue" />
|
||||||
<Input v-else v-model="p.defaultValue" :disabled="p.type === 'select'" />
|
<Input v-else v-model="p.defaultValue" :disabled="p.type === 'select'" />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
</template>
|
||||||
</Form>
|
</Form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
<Button v-if="metaTheme?.source === 'builtin'" style="float: left" @click="doResetBuiltinMeta">
|
||||||
|
恢复默认名称
|
||||||
|
</Button>
|
||||||
<Button @click="metaVisible = false">取消</Button>
|
<Button @click="metaVisible = false">取消</Button>
|
||||||
<Button type="primary" @click="doSaveMeta">保存</Button>
|
<Button type="primary" @click="doSaveMeta">保存</Button>
|
||||||
</template>
|
</template>
|
||||||
@@ -239,7 +367,15 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.theme-manage {
|
.theme-page {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
.theme-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 0 24px 24px;
|
padding: 0 24px 24px;
|
||||||
}
|
}
|
||||||
.preview-source {
|
.preview-source {
|
||||||
@@ -318,4 +454,10 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.form-tip {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user