feat(W1): 皮肤包管理页-卡片网格+上传/派生/编辑/删除,ThemeApi五方法,路由/入口/mock modId=7,ThemeVo增source/inUseCount/editable/usedBySiteNames,T-W1-21..24 绿56/56
build / build (push) Successful in 2m16s
build / build (push) Successful in 2m16s
This commit is contained in:
@@ -479,6 +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"),
|
||||||
]));
|
]));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
/**
|
||||||
|
* W1 皮肤包管理页组件行为(方案 §5.3 T-W1-22..24):
|
||||||
|
* ① 内置卡无删除/编辑按钮(仅"预览");
|
||||||
|
* ② 被用外部包删除禁用且 tooltip 含站点名;
|
||||||
|
* ③ 上传成功刷新列表(list 被再次调用)+ Message.success。
|
||||||
|
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 categoryWorkbench.spec.ts 一致)。
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApp, defineComponent, h, nextTick } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import ViewUIPlus, { Message } from "view-ui-plus";
|
||||||
|
|
||||||
|
const { listMock, uploadMock, removeMock } = vi.hoisted(() => ({
|
||||||
|
listMock: vi.fn(),
|
||||||
|
uploadMock: vi.fn(),
|
||||||
|
removeMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const themes = [
|
||||||
|
{
|
||||||
|
name: "default",
|
||||||
|
title: "云端默认",
|
||||||
|
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||||
|
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
|
||||||
|
source: "builtin",
|
||||||
|
inUseCount: 2,
|
||||||
|
editable: false,
|
||||||
|
usedBySiteNames: ["第一中学门户", "机电学院"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "my-skin",
|
||||||
|
title: "我的皮肤",
|
||||||
|
screenshotUrl: "/api/theme/my-skin/asset/screenshot.png",
|
||||||
|
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#b01a2e" }],
|
||||||
|
source: "external",
|
||||||
|
inUseCount: 1,
|
||||||
|
editable: true,
|
||||||
|
usedBySiteNames: ["删除保护门户"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "free-skin",
|
||||||
|
title: "空闲皮肤",
|
||||||
|
screenshotUrl: "/api/theme/free-skin/asset/screenshot.png",
|
||||||
|
params: [],
|
||||||
|
source: "external",
|
||||||
|
inUseCount: 0,
|
||||||
|
editable: true,
|
||||||
|
usedBySiteNames: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mock("@/api/module/cms", () => ({
|
||||||
|
themeApi: {
|
||||||
|
list: () => ({ send: listMock }),
|
||||||
|
upload: (file: File) => {
|
||||||
|
uploadMock(file);
|
||||||
|
return { send: async () => ({}) };
|
||||||
|
},
|
||||||
|
remove: (name: string) => {
|
||||||
|
removeMock(name);
|
||||||
|
return { send: async () => ({}) };
|
||||||
|
},
|
||||||
|
derive: () => ({ send: async () => ({}) }),
|
||||||
|
updateMeta: () => ({ send: async () => ({}) }),
|
||||||
|
reload: () => ({ send: async () => ({}) }),
|
||||||
|
},
|
||||||
|
siteApi: { list: () => ({ send: async () => [] }) },
|
||||||
|
assetUrl: (u: string) => u,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||||
|
default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
|
||||||
|
|
||||||
|
import ThemeManage from "@/views/cms/ThemeManage.vue";
|
||||||
|
|
||||||
|
let cleanups: (() => void)[] = [];
|
||||||
|
|
||||||
|
async function flush(frames = 6) {
|
||||||
|
for (let i = 0; i < frames; i++) {
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountPage() {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
document.body.appendChild(el);
|
||||||
|
const app = createApp(defineComponent({ render: () => h(ThemeManage) }));
|
||||||
|
app.use(createPinia());
|
||||||
|
app.use(ViewUIPlus);
|
||||||
|
app.mount(el);
|
||||||
|
cleanups.push(() => {
|
||||||
|
app.unmount();
|
||||||
|
el.remove();
|
||||||
|
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
listMock.mockResolvedValue(JSON.parse(JSON.stringify(themes)));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanups.forEach(fn => fn());
|
||||||
|
cleanups = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ThemeManage 皮肤包管理(T-W1-22..24)", () => {
|
||||||
|
it("T-W1-22: 内置卡无删除/编辑按钮(仅'预览')", async () => {
|
||||||
|
const el = mountPage();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const cards = [...el.querySelectorAll(".card")];
|
||||||
|
expect(cards).toHaveLength(3);
|
||||||
|
const builtin = cards[0];
|
||||||
|
expect(builtin.textContent).toContain("内置");
|
||||||
|
const btns = [...builtin.querySelectorAll(".actions button")].map(b => b.textContent?.trim());
|
||||||
|
expect(btns).toEqual(["预览"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("T-W1-23: 被用外部包删除禁用且 tooltip 含站点名;空闲外部包可删", async () => {
|
||||||
|
const el = mountPage();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const cards = [...el.querySelectorAll(".card")];
|
||||||
|
const used = cards[1]; // my-skin,inUseCount=1
|
||||||
|
const usedDelete = [...used.querySelectorAll(".actions button")].find(b => b.textContent?.includes("删除"));
|
||||||
|
expect(usedDelete).toBeTruthy();
|
||||||
|
expect(usedDelete!.getAttribute("disabled")).not.toBeNull();
|
||||||
|
|
||||||
|
// tooltip 内容含站点名(transfer 到 body)
|
||||||
|
usedDelete!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
await flush();
|
||||||
|
expect(document.body.textContent).toContain("删除保护门户");
|
||||||
|
|
||||||
|
const free = cards[2];
|
||||||
|
const freeDelete = [...free.querySelectorAll(".actions button")].find(b => b.textContent?.includes("删除"));
|
||||||
|
expect(freeDelete!.getAttribute("disabled")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("T-W1-24: 上传成功刷新列表(list 再次调用)+ Message.success", async () => {
|
||||||
|
const el = mountPage();
|
||||||
|
await flush();
|
||||||
|
expect(listMock).toHaveBeenCalledTimes(1);
|
||||||
|
const successSpy = vi.spyOn(Message, "success");
|
||||||
|
|
||||||
|
// Upload 组件经 before-upload 手动上传:直接调内部通道(beforeUpload 闭包不可达,
|
||||||
|
// 经组件 props 不可得 → 以 uploadMock + list 重查语义等价驱动:触发 Upload 的 change?
|
||||||
|
// 简化:直接验证上传成功路径——页面暴露 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
|
||||||
|
const input = el.querySelector<HTMLInputElement>('input[type="file"]');
|
||||||
|
expect(input).toBeTruthy();
|
||||||
|
Object.defineProperty(input!, "files", { value: [file] });
|
||||||
|
input!.dispatchEvent(new Event("change"));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(uploadMock).toHaveBeenCalled();
|
||||||
|
expect(successSpy).toHaveBeenCalled();
|
||||||
|
expect(listMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,10 +18,12 @@ vi.mock("@/utils/alova/alova-iview", () => ({
|
|||||||
alovaInstance: {
|
alovaInstance: {
|
||||||
Get: vi.fn(),
|
Get: vi.fn(),
|
||||||
Post: vi.fn(() => ({})),
|
Post: vi.fn(() => ({})),
|
||||||
|
Put: vi.fn(() => ({})),
|
||||||
|
Delete: vi.fn(() => ({})),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { articleApi, assetUrl, themePreviewUrl } from "./cms";
|
import { articleApi, assetUrl, themeApi, themePreviewUrl } from "./cms";
|
||||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||||
|
|
||||||
const JSON_HEADERS = { "Content-Type": "application/json;charset=UTF-8" };
|
const JSON_HEADERS = { "Content-Type": "application/json;charset=UTF-8" };
|
||||||
@@ -100,6 +102,43 @@ describe("assetUrl 站内资产路径补 serviceHost 前缀(线上主题截图
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("themeApi W1 端点契约(T-W1-21)", () => {
|
||||||
|
const JSON_HEADERS = { "Content-Type": "application/json;charset=UTF-8" };
|
||||||
|
|
||||||
|
it("upload 走 multipart Post /api/theme/upload", () => {
|
||||||
|
vi.mocked(alovaInstance.Post).mockClear();
|
||||||
|
const file = new File(["zip-bytes"], "pkg.zip", { type: "application/zip" });
|
||||||
|
themeApi.upload(file);
|
||||||
|
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/theme/upload", expect.any(FormData));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derive/updateMeta/remove/reload 的方法与路径", () => {
|
||||||
|
vi.mocked(alovaInstance.Post).mockClear();
|
||||||
|
vi.mocked(alovaInstance.Put).mockClear();
|
||||||
|
vi.mocked(alovaInstance.Delete).mockClear();
|
||||||
|
|
||||||
|
themeApi.derive("default", "my-skin");
|
||||||
|
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/theme/derive", { source: "default", name: "my-skin" }, {
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
params: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
themeApi.updateMeta("my-skin", { title: "我的皮肤" });
|
||||||
|
expect(alovaInstance.Put).toHaveBeenCalledWith("/api/theme/my-skin/meta", { title: "我的皮肤" }, {
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
});
|
||||||
|
|
||||||
|
themeApi.remove("my-skin");
|
||||||
|
expect(alovaInstance.Delete).toHaveBeenCalledWith("/api/theme/my-skin");
|
||||||
|
|
||||||
|
themeApi.reload();
|
||||||
|
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/theme/reload", undefined, {
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
params: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("themePreviewUrl 参数语义(T-W4-17:W4 起 sitePath)", () => {
|
describe("themePreviewUrl 参数语义(T-W4-17:W4 起 sitePath)", () => {
|
||||||
it("query 携带 sitePath(嵌套路径 URL 编码)与 p_ 前缀参数", () => {
|
it("query 携带 sitePath(嵌套路径 URL 编码)与 p_ 前缀参数", () => {
|
||||||
const url = themePreviewUrl("sch-3/jdxy", "t-demo-red", { primary: "#b01a2e" });
|
const url = themePreviewUrl("sch-3/jdxy", "t-demo-red", { primary: "#b01a2e" });
|
||||||
|
|||||||
@@ -212,6 +212,35 @@ class ThemeApi {
|
|||||||
list() {
|
list() {
|
||||||
return get<ThemeVo[]>(`${THEME}/list`);
|
return get<ThemeVo[]>(`${THEME}/list`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** W1:上传合规 zip(multipart,超管;免重启立即注册) */
|
||||||
|
upload(file: File) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
return alovaInstance.Post<ThemeVo>(`${THEME}/upload`, fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W1:从现有包派生新皮肤(复制包体+改名) */
|
||||||
|
derive(source: string, name: string) {
|
||||||
|
return postJSON<ThemeVo>(`${THEME}/derive`, { source, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W1:编辑外部包 theme.json 的 title/参数 schema */
|
||||||
|
updateMeta(name: string, data: { title?: string; params?: unknown[] }) {
|
||||||
|
return alovaInstance.Put<ThemeVo>(`${THEME}/${name}/meta`, data, {
|
||||||
|
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W1:删除外部包(被用站点拒绝;内置不可删) */
|
||||||
|
remove(name: string) {
|
||||||
|
return alovaInstance.Delete<{ ok: boolean }>(`${THEME}/${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W1:热重载(运维直放目录后生效) */
|
||||||
|
reload() {
|
||||||
|
return postJSON<ThemeVo[]>(`${THEME}/reload`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 主题预览 iframe URL(方案 §5.5:/api/theme/preview,CAS 会话同源,参数 p_ 前缀)。
|
/** 主题预览 iframe URL(方案 §5.5:/api/theme/preview,CAS 会话同源,参数 p_ 前缀)。
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ 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",
|
||||||
@@ -116,6 +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: "/portal-preview", redirect: legacyRedirect("portal-preview") },
|
{ path: "/portal-preview", redirect: legacyRedirect("portal-preview") },
|
||||||
{
|
{
|
||||||
path: "/:pathMatch(.*)*",
|
path: "/:pathMatch(.*)*",
|
||||||
|
|||||||
@@ -269,4 +269,12 @@ export interface ThemeVo {
|
|||||||
/** 预览图(管理端资产代理 /api/theme/{name}/asset/screenshot.png) */
|
/** 预览图(管理端资产代理 /api/theme/{name}/asset/screenshot.png) */
|
||||||
screenshotUrl: string;
|
screenshotUrl: string;
|
||||||
params: ThemeParamVo[];
|
params: ThemeParamVo[];
|
||||||
|
/** 包来源(W1):builtin=内置只读 / external=外部可管 */
|
||||||
|
source?: string;
|
||||||
|
/** 被用站点数(W1,删除保护口径与后端一致) */
|
||||||
|
inUseCount?: number;
|
||||||
|
/** 是否可编辑/删除(W1:仅外部包) */
|
||||||
|
editable?: boolean;
|
||||||
|
/** 引用站点名列表(W1:删除保护提示/tooltip) */
|
||||||
|
usedBySiteNames?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,11 @@ function openWizard() {
|
|||||||
wizardVisible.value = true;
|
wizardVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** W1:皮肤包管理页入口 */
|
||||||
|
function goThemeManage() {
|
||||||
|
router.push(`/sch/${store.schoolCode}/theme`);
|
||||||
|
}
|
||||||
|
|
||||||
function onWizardCreated() {
|
function onWizardCreated() {
|
||||||
run();
|
run();
|
||||||
}
|
}
|
||||||
@@ -327,6 +332,11 @@ 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="暂无站点" />
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
|
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
|
||||||
|
import { ThemeParamVo, ThemeVo, SiteVo } from "@/types/cms";
|
||||||
|
import { themeApi, siteApi, assetUrl } from "@/api/module/cms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 皮肤包管理(W1,方案 §5.2/§5.4):内置只读(仅预览)、外部可管(上传/派生/编辑/删除)。
|
||||||
|
* 入口:站点管理按钮(平台模块表注册前的兜底,R3)+ 平台菜单(modId=7)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const themes = ref<ThemeVo[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const previewTheme = ref<ThemeVo | null>(null);
|
||||||
|
const previewSitePath = ref("");
|
||||||
|
const contentSources = ref<SiteVo[]>([]);
|
||||||
|
|
||||||
|
const deriveVisible = ref(false);
|
||||||
|
const deriveForm = ref({ source: "default", name: "" });
|
||||||
|
|
||||||
|
const metaVisible = ref(false);
|
||||||
|
const metaTheme = ref<ThemeVo | null>(null);
|
||||||
|
const metaTitle = ref("");
|
||||||
|
const metaParams = ref<ThemeParamVo[]>([]);
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
themes.value = (await themeApi.list().send()) || [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(run);
|
||||||
|
|
||||||
|
/** 预览内容源站点(W1:与向导同语义,取启用站点按门户 path 预览) */
|
||||||
|
async function openPreview(theme: ThemeVo) {
|
||||||
|
previewTheme.value = theme;
|
||||||
|
if (!contentSources.value.length) {
|
||||||
|
try {
|
||||||
|
contentSources.value = ((await siteApi.list().send()) || []).filter(s => s.status === 1);
|
||||||
|
} catch {
|
||||||
|
contentSources.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const first = contentSources.value[0];
|
||||||
|
previewSitePath.value = first ? first.path || first.code : "";
|
||||||
|
previewVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContentSourceChange(path: string) {
|
||||||
|
previewSitePath.value = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforeUpload(file: File) {
|
||||||
|
themeApi
|
||||||
|
.upload(file)
|
||||||
|
.send()
|
||||||
|
.then(() => {
|
||||||
|
Message.success("上传成功,皮肤包已注册(免重启)");
|
||||||
|
run();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// 错误详情由 alova 拦截器统一弹出(校验报告逐条展示)
|
||||||
|
});
|
||||||
|
return false; // 阻断 Upload 组件自动上传,改走手动
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDerive() {
|
||||||
|
deriveForm.value = { source: themes.value[0]?.name || "default", name: "" };
|
||||||
|
deriveVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDerive() {
|
||||||
|
if (!/^[a-z][a-z0-9-]{1,30}[a-z0-9]$/.test(deriveForm.value.name)) {
|
||||||
|
Message.warning("新包名仅限小写字母/数字/短横线,且以字母开头、数字或字母结尾");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await themeApi.derive(deriveForm.value.source, deriveForm.value.name).send();
|
||||||
|
Message.success("派生成功,新皮肤包可编辑可删除");
|
||||||
|
deriveVisible.value = false;
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMeta(theme: ThemeVo) {
|
||||||
|
metaTheme.value = theme;
|
||||||
|
metaTitle.value = theme.title;
|
||||||
|
metaParams.value = (theme.params || []).map(p => ({ ...p }));
|
||||||
|
metaVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSaveMeta() {
|
||||||
|
await themeApi
|
||||||
|
.updateMeta(metaTheme.value!.name, {
|
||||||
|
title: metaTitle.value,
|
||||||
|
params: metaParams.value,
|
||||||
|
})
|
||||||
|
.send();
|
||||||
|
Message.success("已保存,引用该皮肤的站点门户与预览立即生效");
|
||||||
|
metaVisible.value = false;
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemove(theme: ThemeVo) {
|
||||||
|
if (theme.source === "builtin") {
|
||||||
|
Message.warning("内置包不可删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((theme.inUseCount || 0) > 0) {
|
||||||
|
Message.warning(`皮肤包正被站点使用,无法删除:${(theme.usedBySiteNames || []).join("、")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MyConfirm({
|
||||||
|
title: "删除皮肤包",
|
||||||
|
msg: `确定删除外部皮肤包「${theme.title}(${theme.name})」吗?`,
|
||||||
|
info: "删除后目录移除并热注销;内置包不受影响",
|
||||||
|
async onOk() {
|
||||||
|
await themeApi.remove(theme.name).send();
|
||||||
|
Message.success("删除成功");
|
||||||
|
run();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtinCount = computed(() => themes.value.filter(t => t.source === "builtin").length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="theme-manage">
|
||||||
|
<div class="toolbar">
|
||||||
|
<Upload :before-upload="beforeUpload" accept=".zip" :show-upload-list="false">
|
||||||
|
<Button type="primary" icon="ios-cloud-upload-outline">上传皮肤包(zip)</Button>
|
||||||
|
</Upload>
|
||||||
|
<Button icon="ios-copy" @click="openDerive">从现有包派生</Button>
|
||||||
|
<Button icon="ios-refresh" @click="run">刷新</Button>
|
||||||
|
<span class="summary">内置 {{ builtinCount }} 个 · 外部 {{ themes.length - builtinCount }} 个(内置只读,外部可管)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin v-if="loading" size="large" style="display: block; margin: 60px auto" />
|
||||||
|
<div v-else class="cards">
|
||||||
|
<div v-for="t in themes" :key="t.name" class="card">
|
||||||
|
<div class="shot">
|
||||||
|
<img :src="assetUrl(t.screenshotUrl)" :alt="t.title" />
|
||||||
|
<Tag class="badge" :color="t.source === 'builtin' ? 'default' : 'success'">
|
||||||
|
{{ t.source === "builtin" ? "内置" : "外部" }}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<p class="title">{{ t.title }}<span class="name">({{ t.name }})</span></p>
|
||||||
|
<p class="desc">{{ t.description || "—" }}</p>
|
||||||
|
<p class="stats">参数 {{ (t.params || []).length }} 个 · 被用 {{ t.inUseCount || 0 }} 站点</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<Button size="small" @click="openPreview(t)">预览</Button>
|
||||||
|
<template v-if="t.source === 'external'">
|
||||||
|
<Tooltip
|
||||||
|
:content="(t.inUseCount || 0) > 0
|
||||||
|
? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
|
||||||
|
: '外部包可编辑参数 schema'"
|
||||||
|
placement="top"
|
||||||
|
transfer
|
||||||
|
>
|
||||||
|
<Button size="small" @click="openMeta(t)">编辑</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
:content="(t.inUseCount || 0) > 0
|
||||||
|
? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
|
||||||
|
: '外部包可删除'"
|
||||||
|
placement="top"
|
||||||
|
transfer
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="error"
|
||||||
|
ghost
|
||||||
|
:disabled="(t.inUseCount || 0) > 0"
|
||||||
|
@click="askRemove(t)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 预览弹窗(复用 ThemePreviewFrame,内容源下拉同向导) -->
|
||||||
|
<Modal v-model="previewVisible" :title="`预览皮肤:${previewTheme?.title || ''}`" width="860px">
|
||||||
|
<div class="preview-source">
|
||||||
|
<label>内容源站点</label>
|
||||||
|
<Select :model-value="previewSitePath" style="width: 280px" @on-change="onContentSourceChange">
|
||||||
|
<Option v-for="s in contentSources" :key="s.id" :value="s.path || s.code">
|
||||||
|
{{ s.name }}({{ s.path || s.code }})
|
||||||
|
</Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<ThemePreviewFrame :site-path="previewSitePath" :theme="previewTheme?.name" :theme-params="{}" />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- 派生弹窗 -->
|
||||||
|
<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">
|
||||||
|
<Form :label-width="110">
|
||||||
|
<FormItem label="标题">
|
||||||
|
<Input v-model="metaTitle" :maxlength="64" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem v-for="p in metaParams" :key="p.key" :label="p.label">
|
||||||
|
<ColorPicker v-if="p.type === 'color'" v-model="p.defaultValue" />
|
||||||
|
<Input v-else v-model="p.defaultValue" :disabled="p.type === 'select'" />
|
||||||
|
</FormItem>
|
||||||
|
</Form>
|
||||||
|
<template #footer>
|
||||||
|
<Button @click="metaVisible = false">取消</Button>
|
||||||
|
<Button type="primary" @click="doSaveMeta">保存</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.theme-manage {
|
||||||
|
padding: 0 24px 24px;
|
||||||
|
}
|
||||||
|
.preview-source {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
label {
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 0;
|
||||||
|
.summary {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
.shot {
|
||||||
|
position: relative;
|
||||||
|
height: 150px;
|
||||||
|
background: #f0f2f5;
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
padding: 10px 12px 4px;
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
.name {
|
||||||
|
color: #999;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.desc {
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user