Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8ac455428 | ||
|
|
dedd4a6a3a | ||
|
|
7b99ec398d | ||
|
|
1e887906eb | ||
|
|
2a077aa917 | ||
|
|
fc566eed8b | ||
|
|
e494a2d2d8 | ||
|
|
bb5c5e513f | ||
|
|
bcd639cfc2 | ||
|
|
3990811ed5 | ||
|
|
c77be8b5d8 |
+15
-1
@@ -8,6 +8,8 @@
|
||||
export interface MockSite {
|
||||
id: number;
|
||||
code: string;
|
||||
/** 门户路径(W4:顶层=code;子站=父path/code) */
|
||||
path: string;
|
||||
/** 上级站点ID(0=顶层校站点;院系子站挂主站 id) */
|
||||
parentSiteId: number;
|
||||
schId: number;
|
||||
@@ -58,6 +60,9 @@ export interface MockAttachment {
|
||||
id: number;
|
||||
siteId: number;
|
||||
articleId?: number;
|
||||
/** 场景引用类型(B2):banner/link/site;空=未使用 */
|
||||
refType?: string;
|
||||
refId?: number;
|
||||
fileName: string;
|
||||
url: string;
|
||||
fileSize: number;
|
||||
@@ -138,6 +143,7 @@ export function seedDb(): MockDb {
|
||||
{
|
||||
id: 1,
|
||||
code: "demo",
|
||||
path: "demo",
|
||||
parentSiteId: 0,
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
@@ -159,6 +165,7 @@ export function seedDb(): MockDb {
|
||||
{
|
||||
id: 2,
|
||||
code: "fsz",
|
||||
path: "fsz",
|
||||
parentSiteId: 0,
|
||||
schId: 1002,
|
||||
schName: "云海大学附属中学",
|
||||
@@ -171,6 +178,7 @@ export function seedDb(): MockDb {
|
||||
{
|
||||
id: 3,
|
||||
code: "jdx",
|
||||
path: "demo/jdx",
|
||||
parentSiteId: 1,
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
@@ -186,6 +194,7 @@ export function seedDb(): MockDb {
|
||||
{
|
||||
id: 4,
|
||||
code: "xxgc",
|
||||
path: "demo/xxgc",
|
||||
parentSiteId: 1,
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
@@ -324,7 +333,12 @@ export function seedDb(): MockDb {
|
||||
at({ fileName: "2026秋季学期校历.pdf", url: "/mock-files/2026校历.pdf", mimeType: "application/pdf", articleId: 1 }),
|
||||
at({ fileName: "运动会秩序册.pdf", url: "/mock-files/运动会秩序册.pdf", mimeType: "application/pdf", articleId: 5 }),
|
||||
at({ fileName: "迎新晚会节目单.docx", url: "/mock-files/迎新晚会节目单.docx", articleId: 6 }),
|
||||
at({ fileName: "banner-旧图.png", url: "/mock-img/banner-old.svg", mimeType: "image/png" }),
|
||||
// B2 场景引用:轮播图/友链 Logo 附件带 refType/refId(与真实后端形态一致)
|
||||
at({ fileName: "banner-1.png", url: "/mock-img/banner-1.svg", mimeType: "image/png", refType: "banner", refId: 1 }),
|
||||
at({ fileName: "banner-2.png", url: "/mock-img/banner-2.svg", mimeType: "image/png", refType: "banner", refId: 2 }),
|
||||
at({ fileName: "banner-3.png", url: "/mock-img/banner-3.svg", mimeType: "image/png", refType: "banner", refId: 3 }),
|
||||
at({ fileName: "logo-教育部.png", url: "/mock-img/logo-1.svg", mimeType: "image/png", refType: "link", refId: 1 }),
|
||||
at({ fileName: "未使用旧banner.png", url: "/mock-img/banner-old.svg", mimeType: "image/png" }),
|
||||
at({ fileName: "未使用配图.jpg", url: "/mock-img/orphan.svg", mimeType: "image/jpeg" })
|
||||
);
|
||||
|
||||
|
||||
+16
-7
@@ -251,15 +251,19 @@ function extractFile(body: Buffer): { name: string; mime: string; buffer: Buffer
|
||||
|
||||
/* ===== 简版门户预览页 ===== */
|
||||
|
||||
function siteByPath(sitePath: string): MockSite | undefined {
|
||||
return db.sites.find(s => s.path === sitePath || (s.parentSiteId === 0 && s.code === sitePath));
|
||||
}
|
||||
|
||||
function siteByCode(code: string): MockSite | undefined {
|
||||
return db.sites.find(s => s.code === code);
|
||||
}
|
||||
|
||||
function portalPage(req: IncomingMessage, res: ServerResponse) {
|
||||
const path = new URL(req.url!, "http://mock.local").pathname;
|
||||
// /portal/{code} 或 /portal/{code}/...(含 /a/{id})
|
||||
// W4:/portal/{path}/ 或 /portal/{path}/...(path 支持嵌套两段;含 /a/{id})
|
||||
const m = path.replace(/^\/portal\//, "").split("/");
|
||||
const site = siteByCode(m[0]);
|
||||
const site = siteByPath(m[0]);
|
||||
const nav = ["学校概况", "新闻中心", "教学科研", "招生网", "联系我们"]
|
||||
.map(n => `<a href="javascript:void(0)">${n}</a>`)
|
||||
.join("");
|
||||
@@ -274,7 +278,7 @@ function portalPage(req: IncomingMessage, res: ServerResponse) {
|
||||
const colleges = db.sites.filter(s => s.parentSiteId === anchorId && s.status === 1);
|
||||
const collegesHtml = colleges.length
|
||||
? `<div class="colleges"><span class="colleges-toggle">专业院系 ▾</span><ul class="colleges-menu">
|
||||
${colleges.map(c => `<li${c.code === site.code ? ' class="on"' : ""}><a href="/portal/${c.code}/">${c.name}</a></li>`).join("")}
|
||||
${colleges.map(c => `<li${c.path === site.path ? ' class="on"' : ""}><a href="/portal/${c.path}/">${c.name}</a></li>`).join("")}
|
||||
</ul></div>`
|
||||
: "";
|
||||
const articleMatch = path.match(/\/a\/(\d+)/);
|
||||
@@ -333,7 +337,7 @@ function portalPage(req: IncomingMessage, res: ServerResponse) {
|
||||
|
||||
function sendThemePreview(req: IncomingMessage, res: ServerResponse) {
|
||||
const u = new URL(req.url!, "http://mock.local");
|
||||
const site = siteByCode(u.searchParams.get("siteCode") || "");
|
||||
const site = siteByPath(u.searchParams.get("sitePath") || "");
|
||||
const themeName = u.searchParams.get("theme") || "default";
|
||||
const theme = themeByName(themeName);
|
||||
const params: Record<string, string> = {};
|
||||
@@ -475,6 +479,7 @@ async function handleApi(req: IncomingMessage, res: ServerResponse, path: string
|
||||
mod(2106, 4, "友情链接", "/link"),
|
||||
mod(2107, 5, "附件管理", "/attachment"),
|
||||
mod(2108, 6, "站点管理", "/site"),
|
||||
mod(2109, 7, "皮肤包管理", "/theme"),
|
||||
]));
|
||||
return true;
|
||||
}
|
||||
@@ -724,12 +729,12 @@ async function handleApi(req: IncomingMessage, res: ServerResponse, path: string
|
||||
const page = num(q(req, "page"), 1)!;
|
||||
const pageSize = num(q(req, "pageSize"), 10)!;
|
||||
let rows = db.attachments.filter(t => t.siteId === siteId);
|
||||
if (orphanOnly) rows = rows.filter(t => !t.articleId);
|
||||
if (orphanOnly) rows = rows.filter(t => !t.articleId && !t.refType); // B2 未使用语义
|
||||
const total = rows.length;
|
||||
const list = rows
|
||||
.slice((page - 1) * pageSize, page * pageSize)
|
||||
.map((t: MockAttachment) => ({ ...t, orphan: !t.articleId }));
|
||||
sendJson(res, ok({ total, list, orphanCount: db.attachments.filter(t => t.siteId === siteId && !t.articleId).length }));
|
||||
sendJson(res, ok({ total, list, unusedCount: db.attachments.filter(t => t.siteId === siteId && !t.articleId && !t.refType).length })); // B2:orphanCount→unusedCount
|
||||
return true;
|
||||
}
|
||||
if (method === "POST" && path === "/api/attachment/upload") {
|
||||
@@ -744,10 +749,14 @@ async function handleApi(req: IncomingMessage, res: ServerResponse, path: string
|
||||
db.files.set(url, { buffer: file.buffer, mime: file.mime, name: safeName });
|
||||
const siteId = num(q(req, "siteId"), 0) ?? num(body.toString("latin1").match(/name="siteId"\r\n\r\n(\d+)/)?.[1], 0)!;
|
||||
const articleId = num(q(req, "articleId")) ?? num(body.toString("latin1").match(/name="articleId"\r\n\r\n(\d+)/)?.[1], undefined);
|
||||
// B2:scene 初步标记(banner/link/site 白名单,其余忽略)
|
||||
const sceneRaw = q(req, "scene");
|
||||
const scene = ["banner", "link", "site"].includes(sceneRaw) ? sceneRaw : undefined;
|
||||
const row: MockAttachment = {
|
||||
id: nextId(db.attachments),
|
||||
siteId,
|
||||
articleId,
|
||||
refType: scene,
|
||||
fileName: safeName,
|
||||
url,
|
||||
fileSize: file.buffer.length,
|
||||
@@ -755,7 +764,7 @@ async function handleApi(req: IncomingMessage, res: ServerResponse, path: string
|
||||
createTime: now(),
|
||||
};
|
||||
db.attachments.push(row);
|
||||
sendJson(res, ok({ ...row, orphan: !row.articleId }));
|
||||
sendJson(res, ok({ ...row, orphan: !row.articleId && !row.refType }));
|
||||
return true;
|
||||
}
|
||||
if (method === "POST" && path === "/api/attachment/delete") {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"build:base": "vite build --mode base",
|
||||
"build:prod": "vite build --mode prod",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"api:gen": "alova gen -f"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* B5 栏目工作台组件行为(方案 P1-2):
|
||||
* ① 列表型栏目 → 右侧 ArticleListPanel 且 categoryId 正确传递;
|
||||
* ② 切换树节点 → 面板 watch categoryId 联动 reload;
|
||||
* ③ 单页型 → 摘要面板(含"编辑内容"入口)不渲染文章列表;
|
||||
* ④ 外链型 → linkUrl + 打开链接;
|
||||
* ⑤ 新建文章按钮 → 路由跳转带 categoryId query。
|
||||
*
|
||||
* 无 @vue/test-utils 依赖:createApp 手写挂载 + vi.mock 桩(树面板/排序弹窗/图标/确认框/api/router)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
const { pageMock, routerPush, openMock, saveMock } = vi.hoisted(() => ({
|
||||
pageMock: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
openMock: vi.fn(),
|
||||
saveMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
articleApi: {
|
||||
page: (query: any) => {
|
||||
pageMock(query);
|
||||
return { send: () => Promise.resolve({ list: [], total: 0 }) };
|
||||
},
|
||||
publish: () => ({ send: () => Promise.resolve({}) }),
|
||||
offline: () => ({ send: () => Promise.resolve({}) }),
|
||||
topmost: () => ({ send: () => Promise.resolve({}) }),
|
||||
delete: () => ({ send: () => Promise.resolve({}) }),
|
||||
},
|
||||
categoryApi: {
|
||||
tree: () => ({ send: () => Promise.resolve([]) }),
|
||||
save: (payload: any) => {
|
||||
saveMock(payload);
|
||||
return { send: () => Promise.resolve({}) };
|
||||
},
|
||||
delete: () => ({ send: () => Promise.resolve({}) }),
|
||||
},
|
||||
uploadUrl: () => "/api/attachment/upload",
|
||||
portalBase: (code: string) => `/portal/${code}`,
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRouter: () => ({ push: routerPush, replace: vi.fn() }),
|
||||
useRoute: () => ({ params: {}, query: {} }),
|
||||
}));
|
||||
|
||||
/** 树面板桩:把 emit 挂到全局,测试用它模拟"选中节点";捕获 props 供 treeApi 断言 */
|
||||
vi.mock("@/components/CategoryTreePanel.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["curTreeNode", "treeApi", "defaultSearchVisible", "showCloseButton"],
|
||||
emits: ["update:curTreeNode", "on-change", "on-tree-data"],
|
||||
setup(props, { emit, expose }) {
|
||||
(globalThis as any).__treeEmit = (node: any) => {
|
||||
emit("update:curTreeNode", node);
|
||||
emit("on-change", node);
|
||||
};
|
||||
(globalThis as any).__treeProps = props;
|
||||
// CategoryManage 会经模板 ref 调这些方法(真实组件的 exposed API)
|
||||
expose({ loadTree: () => {}, refreshTree: () => {}, reloadTree: () => {} });
|
||||
return () => h("div", { class: "tree-stub" }, "tree-stub");
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/CategorySortModal.vue", () => ({
|
||||
default: defineComponent({ props: ["visible", "parentId", "parentName", "children"], emits: ["saved"], render: () => null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/SvgIcon.vue", () => ({
|
||||
default: defineComponent({ props: ["name"], render: () => h("span", "icon") }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
|
||||
|
||||
import ArticleListPanel from "@/components/article/ArticleListPanel.vue";
|
||||
import CategoryManage from "@/views/cms/CategoryManage.vue";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { useCmsStore } from "@/store";
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
let currentStore: ReturnType<typeof useCmsStore> | null = null;
|
||||
|
||||
/** 挂载:真实 pinia(预置学校/站点上下文)+ 全局 view-ui-plus */
|
||||
function mountApp(root: Component) {
|
||||
const pinia = createPinia();
|
||||
const store = useCmsStore(pinia);
|
||||
currentStore = store;
|
||||
store.user = { superAdmin: true, sites: [] } as any;
|
||||
store.currentSchool = { schId: 1, schName: "演示学校", mainSiteId: 1, mainSiteCode: "demo1", mainSiteName: "演示主站" } as any;
|
||||
store.currentSite = { id: 1, code: "demo1", name: "演示站" } as any;
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp(root as any);
|
||||
app.use(pinia);
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
async function flush(frames = 5) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(globalThis as any).__treeEmit = undefined;
|
||||
(globalThis as any).__treeProps = undefined;
|
||||
currentStore = null;
|
||||
window.open = openMock as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () => {
|
||||
it("① 固定栏目模式:categoryId 正确传给查询且隐藏栏目下拉(仅剩状态筛选)", async () => {
|
||||
const state = reactive({ cid: 5, fixed: true });
|
||||
const host = defineComponent({
|
||||
setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }),
|
||||
});
|
||||
const el = mountApp(host);
|
||||
await flush();
|
||||
|
||||
expect(pageMock).toHaveBeenCalled();
|
||||
expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryId: 5 });
|
||||
// 栏目下拉隐藏(剩余 .ivu-select 为状态筛选与分页 sizer,占位文本不含"全部栏目")
|
||||
expect(el.textContent).not.toContain("全部栏目");
|
||||
expect([...el.querySelectorAll(".ivu-select-placeholder")].every(p => !p.textContent?.includes("全部栏目"))).toBe(true);
|
||||
});
|
||||
|
||||
it("② 切换树节点:categoryId 变化触发面板 reload(watch 联动)", async () => {
|
||||
const state = reactive({ cid: 5, fixed: true });
|
||||
const host = defineComponent({
|
||||
setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }),
|
||||
});
|
||||
mountApp(host);
|
||||
await flush();
|
||||
expect(pageMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
state.cid = 7;
|
||||
await flush();
|
||||
|
||||
expect(pageMock).toHaveBeenCalledTimes(2);
|
||||
expect(pageMock.mock.calls[1][0]).toMatchObject({ categoryId: 7 });
|
||||
});
|
||||
|
||||
it("⑤ 面板新建文章:路由跳转带 categoryId query", async () => {
|
||||
const state = reactive({ cid: 5, fixed: true });
|
||||
const host = defineComponent({
|
||||
setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }),
|
||||
});
|
||||
const el = mountApp(host);
|
||||
await flush();
|
||||
|
||||
const btn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("新建文章"));
|
||||
expect(btn).toBeTruthy();
|
||||
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article/edit?categoryId=5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("categoryWorkbench - CategoryManage 右侧类型化面板", () => {
|
||||
it("③ 单页型栏目:显示摘要面板(含编辑内容入口),不渲染文章列表", async () => {
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
(globalThis as any).__treeEmit({ id: 2, parentId: 0, name: "学校简介", type: 2, content: "<p>云海第一中学始建于 1958 年。</p>", sortNo: 0, status: 1 });
|
||||
await flush();
|
||||
|
||||
expect(el.textContent).toContain("学校简介");
|
||||
expect(el.textContent).toContain("云海第一中学始建于 1958 年");
|
||||
expect(el.textContent).toContain("编辑内容");
|
||||
expect(el.querySelector(".article-list-panel")).toBeNull();
|
||||
});
|
||||
|
||||
it("④ 外链型栏目:显示 linkUrl 与「打开链接」,点击新窗口打开", async () => {
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
(globalThis as any).__treeEmit({ id: 3, parentId: 0, name: "教育部链接", type: 3, linkUrl: "https://www.moe.gov.cn", sortNo: 0, status: 1 });
|
||||
await flush();
|
||||
|
||||
expect(el.textContent).toContain("https://www.moe.gov.cn");
|
||||
const openBtn = [...el.querySelectorAll("button, a")].find(b => b.textContent?.includes("打开链接"));
|
||||
expect(openBtn).toBeTruthy();
|
||||
openBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(openMock).toHaveBeenCalledWith("https://www.moe.gov.cn", "_blank");
|
||||
});
|
||||
|
||||
it("列表型栏目:右侧渲染 ArticleListPanel(categoryId=选中栏目)", async () => {
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
(globalThis as any).__treeEmit({ id: 5, parentId: 0, name: "新闻中心", type: 1, sortNo: 0, status: 1 });
|
||||
await flush();
|
||||
|
||||
expect(el.querySelector(".article-list-panel")).toBeTruthy();
|
||||
expect(pageMock).toHaveBeenCalled();
|
||||
expect(pageMock.mock.calls[0][0]).toMatchObject({ categoryId: 5 });
|
||||
});
|
||||
|
||||
it("未选中节点:右侧显示空态引导", async () => {
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
expect(el.textContent).toContain("选择左侧栏目");
|
||||
});
|
||||
});
|
||||
|
||||
describe("W2 栏目树修订(T-W2-06..10)", () => {
|
||||
const selectRoot = async () => {
|
||||
(globalThis as any).__treeEmit({ id: 0, name: "演示学校", virtual: true, children: [] });
|
||||
await flush();
|
||||
};
|
||||
|
||||
it("T-W2-06: 工具栏收敛——搜索/收起展开按钮移除(仅剩 4 个操作)", async () => {
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
expect(el.querySelector(".search-node-li")).toBeNull();
|
||||
expect(el.querySelectorAll(".opera-ul li")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("T-W2-07: 树桩收到带根数据——treeApi 返回首节点 id=0、name=学校名", async () => {
|
||||
mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
const props = (globalThis as any).__treeProps;
|
||||
expect(props?.treeApi).toBeTypeOf("function");
|
||||
const wrapped = await props.treeApi();
|
||||
expect(wrapped[0]).toMatchObject({ id: 0, name: "演示学校", virtual: true });
|
||||
expect(wrapped[0].children).toEqual([]);
|
||||
});
|
||||
|
||||
it("T-W2-08: 根上新增=顶级栏目(保存 payload parentId===0)", async () => {
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
await selectRoot();
|
||||
const addLi = el.querySelector(".add-node-li");
|
||||
addLi!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
// Modal 经 transfer 渲染到 body(不在挂载根内);首个 input 是禁用的"上级栏目"展示框,须按 placeholder 定位名称框
|
||||
const input = document.querySelector('.ivu-modal input[placeholder="栏目名称"]') as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input, "测试顶级栏目");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
const saveBtn = [...document.querySelectorAll(".ivu-modal button")].find(b => b.textContent?.includes("保存"));
|
||||
saveBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(saveMock).toHaveBeenCalledTimes(1);
|
||||
expect(saveMock.mock.calls[0][0]).toMatchObject({ parentId: 0, name: "测试顶级栏目" });
|
||||
});
|
||||
|
||||
it("T-W2-09: 根上编辑/删除被拒——仅警告、零请求", async () => {
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
await selectRoot();
|
||||
const warnSpy = vi.spyOn(Message, "warning");
|
||||
|
||||
const lis = [...el.querySelectorAll(".opera-ul li")];
|
||||
// 工具栏顺序:新增/编辑/排序/删除(搜索与收起展开已移除)
|
||||
lis[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(saveMock).not.toHaveBeenCalled();
|
||||
|
||||
lis[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(MyConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("T-W2-10: 站点切换根 label 跟随——切到子站后根 name=子站名", async () => {
|
||||
mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
const before = await (globalThis as any).__treeProps.treeApi();
|
||||
expect(before[0].name).toBe("演示学校");
|
||||
|
||||
currentStore!.setCurrentSite({ id: 2, code: "jdxy", name: "机械学院" } as any);
|
||||
await flush();
|
||||
const wrapped = await (globalThis as any).__treeProps.treeApi();
|
||||
expect(wrapped[0].name).toBe("机械学院");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* W4 向导地址预览联动(方案 §8.3 T-W4-18):
|
||||
* 第 3 步「门户地址预览」随上级与编码输入联动(校级 lockParent 模式预置上级=选上级)。
|
||||
* 挂载 SiteWizard(Modal transfer 到 body),桩掉主题三件套与配置表单。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
const themesMock = vi.hoisted(() => ({
|
||||
list: vi.fn(async () => [
|
||||
{
|
||||
name: "default",
|
||||
title: "云端默认",
|
||||
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||
params: [],
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
themeApi: { list: () => ({ send: themesMock.list }) },
|
||||
siteApi: { save: () => ({ send: async () => ({}) }) },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemePicker.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["themes", "modelValue"],
|
||||
emits: ["update:modelValue", "pick"],
|
||||
setup(_, { emit }) {
|
||||
(globalThis as any).__wizardPickTheme = (name: string) => emit("pick", name);
|
||||
return () => h("div", { class: "theme-picker-stub" }, "themes");
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
|
||||
default: defineComponent({ props: ["modelValue"], emits: ["update:modelValue"], render: () => null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||
default: defineComponent({ props: ["url", "tick"], render: () => h("div", "preview-stub") }),
|
||||
}));
|
||||
|
||||
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["modelValue"],
|
||||
methods: { validate: async () => true },
|
||||
render: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
import SiteWizard from "@/views/cms/SiteWizard.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 mountWizard(lockParentId?: number, secondMain = false) {
|
||||
const sites = [
|
||||
{ id: 1, code: "sch-3", path: "sch-3", parentSiteId: 0, schId: 1, schName: "第一中学", name: "第一中学门户", status: 1 },
|
||||
...(secondMain
|
||||
? [{ id: 2, code: "sch-8", path: "sch-8", parentSiteId: 0, schId: 2, schName: "第八中学", name: "第八中学门户", status: 1 }]
|
||||
: []),
|
||||
];
|
||||
const visible = ref(false); // 挂载后翻转以触发 SiteWizard 的 visible watch(真实打开路径)
|
||||
const pinia = createPinia();
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp(defineComponent({
|
||||
setup() {
|
||||
return () => h(SiteWizard, {
|
||||
visible: visible.value,
|
||||
sites,
|
||||
lockParentId,
|
||||
"onUpdate:visible": (v: boolean) => { visible.value = v; },
|
||||
});
|
||||
},
|
||||
}));
|
||||
app.use(pinia);
|
||||
app.use(ViewUIPlus);
|
||||
// SiteWizard 第 2 步的 FormItem 不在 Form 内(现状如此),jsdom 卸载时 view-ui-plus
|
||||
// 内部 removeField/colon 读取 undefined 崩溃 → 本测试用展示桩覆盖 FormItem
|
||||
app.component("FormItem", defineComponent({
|
||||
props: ["label", "prop", "labelWidth"],
|
||||
setup(props, { slots }) {
|
||||
return () => h("div", { class: "form-item-stub" }, [
|
||||
props.label ? h("label", props.label) : null,
|
||||
slots.default?.(),
|
||||
]);
|
||||
},
|
||||
}));
|
||||
app.mount(el);
|
||||
visible.value = true;
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
async function gotoStep3() {
|
||||
// step1 → step2 → step3(footer 按钮 transfer 到 body)
|
||||
const clickNext = async () => {
|
||||
await flush();
|
||||
const btn = [...document.querySelectorAll(".ivu-modal-footer button")]
|
||||
.find(b => b.textContent?.includes("下一步"));
|
||||
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
};
|
||||
await clickNext();
|
||||
await clickNext();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("SiteWizard 门户地址预览(T-W4-18)", () => {
|
||||
it("选上级(lockParent 预置 sch-3)+ 输入 jdxy → 预览 /portal/sch-3/jdxy/", async () => {
|
||||
mountWizard(1);
|
||||
await gotoStep3();
|
||||
|
||||
const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input, "jdxy");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(document.body.textContent).toContain("门户地址预览");
|
||||
expect(document.body.textContent).toContain("/portal/sch-3/jdxy/");
|
||||
});
|
||||
|
||||
it("改上级即变:换到另一主站 sch-8 → 预览 /portal/sch-8/jdxy/", async () => {
|
||||
mountWizard(2, true);
|
||||
await gotoStep3();
|
||||
|
||||
const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement;
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input, "jdxy");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(document.body.textContent).toContain("/portal/sch-8/jdxy/");
|
||||
expect(document.body.textContent).not.toContain("/portal/sch-3/jdxy/");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* 路径参数型 POST 接口的 siteId 透传回归(publish 曾因 postJSON 丢第三参,
|
||||
* 请求裸发 POST /api/article/publish/{id},后端报「缺少必填参数:siteId」)。
|
||||
* alova 实例与 host.config(顶层 document 依赖)均 mock,node 环境纯断言。
|
||||
* host.config 用 vi.hoisted 可变对象:assetUrl 用例需要切换 serviceHost 模拟生产 /yschcms。
|
||||
*/
|
||||
const confState = vi.hoisted(() => ({
|
||||
conf: { baseHost: "", ssoHost: "", serviceHost: "", isMock: false, isDebug: false },
|
||||
}));
|
||||
|
||||
vi.mock("@/assets/config/host.config", () => ({
|
||||
default: confState.conf,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/alova/alova-iview", () => ({
|
||||
alovaInstance: {
|
||||
Get: vi.fn(),
|
||||
Post: vi.fn(() => ({})),
|
||||
Put: vi.fn(() => ({})),
|
||||
Delete: vi.fn(() => ({})),
|
||||
},
|
||||
}));
|
||||
|
||||
import { articleApi, assetUrl, themeApi, themePreviewUrl } from "./cms";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
|
||||
const JSON_HEADERS = { "Content-Type": "application/json;charset=UTF-8" };
|
||||
|
||||
describe("cms.ts 路径参数型 POST 的 query 透传", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(alovaInstance.Post).mockClear();
|
||||
});
|
||||
|
||||
it("publish: siteId 以 query 参数随 Post config 发出", () => {
|
||||
articleApi.publish(5, 2);
|
||||
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/article/publish/2", undefined, {
|
||||
headers: JSON_HEADERS,
|
||||
params: { siteId: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("offline: 同款透传", () => {
|
||||
articleApi.offline(5, 3);
|
||||
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/article/offline/3", undefined, {
|
||||
headers: JSON_HEADERS,
|
||||
params: { siteId: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("topmost: siteId 与 enable 一并透传", () => {
|
||||
articleApi.topmost(5, 3, true);
|
||||
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/article/topmost/3", undefined, {
|
||||
headers: JSON_HEADERS,
|
||||
params: { siteId: 5, enable: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("save/delete: siteId 走 body,不带 params(守卫勿误伤)", () => {
|
||||
articleApi.delete({ id: 2, siteId: 5 });
|
||||
expect(alovaInstance.Post).toHaveBeenCalledWith("/api/article/delete", { id: 2, siteId: 5 }, {
|
||||
headers: JSON_HEADERS,
|
||||
params: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("assetUrl 站内资产路径补 serviceHost 前缀(线上主题截图破图回归)", () => {
|
||||
afterEach(() => {
|
||||
confState.conf.serviceHost = "";
|
||||
confState.conf.isMock = false;
|
||||
});
|
||||
|
||||
it("生产:/api 裸路径拼上 context-path(/yschcms)", () => {
|
||||
confState.conf.serviceHost = "/yschcms";
|
||||
expect(assetUrl("/api/theme/default/asset/screenshot.png")).toBe(
|
||||
"/yschcms/api/theme/default/asset/screenshot.png"
|
||||
);
|
||||
});
|
||||
|
||||
it("dev/mock:serviceHost 为空串时原样返回", () => {
|
||||
expect(assetUrl("/api/theme/default/asset/screenshot.png")).toBe(
|
||||
"/api/theme/default/asset/screenshot.png"
|
||||
);
|
||||
});
|
||||
|
||||
it("绝对地址(http/data/blob)不拼前缀", () => {
|
||||
confState.conf.serviceHost = "/yschcms";
|
||||
expect(assetUrl("https://cdn.example.com/x.png")).toBe("https://cdn.example.com/x.png");
|
||||
expect(assetUrl("data:image/png;base64,AAAA")).toBe("data:image/png;base64,AAAA");
|
||||
expect(assetUrl("blob:http://localhost/uuid")).toBe("blob:http://localhost/uuid");
|
||||
});
|
||||
|
||||
it("空值原样返回;isMock 时不拼 serviceHost", () => {
|
||||
expect(assetUrl("")).toBe("");
|
||||
confState.conf.serviceHost = "/yschcms";
|
||||
confState.conf.isMock = true;
|
||||
expect(assetUrl("/api/theme/default/asset/screenshot.png")).toBe(
|
||||
"/api/theme/default/asset/screenshot.png"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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)", () => {
|
||||
it("query 携带 sitePath(嵌套路径 URL 编码)与 p_ 前缀参数", () => {
|
||||
const url = themePreviewUrl("sch-3/jdxy", "t-demo-red", { primary: "#b01a2e" });
|
||||
expect(url).toContain("sitePath=sch-3%2Fjdxy");
|
||||
expect(url).toContain("theme=t-demo-red");
|
||||
expect(url).toContain("p_primary=%23b01a2e");
|
||||
expect(url).not.toContain("siteCode=");
|
||||
});
|
||||
});
|
||||
+50
-15
@@ -41,10 +41,11 @@ function get<T>(url: string, params?: IObj): Method {
|
||||
return alovaInstance.Get<T>(url, { params });
|
||||
}
|
||||
|
||||
/** POST JSON(@RequestBody) */
|
||||
function postJSON<T>(url: string, data?: unknown): Method {
|
||||
/** POST JSON(@RequestBody);params 为追加到 URL 的 query 参数(alova Post config 同 Get) */
|
||||
function postJSON<T>(url: string, data?: unknown, params?: IObj): Method {
|
||||
return alovaInstance.Post<T>(url, data, {
|
||||
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,6 +59,16 @@ export function portalBase(siteCode: string): string {
|
||||
return `${conf.isMock ? "" : conf.serviceHost}/portal/${siteCode}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端下发的站内资产路径补 serviceHost 前缀(如主题截图 /api/theme/{name}/asset/screenshot.png)。
|
||||
* 生产 context-path=/yschcms,裸 /api 绝对路径会打到网关根上 404(破图);
|
||||
* dev/mock 下 serviceHost 为空串,拼接后与原路径一致不受影响。
|
||||
*/
|
||||
export function assetUrl(url: string): string {
|
||||
if (!url || /^(https?:|data:|blob:)/i.test(url)) return url;
|
||||
return `${conf.isMock ? "" : conf.serviceHost}${url}`;
|
||||
}
|
||||
|
||||
class AuthApi {
|
||||
/** 登录态:用户 + 可管理站点 + 是否超管 */
|
||||
getUser() {
|
||||
@@ -137,21 +148,15 @@ class ArticleApi {
|
||||
|
||||
/** 发布(publishTime 为未来时间即定时发布) */
|
||||
publish(siteId: number, id: number) {
|
||||
return postJSON<void>(`${ARTICLE}/publish/${id}`, undefined, {
|
||||
params: { siteId },
|
||||
});
|
||||
return postJSON<void>(`${ARTICLE}/publish/${id}`, undefined, { siteId });
|
||||
}
|
||||
|
||||
offline(siteId: number, id: number) {
|
||||
return postJSON<void>(`${ARTICLE}/offline/${id}`, undefined, {
|
||||
params: { siteId },
|
||||
});
|
||||
return postJSON<void>(`${ARTICLE}/offline/${id}`, undefined, { siteId });
|
||||
}
|
||||
|
||||
topmost(siteId: number, id: number, enable: boolean) {
|
||||
return postJSON<void>(`${ARTICLE}/topmost/${id}`, undefined, {
|
||||
params: { siteId, enable },
|
||||
});
|
||||
return postJSON<void>(`${ARTICLE}/topmost/${id}`, undefined, { siteId, enable });
|
||||
}
|
||||
|
||||
delete(data: DeleteRequest) {
|
||||
@@ -161,7 +166,7 @@ class ArticleApi {
|
||||
|
||||
class AttachmentApi {
|
||||
page(siteId: number, orphanOnly: boolean, page: number, pageSize: number) {
|
||||
return get<PageResult<AttachmentVo> & { orphanCount: number }>(`${ATTACHMENT}/page`, {
|
||||
return get<PageResult<AttachmentVo> & { unusedCount: number }>(`${ATTACHMENT}/page`, {
|
||||
siteId,
|
||||
orphanOnly,
|
||||
page,
|
||||
@@ -207,11 +212,41 @@ class ThemeApi {
|
||||
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_ 前缀) */
|
||||
export function themePreviewUrl(siteCode: string, theme: string, themeParams: Record<string, string> = {}): string {
|
||||
const params = new URLSearchParams({ siteCode, theme, page: "home" });
|
||||
/** 主题预览 iframe URL(方案 §5.5:/api/theme/preview,CAS 会话同源,参数 p_ 前缀)。
|
||||
* W4:内容源站点参数 sitePath(嵌套路径语义,后端 siteSnapshotByPath 解析)。 */
|
||||
export function themePreviewUrl(sitePath: string, theme: string, themeParams: Record<string, string> = {}): string {
|
||||
const params = new URLSearchParams({ sitePath, theme, page: "home" });
|
||||
Object.keys(themeParams).forEach(k => params.set(`p_${k}`, themeParams[k]));
|
||||
return `${conf.isMock ? "" : conf.serviceHost}/api/theme/preview?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { ref, nextTick, useTemplateRef } from "vue";
|
||||
import { cloneDeep } from "lodash";
|
||||
import MyTree from '@/components/MyTree.vue'
|
||||
import { initialSelectId } from '@/utils/categoryTree'
|
||||
|
||||
const DEFAULT_CACHE_KEY = 'cms.category-tree.selected-node'
|
||||
|
||||
@@ -112,10 +113,12 @@ function resolveInitialSelectId(list: any[], preferredId?: number | null) {
|
||||
const preferred = findNodeById(list, preferredId);
|
||||
if (preferred) return preferred.id;
|
||||
|
||||
// W2 虚拟根(D5):默认下钻到虚拟根下首个真实栏目,绝不落到根 id=0;
|
||||
// 缓存恢复优先于下钻(B5:刷新后仍选中缓存的 REAL 节点)
|
||||
const cached = findNodeById(list, getCachedSelectedNodeId());
|
||||
if (cached) return cached.id;
|
||||
|
||||
return list[0]?.id ?? null;
|
||||
return initialSelectId(list) ?? list[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async function loadTree(preferredId?: number | null) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
/* 学校专属管理壳(docs/09 §3.3,由 layout.vue 改造,二级壳 /sch/:schCode):
|
||||
* header 左侧「←学校列表」+ 校名徽标/快速切换下拉;面包屑首段=校名;
|
||||
* 面包屑栏右侧「站点切换(校内主站+院系子站)+ 预览门户」承接院系子站维度。
|
||||
* header 左侧仅超管可见「↩返回学校列表」图标(校级账号不显示);
|
||||
* 面包屑首段=校名(落点为院系子站时后跟当前站点名),超管在校名前多一段可点「学校列表」(与返回图标同效)。
|
||||
* 学校/站点切换一律离壳完成:学校在 /workbench,站点在站点管理行「进入管理」。
|
||||
* 菜单/高亮/子页面面包屑逻辑与原 layout 一致,布局数据来自 store.session。 */
|
||||
import { ref, computed, watch, onBeforeMount } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
@@ -9,7 +10,6 @@ import { Message } from "view-ui-plus";
|
||||
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { portalBase } from "@/api/module/cms";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -31,13 +31,18 @@ const modList = computed(() => store.session.modList || []);
|
||||
|
||||
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
|
||||
|
||||
/** 当前学校 + 校内站点(主站在前,切换下拉用) */
|
||||
/** 当前学校上下文(面包屑首段) */
|
||||
const school = computed(() => store.currentSchool);
|
||||
const schoolSites = computed(() => store.schoolSites);
|
||||
/** 快速切换学校候选 = 可管理顶层站点(主站) */
|
||||
const schoolOptions = computed(() =>
|
||||
((store.user?.sites || []) as any[]).filter((s: any) => !s.parentSiteId)
|
||||
);
|
||||
const isSuper = computed(() => store.isSuperAdmin);
|
||||
|
||||
/** 面包屑校名后缀:操作落点非主站时跟随展示当前站点名(院系子站管理可见性,
|
||||
* 站点切换入口在站点管理行「进入管理」,壳上不再放切换器) */
|
||||
const currentSiteSuffix = computed(() => {
|
||||
const s = store.currentSite;
|
||||
const sc = school.value;
|
||||
if (!s || !sc || s.id === sc.mainSiteId) return "";
|
||||
return s.name || "";
|
||||
});
|
||||
|
||||
const flaten = (tree: any[], pid = 0): any[] => {
|
||||
const arr: any[] = [];
|
||||
@@ -187,29 +192,6 @@ function goWorkbench() {
|
||||
router.push("/workbench");
|
||||
}
|
||||
|
||||
/** 快速切换学校:守卫负责恢复上下文,进入后落在文章列表 */
|
||||
function onSchoolChange(code: string) {
|
||||
if (!code || code === store.schoolCode) return;
|
||||
router.push(`/sch/${code}/article`);
|
||||
}
|
||||
|
||||
function onSiteChange(siteId: number) {
|
||||
const site = schoolSites.value.find((s: any) => s.id === siteId);
|
||||
if (site) {
|
||||
store.setCurrentSite({ id: site.id, code: site.code, name: site.name });
|
||||
}
|
||||
}
|
||||
|
||||
function openPortal() {
|
||||
if (!store.currentSite) return;
|
||||
if (config.isMock) {
|
||||
// mock 模式:SPA 内部门户预览(免依赖后端 Thymeleaf 门户)
|
||||
router.push(`/sch/${store.schoolCode}/portal-preview`);
|
||||
} else {
|
||||
window.open(`${portalBase(store.currentSite.code)}/`, "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动引导期守卫放行的深链:user 数据就绪后兜底校验/恢复学校上下文 */
|
||||
watch(
|
||||
() => store.user,
|
||||
@@ -263,28 +245,18 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<a class="fl align-middle back-workbench" href="javascript:void(0);" title="返回学校列表" @click="goWorkbench">
|
||||
<Icon type="ios-arrow-back" />
|
||||
学校列表
|
||||
<!-- 返回学校列表:仅超管显示(校级账号固定本校,无需返回入口) -->
|
||||
<a
|
||||
v-if="isSuper"
|
||||
class="fl align-middle back-workbench"
|
||||
href="javascript:void(0);"
|
||||
title="返回学校列表"
|
||||
@click="goWorkbench"
|
||||
>
|
||||
<Icon type="ios-undo" :size="18" />
|
||||
</a>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<div v-if="isSuper" class="delimiter fl align-middle"></div>
|
||||
<h4 class="fl align-middle">{{ appName }}</h4>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<!-- 校名徽标 + 快速切换(当前学校常驻 header) -->
|
||||
<div class="school-badge fl align-middle" v-if="school">
|
||||
<Icon type="ios-school" :size="16" />
|
||||
<Select
|
||||
size="small"
|
||||
:model-value="school.mainSiteCode"
|
||||
class="school-select"
|
||||
transfer
|
||||
@on-change="onSchoolChange"
|
||||
>
|
||||
<Option v-for="s in schoolOptions" :key="s.id" :value="s.code">
|
||||
{{ s.schName || s.name }}
|
||||
</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="header-aside fr">
|
||||
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
|
||||
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
|
||||
@@ -335,10 +307,12 @@ watch(
|
||||
|
||||
<div class="path-nav" :style="pathNavStyle">
|
||||
<Breadcrumb separator=">">
|
||||
<!-- 面包屑首段 = 学校上下文(常驻) -->
|
||||
<!-- 超管:校名前多一段可点「学校列表」,与 header 返回图标同效(回工作台切换学校) -->
|
||||
<BreadcrumbItem v-if="isSuper" to="/workbench">学校列表</BreadcrumbItem>
|
||||
<!-- 面包屑首段 = 学校上下文(常驻);落点为院系子站时跟出当前站点名 -->
|
||||
<BreadcrumbItem v-if="school">
|
||||
<Icon type="ios-school" />
|
||||
{{ school.schName || school.mainSiteName }}
|
||||
{{ school.schName || school.mainSiteName }}<template v-if="currentSiteSuffix"> · {{ currentSiteSuffix }}</template>
|
||||
</BreadcrumbItem>
|
||||
<template v-for="(item, index) in navPath" :key="index">
|
||||
<BreadcrumbItem v-if="index != navPath.length - 1" :to="item.page">
|
||||
@@ -347,26 +321,6 @@ watch(
|
||||
<BreadcrumbItem v-else>{{ item.name }}</BreadcrumbItem>
|
||||
</template>
|
||||
</Breadcrumb>
|
||||
<!-- 站点上下文(校内:主站 + 院系子站;切换后各页面即时跟随) -->
|
||||
<div class="cms-site-bar fr">
|
||||
<span class="site-label">站点:</span>
|
||||
<Select
|
||||
size="small"
|
||||
:model-value="store.currentSite?.id"
|
||||
:placeholder="schoolSites.length ? '选择站点' : '暂无可管理站点'"
|
||||
style="width: 200px"
|
||||
transfer
|
||||
@on-change="onSiteChange"
|
||||
>
|
||||
<Option v-for="opt in schoolSites" :key="opt.id" :value="opt.id">
|
||||
{{ opt.name }}{{ opt.parentSiteId ? "(院系)" : "" }}
|
||||
</Option>
|
||||
</Select>
|
||||
<Button size="small" type="primary" ghost style="margin-left: 10px" @click="openPortal">
|
||||
<Icon type="ios-eye" />
|
||||
预览门户
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade_apps">
|
||||
@@ -401,37 +355,15 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 返回学校列表 + 校名徽标:header 常驻学校上下文(docs/09 §3.3) */
|
||||
/* 超管返回学校列表图标:header 常驻入口(校级账号不渲染) */
|
||||
.back-workbench {
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
.school-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
.school-select {
|
||||
width: 180px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
/* 站点切换条:挂在面包屑条右侧,沿用 path-nav 白底视觉 */
|
||||
.cms-site-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
margin-right: 24px;
|
||||
|
||||
.site-label {
|
||||
color: #777;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
.no-site-tip {
|
||||
height: 60vh;
|
||||
display: flex;
|
||||
@@ -440,4 +372,12 @@ watch(
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* W2(D6):左树高度链修复——UserBase App.vue:222-224 同款覆盖式。
|
||||
layout.css 全局 .main-wrapper 只有 min-height:100% 无 height,
|
||||
子级 .tree-wrapper{height:100%} 因父链高度 auto 解析失败,栏目树缩在一起。
|
||||
scoped 生效前提:.base-wrap-main/.main-wrapper 是本组件模板内节点(:348-349) */
|
||||
.base-wrap-main .main-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message, Tag } from "view-ui-plus";
|
||||
import Utils from "@/utils/index";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { articleApi, categoryApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { ArticlePageItem, CategoryTreeNode } from "@/types/cms";
|
||||
|
||||
/**
|
||||
* 文章列表面板(B5 栏目工作台组件化,自 ArticleList.vue 原样抽出):
|
||||
* - 无 props = 全局检索行为(ArticleList.vue 薄壳引用,路由/搜索参数兼容);
|
||||
* - fixedCategory + categoryId = 固定栏目模式:隐藏栏目下拉、查询强制带 categoryId,
|
||||
* categoryId 变化 watch 联动 reload(树节点切换;拖拽排序不改变 id 不触发)。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
categoryId?: number;
|
||||
fixedCategory?: boolean;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const store = useCmsStore();
|
||||
|
||||
const rows = ref<ArticlePageItem[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||
|
||||
const search = reactive({
|
||||
categoryId: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
keyword: "",
|
||||
});
|
||||
|
||||
/** 栏目下拉(树拍平缩进展示;固定栏目模式不加载不显示) */
|
||||
const categoryOptions = ref<{ id: number; label: string }[]>([]);
|
||||
|
||||
const statusDict = [
|
||||
{ value: 0, label: "草稿", color: "default" },
|
||||
{ value: 1, label: "已发布", color: "success" },
|
||||
{ value: 2, label: "已下架", color: "warning" },
|
||||
];
|
||||
const statusText = (s: number) => statusDict.find(i => i.value === s)?.label || "未知";
|
||||
const statusColor = (s: number) => statusDict.find(i => i.value === s)?.color || "default";
|
||||
|
||||
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true });
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "标题",
|
||||
key: "title",
|
||||
minWidth: 260,
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) =>
|
||||
h("span", null, [
|
||||
h(
|
||||
"a",
|
||||
{
|
||||
style: { color: "#2d8cf0", cursor: "pointer", marginRight: "6px" },
|
||||
onClick: () => goEdit(p.row.id),
|
||||
},
|
||||
p.row.title
|
||||
),
|
||||
p.row.topmost === 1 ? h(Tag, { color: "error" }, () => "置顶") : null,
|
||||
]),
|
||||
},
|
||||
{ title: "栏目", key: "categoryName", width: 140, tooltip: true },
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h(Tag, { color: statusColor(p.row.status) }, () => statusText(p.row.status)),
|
||||
},
|
||||
{
|
||||
title: "发布时间",
|
||||
key: "publishTime",
|
||||
width: 170,
|
||||
render: (h: any, p: any) => h("span", p.row.publishTime || "-"),
|
||||
},
|
||||
{ title: "浏览量", key: "viewCount", width: 90, align: "center" },
|
||||
{ title: "创建时间", key: "createTime", width: 170 },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 190,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const ops: any[] = [
|
||||
Utils.operatingHandle(h, "编辑", "icon-edit", () => goEdit(p.row.id)),
|
||||
];
|
||||
if (p.row.status !== 1) {
|
||||
ops.push(Utils.operatingHandle(h, "发布", "icon-apps", () => doPublish(p.row)));
|
||||
}
|
||||
if (p.row.status === 1) {
|
||||
ops.push(Utils.operatingHandle(h, "下架", "icon-stop", () => doOffline(p.row)));
|
||||
}
|
||||
ops.push(
|
||||
Utils.operatingHandle(
|
||||
h,
|
||||
p.row.topmost === 1 ? "取消置顶" : "置顶",
|
||||
"icon-saoba",
|
||||
() => doTopmost(p.row)
|
||||
),
|
||||
Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row))
|
||||
);
|
||||
return ops;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function flattenCategories(tree: CategoryTreeNode[], depth = 0) {
|
||||
const list: { id: number; label: string }[] = [];
|
||||
tree.forEach(node => {
|
||||
list.push({ id: node.id!, label: `${" ".repeat(depth)}${node.name}` });
|
||||
if (node.children?.length) {
|
||||
list.push(...flattenCategories(node.children, depth + 1));
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
if (!store.siteId || props.fixedCategory) return;
|
||||
const tree = await categoryApi.tree(store.siteId).send();
|
||||
categoryOptions.value = flattenCategories(tree || []);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!store.siteId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const page = await articleApi
|
||||
.page({
|
||||
siteId: store.siteId,
|
||||
categoryId: props.fixedCategory ? props.categoryId : search.categoryId,
|
||||
status: search.status,
|
||||
keyword: search.keyword || undefined,
|
||||
page: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
.send();
|
||||
rows.value = page?.list || [];
|
||||
total.value = page?.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pageSizeUpdate(size: number) {
|
||||
pageSize.value = size;
|
||||
localStorage.setItem("customPageCount", String(size));
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
|
||||
/** 固定栏目模式下新建文章带 categoryId 预选(ArticleEdit onMounted 回填) */
|
||||
function goEdit(id?: number) {
|
||||
const code = store.schoolCode;
|
||||
if (!code) {
|
||||
router.push("/workbench");
|
||||
return;
|
||||
}
|
||||
const query = props.fixedCategory && props.categoryId ? `?categoryId=${props.categoryId}` : "";
|
||||
router.push(id ? `/sch/${code}/article/edit/${id}${query}` : `/sch/${code}/article/edit${query}`);
|
||||
}
|
||||
|
||||
async function doPublish(row: ArticlePageItem) {
|
||||
await articleApi.publish(store.siteId!, row.id).send();
|
||||
Message.success("发布成功!");
|
||||
run();
|
||||
}
|
||||
|
||||
async function doOffline(row: ArticlePageItem) {
|
||||
await articleApi.offline(store.siteId!, row.id).send();
|
||||
Message.success("已下架");
|
||||
run();
|
||||
}
|
||||
|
||||
async function doTopmost(row: ArticlePageItem) {
|
||||
await articleApi.topmost(store.siteId!, row.id, row.topmost !== 1).send();
|
||||
Message.success(row.topmost === 1 ? "已取消置顶" : "已置顶");
|
||||
run();
|
||||
}
|
||||
|
||||
function doDelete(row: ArticlePageItem) {
|
||||
MyConfirm({
|
||||
title: "删除文章",
|
||||
msg: `确定删除「${row.title}」吗?`,
|
||||
info: "删除后正文图片将转为未使用附件,可在附件管理中清理",
|
||||
async onOk() {
|
||||
await articleApi.delete({ id: row.id, siteId: store.siteId! }).send();
|
||||
Message.success("操作成功!");
|
||||
if (rows.value.length <= 1 && pageNum.value > 1) pageNum.value -= 1;
|
||||
run();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories();
|
||||
await run();
|
||||
});
|
||||
|
||||
// 树节点切换联动(B5 红线:仅 watch categoryId,拖拽排序不经过此通道)
|
||||
watch(() => props.categoryId, async () => {
|
||||
pageNum.value = 1;
|
||||
await run();
|
||||
});
|
||||
|
||||
// 站点/学校上下文切换即时重载(docs/09 §3.3,修复切换不刷新缺陷)
|
||||
watch(() => store.siteId, async () => {
|
||||
await loadCategories();
|
||||
pageNum.value = 1;
|
||||
await run();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="article-list-panel">
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="goEdit()">
|
||||
<Icon type="ios-add" />
|
||||
新建文章
|
||||
</Button>
|
||||
</div>
|
||||
<div class="search-wrap fix">
|
||||
<Select
|
||||
v-if="!fixedCategory"
|
||||
v-model="search.categoryId"
|
||||
class="search-wrap_sel"
|
||||
clearable
|
||||
placeholder="全部栏目"
|
||||
@on-change="pageNum = 1; run()"
|
||||
>
|
||||
<Option v-for="opt in categoryOptions" :key="opt.id" :value="opt.id">
|
||||
{{ opt.label }}
|
||||
</Option>
|
||||
</Select>
|
||||
<Select
|
||||
v-model="search.status"
|
||||
class="search-wrap_sel"
|
||||
:class="{ 'ml-12': !fixedCategory }"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
@on-change="pageNum = 1; run()"
|
||||
>
|
||||
<Option v-for="s in statusDict" :key="s.value" :value="s.value">{{ s.label }}</Option>
|
||||
</Select>
|
||||
<Input
|
||||
search
|
||||
enter-button
|
||||
class="fr"
|
||||
placeholder="标题关键字"
|
||||
style="width: 220px"
|
||||
v-model="search.keyword"
|
||||
@on-search="pageNum = 1; run()"
|
||||
@on-clear="pageNum = 1; run()"
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无文章"
|
||||
/>
|
||||
<div class="page-panel">
|
||||
<Page
|
||||
class="page-box fr"
|
||||
:total="total"
|
||||
:current="pageNum"
|
||||
:page-size="pageSize"
|
||||
:page-size-opts="[10, 20, 30, 40]"
|
||||
@on-page-size-change="pageSizeUpdate"
|
||||
@on-change="(p: number) => { pageNum = p; run(); }"
|
||||
placement="top"
|
||||
show-total
|
||||
show-sizer
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-wrap .search-wrap_sel {
|
||||
width: 180px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ThemeVo } from "@/types/cms";
|
||||
import { assetUrl } from "@/api/module/cms";
|
||||
|
||||
/**
|
||||
* 主题卡片单选(方案 §6.3):建站向导①与站点编辑共用。
|
||||
* 展示 screenshot + 名称/描述;选中高亮;卡片布局在父级控制宽度。
|
||||
* 截图 src 经 assetUrl 补 serviceHost 前缀(生产 context-path=/yschcms,裸 /api 会 404 破图)。
|
||||
*/
|
||||
defineProps<{
|
||||
themes: ThemeVo[];
|
||||
@@ -25,7 +27,7 @@ const emit = defineEmits<{
|
||||
:class="{ selected: modelValue === t.name }"
|
||||
@click="emit('update:modelValue', t.name)"
|
||||
>
|
||||
<img class="theme-shot" :src="t.screenshotUrl" :alt="t.title" loading="lazy" />
|
||||
<img class="theme-shot" :src="assetUrl(t.screenshotUrl)" :alt="t.title" loading="lazy" />
|
||||
<div class="theme-info">
|
||||
<div class="theme-title">
|
||||
{{ t.title }}
|
||||
|
||||
@@ -8,21 +8,21 @@ import { FormModelValue } from "@/utils/themeParamForm";
|
||||
* no-store),参数变更由父级 debounce 后更新 src。内容源站点不存在时提示。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 预览内容源站点编码(取该站栏目/文章/轮播渲染) */
|
||||
siteCode?: string;
|
||||
/** 预览内容源站点门户路径(W4:sitePath 语义,取该站栏目/文章/轮播渲染) */
|
||||
sitePath?: string;
|
||||
theme?: string;
|
||||
themeParams?: FormModelValue;
|
||||
}>();
|
||||
|
||||
const src = computed(() => {
|
||||
if (!props.siteCode || !props.theme) return "";
|
||||
return themePreviewUrl(props.siteCode, props.theme, props.themeParams || {});
|
||||
if (!props.sitePath || !props.theme) return "";
|
||||
return themePreviewUrl(props.sitePath, props.theme, props.themeParams || {});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-preview">
|
||||
<template v-if="!siteCode || !theme">
|
||||
<template v-if="!sitePath || !theme">
|
||||
<div class="preview-empty">
|
||||
无可用内容源站点 —— 预览需要借用一个已有站点的内容
|
||||
<p class="hint">请先创建/启用一个站点(或初始化演示数据)后再预览</p>
|
||||
|
||||
@@ -99,6 +99,13 @@ export const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/cms/SiteManage.vue"),
|
||||
meta: { title: "站点管理", modId: 6 },
|
||||
},
|
||||
{
|
||||
// W1:皮肤包管理(平台模块表注册前由站点管理按钮兜底可达,R3)
|
||||
path: "theme",
|
||||
name: "ThemeManage",
|
||||
component: () => import("@/views/cms/ThemeManage.vue"),
|
||||
meta: { title: "皮肤包管理", modId: 7 },
|
||||
},
|
||||
{
|
||||
// 门户预览(SPA 内嵌简版门户;真实门户由后端 Thymeleaf 提供)
|
||||
path: "portal-preview",
|
||||
@@ -116,6 +123,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
{ path: "/link", redirect: legacyRedirect("link") },
|
||||
{ path: "/attachment", redirect: legacyRedirect("attachment") },
|
||||
{ path: "/site", redirect: legacyRedirect("site") },
|
||||
{ path: "/theme", redirect: legacyRedirect("theme") },
|
||||
{ path: "/portal-preview", redirect: legacyRedirect("portal-preview") },
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
|
||||
+2
-2
@@ -85,7 +85,7 @@ export const useCmsStore = defineStore("cmsStore", {
|
||||
return this.currentSite;
|
||||
},
|
||||
/** 进入学校上下文:校卡 + 默认站点(记住的站点仍属本校则沿用,否则主站) */
|
||||
enterSchool(school: CurrentSchool, sites: { id: number; code: string; name: string; parentSiteId?: number }[]) {
|
||||
enterSchool(school: CurrentSchool, sites: { id: number; code: string; path?: string; name: string; parentSiteId?: number }[]) {
|
||||
this.setCurrentSchool(school);
|
||||
const remembered = this.currentSite;
|
||||
const belongs = remembered && sites.some(s => s.id === remembered.id);
|
||||
@@ -94,7 +94,7 @@ export const useCmsStore = defineStore("cmsStore", {
|
||||
}
|
||||
const main = sites.find(s => s.id === school.mainSiteId) || sites[0];
|
||||
if (main) {
|
||||
this.setCurrentSite({ id: main.id, code: main.code, name: main.name });
|
||||
this.setCurrentSite({ id: main.id, code: main.code, path: main.path || main.code, name: main.name });
|
||||
} else {
|
||||
// 该校无可管理站点:清掉他校残留,避免页面串数据
|
||||
this.setCurrentSite(null);
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface AuthUserInfo {
|
||||
export interface SiteVo {
|
||||
id: number;
|
||||
code: string;
|
||||
/** 门户路径(W4:顶层=code;子站=父path/code,/portal/{path}/ 唯一标识) */
|
||||
path?: string;
|
||||
/** 上级站点ID(0=顶层校站点;院系子站挂主站 id) */
|
||||
parentSiteId?: number;
|
||||
/** 上级站点名称(列表展示用,服务端装配) */
|
||||
@@ -44,6 +46,8 @@ export interface SiteVo {
|
||||
export interface CurrentSite {
|
||||
id: number;
|
||||
code: string;
|
||||
/** 门户路径(W4:嵌套 path) */
|
||||
path?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -53,6 +57,8 @@ export interface CurrentSchool {
|
||||
schName?: string;
|
||||
mainSiteId: number;
|
||||
mainSiteCode: string;
|
||||
/** 主站门户路径(W4:/portal/{mainSitePath}/) */
|
||||
mainSitePath?: string;
|
||||
mainSiteName: string;
|
||||
}
|
||||
|
||||
@@ -62,6 +68,8 @@ export interface SchoolCard {
|
||||
schName?: string;
|
||||
mainSiteId: number;
|
||||
mainSiteCode: string;
|
||||
/** 主站门户路径(W4:/portal/{mainSitePath}/) */
|
||||
mainSitePath?: string;
|
||||
mainSiteName: string;
|
||||
theme?: string;
|
||||
siteCount: number;
|
||||
@@ -173,11 +181,14 @@ export interface AttachmentVo {
|
||||
id: number;
|
||||
siteId: number;
|
||||
articleId?: number;
|
||||
/** 场景引用类型(B2):banner/link/site;空=无场景引用 */
|
||||
refType?: string;
|
||||
fileName: string;
|
||||
url: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
createTime?: string;
|
||||
/** 未使用(B2 语义:无文章引用且无场景引用) */
|
||||
orphan: boolean;
|
||||
}
|
||||
|
||||
@@ -258,4 +269,12 @@ export interface ThemeVo {
|
||||
/** 预览图(管理端资产代理 /api/theme/{name}/asset/screenshot.png) */
|
||||
screenshotUrl: string;
|
||||
params: ThemeParamVo[];
|
||||
/** 包来源(W1):builtin=内置只读 / external=外部可管 */
|
||||
source?: string;
|
||||
/** 被用站点数(W1,删除保护口径与后端一致) */
|
||||
inUseCount?: number;
|
||||
/** 是否可编辑/删除(W1:仅外部包) */
|
||||
editable?: boolean;
|
||||
/** 引用站点名列表(W1:删除保护提示/tooltip) */
|
||||
usedBySiteNames?: string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* W2 栏目树修订:虚拟根装配纯函数(方案 §6.3 T-W2-01..05)。
|
||||
* rootLabelOf:根 label 随站点上下文(主站→学校名,缺省回退 mainSiteName;子站→子站名);
|
||||
* wrapTreeWithRoot:单根包装 {id:0,name:label,virtual:true,children};
|
||||
* initialSelectId:虚拟根下钻首个真实栏目 id,空树 null。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialSelectId, rootLabelOf, wrapTreeWithRoot } from "@/utils/categoryTree";
|
||||
|
||||
const school = (overrides: Partial<any> = {}) => ({
|
||||
schId: 1,
|
||||
schName: "演示学校",
|
||||
mainSiteId: 1,
|
||||
mainSiteCode: "demo1",
|
||||
mainSiteName: "演示主站",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("rootLabelOf(T-W2-01..03)", () => {
|
||||
it("T-W2-01: 主站上下文 → schName", () => {
|
||||
expect(rootLabelOf({ currentSchool: school(), currentSite: { id: 1, code: "demo1", name: "演示主站" } }))
|
||||
.toBe("演示学校");
|
||||
});
|
||||
|
||||
it("T-W2-02: 主站 schName 缺省 → 回退 mainSiteName", () => {
|
||||
expect(rootLabelOf({ currentSchool: school({ schName: "" }), currentSite: { id: 1, code: "demo1", name: "演示主站" } }))
|
||||
.toBe("演示主站");
|
||||
expect(rootLabelOf({ currentSchool: school({ schName: undefined as any }), currentSite: { id: 1, code: "demo1", name: "演示主站" } }))
|
||||
.toBe("演示主站");
|
||||
});
|
||||
|
||||
it("T-W2-03: 子站上下文 → 子站名", () => {
|
||||
expect(rootLabelOf({ currentSchool: school(), currentSite: { id: 2, code: "jdxy", name: "机械学院" } }))
|
||||
.toBe("机械学院");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapTreeWithRoot(T-W2-04)", () => {
|
||||
it("返回单根虚拟节点,原树挂 children", () => {
|
||||
const tree = [
|
||||
{ id: 5, parentId: 0, name: "新闻中心", children: [] },
|
||||
{ id: 6, parentId: 0, name: "学校简介", children: [] },
|
||||
];
|
||||
const root = wrapTreeWithRoot(tree, "演示学校");
|
||||
expect(root).toMatchObject({ id: 0, name: "演示学校", virtual: true });
|
||||
expect(root.children).toHaveLength(2);
|
||||
expect(root.children[0].id).toBe(5);
|
||||
});
|
||||
|
||||
it("空树也有根(children=[])", () => {
|
||||
const root = wrapTreeWithRoot([], "演示学校");
|
||||
expect(root).toMatchObject({ id: 0, name: "演示学校", virtual: true, children: [] });
|
||||
expect(wrapTreeWithRoot(null as any, "演示学校").children).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialSelectId(T-W2-05)", () => {
|
||||
it("虚拟根下钻首个真实栏目 id(非 0)", () => {
|
||||
const root = wrapTreeWithRoot([
|
||||
{ id: 5, parentId: 0, name: "新闻中心", children: [] },
|
||||
{ id: 6, parentId: 0, name: "学校简介", children: [] },
|
||||
], "演示学校");
|
||||
expect(initialSelectId(root)).toBe(5);
|
||||
});
|
||||
|
||||
it("空树 → null", () => {
|
||||
expect(initialSelectId(wrapTreeWithRoot([], "演示学校"))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* W2 栏目树修订:虚拟根装配纯函数(方案 §6.4 步骤 1)。
|
||||
* 虚拟根约定(D5):id=0 的前端虚拟节点包裹真实栏目树;新增子栏目→parentId=0(顶级)、
|
||||
* 子栏目排序→parentId=0、编辑/删除→拒绝并提示(CategoryManage 既有 !node?.id 守卫天然拦截)。
|
||||
*/
|
||||
import type { CurrentSchool, CurrentSite } from "@/types/cms";
|
||||
|
||||
/** 根节点 label:主站上下文=学校名(schName 缺省回退 mainSiteName);子站上下文=该子站名 */
|
||||
export function rootLabelOf(ctx: {
|
||||
currentSchool: CurrentSchool | null;
|
||||
currentSite: CurrentSite | null;
|
||||
}): string {
|
||||
const school = ctx.currentSchool;
|
||||
const site = ctx.currentSite;
|
||||
if (site && school && site.id !== school.mainSiteId) {
|
||||
return site.name || "";
|
||||
}
|
||||
const label = school?.schName?.trim();
|
||||
return label || school?.mainSiteName || "";
|
||||
}
|
||||
|
||||
/** 把真实栏目树包装到虚拟根下(单根;空树也有根,children 恒为数组) */
|
||||
export function wrapTreeWithRoot(tree: any[] | null | undefined, label: string): any {
|
||||
return { id: 0, name: label, virtual: true, children: Array.isArray(tree) ? tree : [] };
|
||||
}
|
||||
|
||||
/** 初始选中:虚拟根下钻首个真实栏目 id;空树 → null(不落到根 id=0) */
|
||||
export function initialSelectId(wrapped: any): number | null {
|
||||
if (wrapped == null) {
|
||||
return null;
|
||||
}
|
||||
const root = Array.isArray(wrapped) ? wrapped[0] : wrapped;
|
||||
if (root?.virtual) {
|
||||
return root.children?.length ? root.children[0].id : null;
|
||||
}
|
||||
return root?.id ?? null;
|
||||
}
|
||||
@@ -115,4 +115,11 @@ describe("siteConfigForm", () => {
|
||||
"domain", "public_base_url",
|
||||
]);
|
||||
});
|
||||
it("B3:custom_css 为保留键,不入扩展行,parse/serialize 无损往返", () => {
|
||||
const json = JSON.stringify({ custom_css: ".site-header{padding:16px 0}", my_ext: "v" });
|
||||
const m = parseSiteConfig(json);
|
||||
expect(m.extras).toEqual([{ key: "my_ext", value: "v" }]);
|
||||
expect(m.preserved.custom_css).toBe(".site-header{padding:16px 0}");
|
||||
expect(JSON.parse(serializeSiteConfig(m)).custom_css).toBe(".site-header{padding:16px 0}");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ export const SITE_CONFIG_GROUPS: { name: SiteConfigGroup; title: string }[] = [
|
||||
];
|
||||
|
||||
/** 系统托管键:不进表单,解析时原样保留、序列化时无损回写(theme_params 由主题参数表单维护) */
|
||||
export const RESERVED_SITE_CONFIG_KEYS = ["theme_params"];
|
||||
export const RESERVED_SITE_CONFIG_KEYS = ["theme_params", "custom_css"];
|
||||
|
||||
/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致) */
|
||||
export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* W4 嵌套路径:站点门户 URL 拼接纯函数(方案 §8.3 T-W4-16)。
|
||||
* portalUrlOf:站点对象 → 门户地址(path 优先,尾斜杠;path 缺失回退 code 兼容历史数据);
|
||||
* previewPortalUrl:上级 path + 编码 → 子站门户地址(parentPath 空 → 单段)。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { portalUrlOf, previewPortalUrl } from "@/utils/sitePath";
|
||||
|
||||
describe("portalUrlOf(T-W4-16)", () => {
|
||||
it("path 优先:嵌套 path 拼单段门户地址 + 尾斜杠", () => {
|
||||
expect(portalUrlOf({ path: "sch-3/jdxy", code: "jdxy" })).toBe("/portal/sch-3/jdxy/");
|
||||
});
|
||||
|
||||
it("path 缺失回退 code(历史数据兼容)", () => {
|
||||
expect(portalUrlOf({ code: "demo" })).toBe("/portal/demo/");
|
||||
expect(portalUrlOf({ path: "", code: "demo" })).toBe("/portal/demo/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewPortalUrl(T-W4-16)", () => {
|
||||
it("上级 path 非空 → 两段嵌套 + 尾斜杠", () => {
|
||||
expect(previewPortalUrl("sch-3", "jdxy")).toBe("/portal/sch-3/jdxy/");
|
||||
});
|
||||
|
||||
it("上级 path 空 → 单段(顶层站)", () => {
|
||||
expect(previewPortalUrl("", "sch-9")).toBe("/portal/sch-9/");
|
||||
expect(previewPortalUrl(undefined, "sch-9")).toBe("/portal/sch-9/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* W4 嵌套路径:站点门户 URL 拼接纯函数(前后端各一份同构语义,后端 SiteService.buildPath)。
|
||||
* 规范:门户地址 = /portal/{path}/(尾斜杠);path 缺失回退 code(历史数据兼容);
|
||||
* 子站预览地址 = /portal/{parentPath}/{code}/(parentPath 空 → 单段顶层)。
|
||||
*/
|
||||
|
||||
/** 站点对象 → 门户地址(尾斜杠;path 优先,缺失回退 code) */
|
||||
export function portalUrlOf(site: { path?: string; code: string }): string {
|
||||
const seg = site.path && site.path.trim() ? site.path.trim() : site.code;
|
||||
return `/portal/${seg}/`;
|
||||
}
|
||||
|
||||
/** 上级 path + 编码 → 子站门户地址预览(parentPath 空 → 单段顶层站) */
|
||||
export function previewPortalUrl(parentPath: string | undefined | null, code: string): string {
|
||||
const parent = parentPath && parentPath.trim() ? parentPath.trim() : "";
|
||||
return parent ? `/portal/${parent}/${code}/` : `/portal/${code}/`;
|
||||
}
|
||||
@@ -4,9 +4,10 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
import { articleApi, categoryApi, portalBase, uploadUrl } from "@/api/module/cms";
|
||||
import { articleApi, categoryApi, uploadUrl } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -112,7 +113,14 @@ async function load() {
|
||||
if (!store.siteId) return;
|
||||
const tree = await categoryApi.tree(store.siteId).send();
|
||||
categoryOptions.value = flattenCategories(tree || []);
|
||||
if (!editId.value) return;
|
||||
if (!editId.value) {
|
||||
// B5:栏目工作台「新建文章」带 categoryId 预选(仅新建态;编辑态以详情回填为准)
|
||||
const preselect = route.query.categoryId ? parseInt(String(route.query.categoryId)) : undefined;
|
||||
if (preselect && categoryOptions.value.some(o => o.id === preselect)) {
|
||||
form.categoryId = preselect;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const detail = await articleApi.get(store.siteId, editId.value).send();
|
||||
if (!detail) return;
|
||||
Object.assign(form, {
|
||||
@@ -198,7 +206,7 @@ async function saveAndPublish() {
|
||||
/** 预览:仅已发布文章在门户可见 */
|
||||
function preview() {
|
||||
if (!editId.value || !store.currentSite) return;
|
||||
window.open(`${portalBase(store.currentSite.code)}/a/${editId.value}`, "_blank");
|
||||
window.open(`${config.serviceHost}${portalUrlOf(store.currentSite)}a/${editId.value}`, "_blank");
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
@@ -1,270 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message, Tag } from "view-ui-plus";
|
||||
import Utils from "@/utils/index";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { articleApi, categoryApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { ArticlePageItem, CategoryTreeNode } from "@/types/cms";
|
||||
import ArticleListPanel from "@/components/article/ArticleListPanel.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const store = useCmsStore();
|
||||
|
||||
const rows = ref<ArticlePageItem[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||
|
||||
const search = reactive({
|
||||
categoryId: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
keyword: "",
|
||||
});
|
||||
|
||||
/** 栏目下拉(树拍平缩进展示) */
|
||||
const categoryOptions = ref<{ id: number; label: string }[]>([]);
|
||||
|
||||
const statusDict = [
|
||||
{ value: 0, label: "草稿", color: "default" },
|
||||
{ value: 1, label: "已发布", color: "success" },
|
||||
{ value: 2, label: "已下架", color: "warning" },
|
||||
];
|
||||
const statusText = (s: number) => statusDict.find(i => i.value === s)?.label || "未知";
|
||||
const statusColor = (s: number) => statusDict.find(i => i.value === s)?.color || "default";
|
||||
|
||||
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true });
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "标题",
|
||||
key: "title",
|
||||
minWidth: 260,
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) =>
|
||||
h("span", null, [
|
||||
h(
|
||||
"a",
|
||||
{
|
||||
style: { color: "#2d8cf0", cursor: "pointer", marginRight: "6px" },
|
||||
onClick: () => goEdit(p.row.id),
|
||||
},
|
||||
p.row.title
|
||||
),
|
||||
p.row.topmost === 1 ? h(Tag, { color: "error" }, () => "置顶") : null,
|
||||
]),
|
||||
},
|
||||
{ title: "栏目", key: "categoryName", width: 140, tooltip: true },
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h(Tag, { color: statusColor(p.row.status) }, () => statusText(p.row.status)),
|
||||
},
|
||||
{
|
||||
title: "发布时间",
|
||||
key: "publishTime",
|
||||
width: 170,
|
||||
render: (h: any, p: any) => h("span", p.row.publishTime || "-"),
|
||||
},
|
||||
{ title: "浏览量", key: "viewCount", width: 90, align: "center" },
|
||||
{ title: "创建时间", key: "createTime", width: 170 },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 190,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const ops: any[] = [
|
||||
Utils.operatingHandle(h, "编辑", "icon-edit", () => goEdit(p.row.id)),
|
||||
];
|
||||
if (p.row.status !== 1) {
|
||||
ops.push(Utils.operatingHandle(h, "发布", "icon-apps", () => doPublish(p.row)));
|
||||
}
|
||||
if (p.row.status === 1) {
|
||||
ops.push(Utils.operatingHandle(h, "下架", "icon-stop", () => doOffline(p.row)));
|
||||
}
|
||||
ops.push(
|
||||
Utils.operatingHandle(
|
||||
h,
|
||||
p.row.topmost === 1 ? "取消置顶" : "置顶",
|
||||
"icon-saoba",
|
||||
() => doTopmost(p.row)
|
||||
),
|
||||
Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row))
|
||||
);
|
||||
return ops;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function flattenCategories(tree: CategoryTreeNode[], depth = 0) {
|
||||
const list: { id: number; label: string }[] = [];
|
||||
tree.forEach(node => {
|
||||
list.push({ id: node.id!, label: `${" ".repeat(depth)}${node.name}` });
|
||||
if (node.children?.length) {
|
||||
list.push(...flattenCategories(node.children, depth + 1));
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
if (!store.siteId) return;
|
||||
const tree = await categoryApi.tree(store.siteId).send();
|
||||
categoryOptions.value = flattenCategories(tree || []);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!store.siteId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const page = await articleApi
|
||||
.page({
|
||||
siteId: store.siteId,
|
||||
categoryId: search.categoryId,
|
||||
status: search.status,
|
||||
keyword: search.keyword || undefined,
|
||||
page: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
.send();
|
||||
rows.value = page?.list || [];
|
||||
total.value = page?.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pageSizeUpdate(size: number) {
|
||||
pageSize.value = size;
|
||||
localStorage.setItem("customPageCount", String(size));
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
|
||||
function goEdit(id?: number) {
|
||||
const code = store.schoolCode;
|
||||
router.push(code ? `/sch/${code}/article/edit${id ? `/${id}` : ""}` : "/workbench");
|
||||
}
|
||||
|
||||
async function doPublish(row: ArticlePageItem) {
|
||||
await articleApi.publish(store.siteId!, row.id).send();
|
||||
Message.success("发布成功!");
|
||||
run();
|
||||
}
|
||||
|
||||
async function doOffline(row: ArticlePageItem) {
|
||||
await articleApi.offline(store.siteId!, row.id).send();
|
||||
Message.success("已下架");
|
||||
run();
|
||||
}
|
||||
|
||||
async function doTopmost(row: ArticlePageItem) {
|
||||
await articleApi.topmost(store.siteId!, row.id, row.topmost !== 1).send();
|
||||
Message.success(row.topmost === 1 ? "已取消置顶" : "已置顶");
|
||||
run();
|
||||
}
|
||||
|
||||
function doDelete(row: ArticlePageItem) {
|
||||
MyConfirm({
|
||||
title: "删除文章",
|
||||
msg: `确定删除「${row.title}」吗?`,
|
||||
info: "删除后正文图片将转为孤儿附件,可在附件管理中清理",
|
||||
async onOk() {
|
||||
await articleApi.delete({ id: row.id, siteId: store.siteId! }).send();
|
||||
Message.success("操作成功!");
|
||||
if (rows.value.length <= 1 && pageNum.value > 1) pageNum.value -= 1;
|
||||
run();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories();
|
||||
await run();
|
||||
});
|
||||
|
||||
// 站点/学校上下文切换即时重载(docs/09 §3.3,修复切换不刷新缺陷)
|
||||
watch(() => store.siteId, async () => {
|
||||
await loadCategories();
|
||||
pageNum.value = 1;
|
||||
await run();
|
||||
});
|
||||
/**
|
||||
* 文章列表(全局检索页,B5 组件化后薄壳引用):
|
||||
* 无 props 透传 = 栏目下拉可筛 + 全站关键字检索,路由/搜索参数与组件化前完全一致。
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="goEdit()">
|
||||
<Icon type="ios-add" />
|
||||
新建文章
|
||||
</Button>
|
||||
</div>
|
||||
<div class="search-wrap fix">
|
||||
<Select
|
||||
v-model="search.categoryId"
|
||||
class="search-wrap_sel"
|
||||
clearable
|
||||
placeholder="全部栏目"
|
||||
@on-change="pageNum = 1; run()"
|
||||
>
|
||||
<Option v-for="opt in categoryOptions" :key="opt.id" :value="opt.id">
|
||||
{{ opt.label }}
|
||||
</Option>
|
||||
</Select>
|
||||
<Select
|
||||
v-model="search.status"
|
||||
class="search-wrap_sel ml-12"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
@on-change="pageNum = 1; run()"
|
||||
>
|
||||
<Option v-for="s in statusDict" :key="s.value" :value="s.value">{{ s.label }}</Option>
|
||||
</Select>
|
||||
<Input
|
||||
search
|
||||
enter-button
|
||||
class="fr"
|
||||
placeholder="标题关键字"
|
||||
style="width: 220px"
|
||||
v-model="search.keyword"
|
||||
@on-search="pageNum = 1; run()"
|
||||
@on-clear="pageNum = 1; run()"
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无文章"
|
||||
/>
|
||||
<div class="page-panel">
|
||||
<Page
|
||||
class="page-box fr"
|
||||
:total="total"
|
||||
:current="pageNum"
|
||||
:page-size="pageSize"
|
||||
:page-size-opts="[10, 20, 30, 40]"
|
||||
@on-page-size-change="pageSizeUpdate"
|
||||
@on-change="(p: number) => { pageNum = p; run(); }"
|
||||
placement="top"
|
||||
show-total
|
||||
show-sizer
|
||||
/>
|
||||
</div>
|
||||
<ArticleListPanel />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-wrap .search-wrap_sel {
|
||||
width: 180px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,7 +11,7 @@ const store = useCmsStore();
|
||||
|
||||
const rows = ref<AttachmentVo[]>([]);
|
||||
const total = ref(0);
|
||||
const orphanCount = ref(0);
|
||||
const unusedCount = ref(0);
|
||||
const loading = ref(false);
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||
@@ -32,16 +32,17 @@ const columns = [
|
||||
},
|
||||
{ title: "大小", key: "fileSize", width: 100, render: (h: any, p: any) => h("span", Utils.getFileSize(p.row.fileSize || 0)) },
|
||||
{
|
||||
title: "关联文章",
|
||||
key: "articleId",
|
||||
width: 100,
|
||||
title: "用途",
|
||||
key: "usage",
|
||||
width: 110,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h(
|
||||
Tag,
|
||||
{ color: p.row.orphan ? "warning" : "default" },
|
||||
() => (p.row.orphan ? "孤儿" : p.row.articleId)
|
||||
),
|
||||
render: (h: any, p: any) => {
|
||||
// B2 引用模型:文章(articleId)优先,其次场景引用(banner/link/site),均无=未使用
|
||||
const usage = p.row.articleId
|
||||
? "文章"
|
||||
: { banner: "轮播", link: "友链", site: "站点配置" }[p.row.refType as string] || "未使用";
|
||||
return h(Tag, { color: usage === "未使用" ? "warning" : "default" }, () => usage);
|
||||
},
|
||||
},
|
||||
{ title: "上传时间", key: "createTime", width: 170 },
|
||||
{
|
||||
@@ -68,7 +69,7 @@ async function run() {
|
||||
.send();
|
||||
rows.value = page?.list || [];
|
||||
total.value = page?.total || 0;
|
||||
orphanCount.value = (page as any)?.orphanCount || 0;
|
||||
unusedCount.value = (page as any)?.unusedCount || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -95,9 +96,9 @@ function doDelete(row: AttachmentVo) {
|
||||
|
||||
function cleanOrphans() {
|
||||
MyConfirm({
|
||||
title: "清理孤儿附件",
|
||||
msg: `当前共有 ${orphanCount.value} 条孤儿附件记录(关联文章已删除),仅清理列表页中勾选的记录。`,
|
||||
info: "为避免误删被封面/轮播引用的文件,MVP 不提供一键全清",
|
||||
title: "查看未使用附件",
|
||||
msg: `当前共有 ${unusedCount.value} 条未使用附件记录(无文章引用且无场景引用),点击确定切换到未使用列表。`,
|
||||
info: "为避免误删被引用的文件,MVP 不提供一键全清;旧数据在重新保存对应内容前显示为未使用属正常现象",
|
||||
onOk() {
|
||||
filter.orphanOnly = true;
|
||||
pageNum.value = 1;
|
||||
@@ -140,11 +141,11 @@ watch(() => store.siteId, () => {
|
||||
<Button type="primary" size="large" icon="ios-cloud-upload-outline">上传附件</Button>
|
||||
</Upload>
|
||||
<Button class="fl" style="margin-left: 8px" size="large" @click="cleanOrphans">
|
||||
孤儿附件({{ orphanCount }})
|
||||
未使用附件({{ unusedCount }})
|
||||
</Button>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<Checkbox v-model="filter.orphanOnly" @on-change="pageNum = 1; run()">仅看孤儿附件</Checkbox>
|
||||
<Checkbox v-model="filter.orphanOnly" @on-change="pageNum = 1; run()">仅看未使用附件</Checkbox>
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
|
||||
@@ -207,7 +207,7 @@ watch(() => store.siteId, () => run());
|
||||
</FormItem>
|
||||
<FormItem label="轮播图片" required>
|
||||
<Upload
|
||||
:action="uploadUrl() + '?siteId=' + store.siteId"
|
||||
:action="uploadUrl() + '?siteId=' + store.siteId + '&scene=banner'"
|
||||
with-credentials
|
||||
:show-upload-list="false"
|
||||
:format="['jpg', 'jpeg', 'png', 'gif', 'webp']"
|
||||
|
||||
@@ -5,11 +5,15 @@ import MyConfirm from "@/components/MyConfirm";
|
||||
import CategoryTreePanel from "@/components/CategoryTreePanel.vue";
|
||||
import CategorySortModal from "@/components/CategorySortModal.vue";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import ArticleListPanel from "@/components/article/ArticleListPanel.vue";
|
||||
import { categoryApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
import { rootLabelOf, wrapTreeWithRoot } from "@/utils/categoryTree";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
|
||||
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除/搜索)+ ztree */
|
||||
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除) */
|
||||
const store = useCmsStore();
|
||||
|
||||
const refTreePanel = useTemplateRef("refTreePanel");
|
||||
@@ -53,9 +57,13 @@ const rules = {
|
||||
type: [{ required: true, type: "number", message: "请选择类型", trigger: "change" }],
|
||||
};
|
||||
|
||||
/** 树数据源(CategoryTreePanel 拉取用;站点切换即时重载) */
|
||||
function loadTreeApi(): Promise<CategoryTreeNode[]> {
|
||||
return categoryApi.tree(store.siteId!).send() as Promise<CategoryTreeNode[]>;
|
||||
/** 根节点 label(W2 虚拟根,D5):主站=学校名(缺省回退 mainSiteName),子站=子站名;随站点上下文响应 */
|
||||
const rootLabel = computed(() => rootLabelOf({ currentSchool: store.currentSchool, currentSite: store.currentSite }));
|
||||
|
||||
/** 树数据源(CategoryTreePanel 拉取用;站点切换即时重载;W2:包装虚拟根后返回) */
|
||||
async function loadTreeApi(): Promise<CategoryTreeNode[]> {
|
||||
const tree = await categoryApi.tree(store.siteId!).send();
|
||||
return [wrapTreeWithRoot(tree as any[], rootLabel.value)];
|
||||
}
|
||||
|
||||
// 站点上下文切换即时重载(docs/09 §3.3,修复切换不刷新缺陷)
|
||||
@@ -195,6 +203,33 @@ async function save() {
|
||||
function onSortSaved() {
|
||||
refTreePanel.value?.refreshTree(curTreeNode.value?.id);
|
||||
}
|
||||
|
||||
/* ===== B5 栏目工作台:右侧类型化内容面板 ===== */
|
||||
|
||||
/** 单页摘要:去 HTML 标签截断展示 */
|
||||
function summaryText(node: any): string {
|
||||
const text = String(node?.content || "").replace(/<[^>]+>/g, "").trim();
|
||||
return text.length > 200 ? text.slice(0, 200) + "…" : text || "(暂无内容,点击「编辑内容」补充)";
|
||||
}
|
||||
|
||||
/** 新建文章:带 categoryId 预选跳编辑页(ArticleEdit onMounted 回填) */
|
||||
function goNewArticle(categoryId: number) {
|
||||
window.location.hash = `#/sch/${store.schoolCode}/article/edit?categoryId=${categoryId}`;
|
||||
}
|
||||
|
||||
/** 门户预览:新开 /portal/{path}/c/{id}(W4 嵌套路径) */
|
||||
function openPreview(node: any) {
|
||||
if (!store.currentSite) {
|
||||
Message.warning("当前无站点上下文,无法预览");
|
||||
return;
|
||||
}
|
||||
window.open(`${config.serviceHost}${portalUrlOf(store.currentSite)}c/${node.id}`, "_blank");
|
||||
}
|
||||
|
||||
/** 外链栏目直达 */
|
||||
function openLink(url: string) {
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -222,11 +257,6 @@ function onSortSaved() {
|
||||
<SvgIcon name="tree-icon-5" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
<Tooltip content="搜索栏目" placement="top" transfer>
|
||||
<li class="search-node-li" @click="refTreePanel?.openSearch()">
|
||||
<Icon type="md-search" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
</ul>
|
||||
|
||||
<CategoryTreePanel
|
||||
@@ -240,18 +270,71 @@ function onSortSaved() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:选中栏目详情 -->
|
||||
<div class="main-container">
|
||||
<div class="info-panel" v-if="selected">
|
||||
<h3>{{ selected.name }}</h3>
|
||||
<p>类型:{{ typeDict.find(t => t.value === selected!.type)?.label }}</p>
|
||||
<p v-if="selected.type === 3">外链:{{ selected.linkUrl }}</p>
|
||||
<p v-if="selected.type === 2">单页内容:见门户预览</p>
|
||||
<p>排序号:{{ selected.sortNo }} · 状态:{{ selected.status === 1 ? "显示" : "隐藏" }}</p>
|
||||
<p v-if="selected.seoTitle">SEO 标题:{{ selected.seoTitle }}</p>
|
||||
<p v-if="selected.seoDescription">SEO 描述:{{ selected.seoDescription }}</p>
|
||||
</div>
|
||||
<div v-else class="empty-tip">请选择左侧栏目,或使用顶部图标新增子栏目</div>
|
||||
<!-- 右侧:类型化内容面板(B5 栏目工作台) -->
|
||||
<div class="main-container workbench-main">
|
||||
<template v-if="selected">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">
|
||||
<h3>{{ selected.name }}</h3>
|
||||
<Tag :color="selected.type === 1 ? 'primary' : selected.type === 2 ? 'success' : 'warning'">
|
||||
{{ typeDict.find(t => t.value === selected!.type)?.label }}
|
||||
</Tag>
|
||||
<span class="panel-meta">排序 {{ selected.sortNo }} · {{ selected.status === 1 ? "显示" : "隐藏" }}</span>
|
||||
</div>
|
||||
<div class="panel-tools">
|
||||
<Button v-if="selected.type === 1" type="primary" @click="goNewArticle(selected.id)">
|
||||
<Icon type="ios-add" />
|
||||
新建文章
|
||||
</Button>
|
||||
<Button v-if="selected.type !== 3" @click="openPreview(selected)">
|
||||
<Icon type="ios-eye" />
|
||||
预览
|
||||
</Button>
|
||||
<Button @click="showEditNode">
|
||||
<Icon type="ios-create" />
|
||||
编辑栏目
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表型:本栏目文章管理(联动仅经 categoryId watch,拖拽排序不触发) -->
|
||||
<ArticleListPanel
|
||||
v-if="selected.type === 1"
|
||||
class="panel-body"
|
||||
:category-id="selected.id"
|
||||
fixed-category
|
||||
/>
|
||||
|
||||
<!-- 单页型:内容摘要 + 编辑入口 -->
|
||||
<div v-else-if="selected.type === 2" class="single-summary">
|
||||
<p class="summary-text">{{ summaryText(selected) }}</p>
|
||||
<div class="summary-actions">
|
||||
<Button type="primary" @click="showEditNode">
|
||||
<Icon type="ios-create" />
|
||||
编辑内容
|
||||
</Button>
|
||||
<Button @click="openPreview(selected)">
|
||||
<Icon type="ios-eye" />
|
||||
预览单页
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="selected.seoTitle" class="seo-line">SEO 标题:{{ selected.seoTitle }}</p>
|
||||
<p v-if="selected.seoDescription" class="seo-line">SEO 描述:{{ selected.seoDescription }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 外链型:URL 展示 + 直达 -->
|
||||
<div v-else class="link-info">
|
||||
<p class="link-url-label">跳转地址</p>
|
||||
<p class="link-url">{{ selected.linkUrl }}</p>
|
||||
<div class="summary-actions">
|
||||
<Button type="primary" @click="openLink(selected.linkUrl)">
|
||||
<Icon type="ios-open-outline" />
|
||||
打开链接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-tip">选择左侧栏目查看内容</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
@@ -342,8 +425,7 @@ function onSortSaved() {
|
||||
}
|
||||
}
|
||||
|
||||
.add-node-li,
|
||||
.search-node-li {
|
||||
.add-node-li {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
@@ -371,6 +453,98 @@ function onSortSaved() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== B5 栏目工作台右侧面板 ===== */
|
||||
.workbench-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-tools {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.single-summary,
|
||||
.link-info {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
margin-top: 10px;
|
||||
|
||||
.summary-text {
|
||||
color: #333;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.summary-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.seo-line {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-info {
|
||||
.link-url-label {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.link-url {
|
||||
color: #2d8cf0;
|
||||
word-break: break-all;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
color: #999;
|
||||
text-align: center;
|
||||
|
||||
@@ -191,14 +191,24 @@ watch(() => store.siteId, () => run());
|
||||
<Modal v-model="modalVisible" :title="form.id ? '编辑友链' : '新增友链'" :mask-closable="false" width="520px">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="100">
|
||||
<FormItem label="分组">
|
||||
<Input v-model="form.groupName" :maxlength="64" placeholder="上级部门/兄弟院校/合作单位…" />
|
||||
<!-- B4:下拉选已有分组 + 允许新建(仍落 groupName 字符串,后端零改动);杜绝拼错旧组名产生脏分组 -->
|
||||
<Select
|
||||
v-model="form.groupName"
|
||||
filterable
|
||||
allow-create
|
||||
:maxlength="64"
|
||||
placeholder="选择已有分组,或输入新名称回车新建"
|
||||
style="width: 320px"
|
||||
>
|
||||
<Option v-for="g in groups" :key="g" :value="g">{{ g }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="名称" prop="name">
|
||||
<Input v-model="form.name" :maxlength="128" />
|
||||
</FormItem>
|
||||
<FormItem label="Logo(可选)">
|
||||
<Upload
|
||||
:action="uploadUrl() + '?siteId=' + store.siteId"
|
||||
:action="uploadUrl() + '?siteId=' + store.siteId + '&scene=link'"
|
||||
with-credentials
|
||||
:show-upload-list="false"
|
||||
:format="['jpg', 'jpeg', 'png', 'gif', 'webp']"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { categoryApi, articleApi, bannerApi, linkApi, siteApi } from "@/api/modu
|
||||
import { useCmsStore } from "@/store";
|
||||
import { CategoryTreeNode, ArticlePageItem, BannerVo, LinkVo, SiteVo } from "@/types/cms";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
|
||||
/**
|
||||
* 门户预览(SPA 内简版门户):mock 模式下「预览门户」跳转到这里,
|
||||
@@ -36,9 +37,9 @@ const colleges = computed(() => {
|
||||
return sites.value.filter(s => s.parentSiteId === anchor && s.status === 1);
|
||||
});
|
||||
|
||||
/** 切换到该院系的真实门户(mock 模式由 mock 中间件承接,真实模式为 SSR 门户) */
|
||||
function openCollege(code: string) {
|
||||
window.open(`${config.serviceHost}/portal/${code}/`, "_blank");
|
||||
/** 切换到该院系的真实门户(mock 模式由 mock 中间件承接,真实模式为 SSR 门户;W4 嵌套 path) */
|
||||
function openCollege(site: SiteVo) {
|
||||
window.open(`${config.serviceHost}${portalUrlOf(site)}`, "_blank");
|
||||
}
|
||||
const linkGroups = computed(() => {
|
||||
const map = new Map<string, LinkVo[]>();
|
||||
@@ -129,7 +130,7 @@ onBeforeUnmount(stopCarousel);
|
||||
<span class="colleges-toggle">专业院系 <i>▾</i></span>
|
||||
<ul class="colleges-menu">
|
||||
<li v-for="c in colleges" :key="c.id" :class="{ on: c.id === store.siteId }">
|
||||
<a href="javascript:;" @click="openCollege(c.code)" :title="c.name">{{ c.name }}</a>
|
||||
<a href="javascript:;" @click="openCollege(c)" :title="c.name">{{ c.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { schoolApi, siteApi, themeApi, portalBase } from "@/api/module/cms";
|
||||
import { schoolApi, siteApi, themeApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { SchoolCard, SchoolOption, ThemeVo } from "@/types/cms";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
import ThemePicker from "@/components/theme/ThemePicker.vue";
|
||||
|
||||
/**
|
||||
@@ -62,7 +63,7 @@ function enter(card: SchoolCard) {
|
||||
}
|
||||
|
||||
function openPortal(card: SchoolCard) {
|
||||
window.open(`${portalBase(card.mainSiteCode)}/`, "_blank");
|
||||
window.open(`${config.serviceHost}${portalUrlOf({ path: card.mainSitePath, code: card.mainSiteCode })}`, "_blank");
|
||||
}
|
||||
|
||||
/** 超管全局站点视图(借道当前/第一个学校上下文 + scope=all) */
|
||||
@@ -88,7 +89,8 @@ const form = ref({
|
||||
});
|
||||
|
||||
const rules = {
|
||||
schId: [{ required: true, message: "请选择学校(来自用户中心)", trigger: "change" }],
|
||||
// 必须声明 type:"number":schId 是 UserBase 组 ID(数字),缺省按 string 校验会把已选中的数字 ID 判为不通过
|
||||
schId: [{ required: true, type: "number", message: "请选择学校(来自用户中心)", trigger: "change" }],
|
||||
code: [
|
||||
{ required: true, message: "主站编码不得为空", trigger: "blur" },
|
||||
{ pattern: /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/, message: "仅限小写字母/数字/短横线", trigger: "blur" },
|
||||
@@ -125,11 +127,10 @@ async function loadThemes() {
|
||||
|
||||
function onSchoolPick(id: number) {
|
||||
const opt = options.value.find(o => o.id === id);
|
||||
// 校名/编码/名称始终跟随当前选中学校带出,避免切换学校后残留上一所的默认值造成“已选好”的假象
|
||||
form.value.schName = opt?.name || "";
|
||||
if (!form.value.name && opt) {
|
||||
if (opt) {
|
||||
form.value.name = `${opt.name}门户网站`;
|
||||
}
|
||||
if (!form.value.code && opt) {
|
||||
form.value.code = `sch-${opt.id}`;
|
||||
}
|
||||
}
|
||||
@@ -171,31 +172,34 @@ onMounted(run);
|
||||
|
||||
<template>
|
||||
<div class="workbench">
|
||||
<header class="wb-header fix">
|
||||
<h4 class="fl align-middle">云海CMS · 学校工作台</h4>
|
||||
<div class="wb-header-aside fr">
|
||||
<Button v-if="isSuper" size="small" class="wb-btn" @click="goAllSites" :disabled="!cards.length">
|
||||
<Icon type="ios-globe" />
|
||||
全部站点总览
|
||||
</Button>
|
||||
<Button v-if="isSuper" size="small" type="primary" class="wb-btn" @click="openAdd">
|
||||
<Icon type="ios-add" />
|
||||
添加学校
|
||||
</Button>
|
||||
<span class="delimiter align-middle"></span>
|
||||
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="fl align-middle wb-avatar" @click="linkUserInfo" />
|
||||
<a class="name fl align-middle" href="javascript:void(0);" @click="linkUserInfo">{{ userInfo.name }}</a>
|
||||
<span class="delimiter align-middle"></span>
|
||||
<a href="javascript:;" @click="logout" title="退出登陆">
|
||||
<header class="wb-header">
|
||||
<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 右侧 -->
|
||||
<div class="wb-toolbar" v-if="isSuper">
|
||||
<Button class="wb-btn" @click="goAllSites" :disabled="!cards.length">
|
||||
<Icon type="ios-globe" />
|
||||
全部站点总览
|
||||
</Button>
|
||||
<Button type="primary" class="wb-btn" @click="openAdd">
|
||||
<Icon type="ios-add" />
|
||||
添加学校
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="wb-body">
|
||||
<div class="wb-title">
|
||||
<h3>我的学校</h3>
|
||||
<p class="wb-sub">选择学校进入内容管理;学校与站点由平台管理员在「添加学校」中绑定创建。</p>
|
||||
<p class="wb-sub">选择学校进入内容管理;学校由平台管理员接入,院系子站可在「站点管理」自助创建。</p>
|
||||
</div>
|
||||
|
||||
<Spin v-if="loading" size="large" fix />
|
||||
@@ -225,7 +229,7 @@ onMounted(run);
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<Icon type="ios-school" :size="48" />
|
||||
<p v-if="isSuper">还没有学校 —— 点击右上角「添加学校」绑定用户中心学校并创建门户主站。</p>
|
||||
<p v-if="isSuper">还没有学校 —— 点击上方「添加学校」绑定用户中心学校并创建门户主站。</p>
|
||||
<p v-else>暂无可管理的学校站点,请联系平台管理员。</p>
|
||||
<Button v-if="isSuper" type="primary" @click="openAdd">
|
||||
<Icon type="ios-add" />
|
||||
@@ -279,6 +283,7 @@ onMounted(run);
|
||||
}
|
||||
.wb-header {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
background: #1f2a3a;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
@@ -288,9 +293,9 @@ onMounted(run);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
line-height: 56px;
|
||||
}
|
||||
.wb-header-aside {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
@@ -307,12 +312,24 @@ onMounted(run);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wb-btn {
|
||||
// 深色 header 上按钮保持默认浅色块
|
||||
.logout {
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* 工具行:Notice front .search-wrap 同款(#f7f7f7 底 + 10px 16px 内边距 ≈ 52px 高),按钮居左 */
|
||||
.wb-toolbar {
|
||||
flex-shrink: 0;
|
||||
background: #f7f7f7;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.wb-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
+137
-18
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Utils from "@/utils/index";
|
||||
import { siteApi, themeApi, themePreviewUrl } from "@/api/module/cms";
|
||||
import { assetUrl, siteApi, themeApi } from "@/api/module/cms";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { SiteVo, ThemeVo } from "@/types/cms";
|
||||
@@ -18,6 +19,8 @@ import SiteWizard from "@/views/cms/SiteWizard.vue";
|
||||
* 站点管理(方案 §9.1 + §6.2 主题选择增强)。
|
||||
* 学校化管理(docs/09 §3.3):默认只显示当前学校站点(主站+院系子站);
|
||||
* 超管可经工作台「全部站点总览」(?scope=all)查看全局列表。
|
||||
* 行「进入管理」= 院系子站切换入口(壳面包屑条切换器已按 UX 收敛移除):
|
||||
* 切 store.currentSite 落文章列表,跨校行先重建学校上下文;当前站点行带「当前」标记。
|
||||
* 新建 → 三步向导(选模板→微调预览→确认);已建站点 → 编辑弹窗内
|
||||
* 主题卡片单选 + 参数微调表单,保存带 themeParams(服务端合并进 config_json,方案 §5.3)。
|
||||
* config_json 结构化编辑见 SiteConfigForm(docs/09 §3.1):
|
||||
@@ -25,10 +28,15 @@ import SiteWizard from "@/views/cms/SiteWizard.vue";
|
||||
*/
|
||||
const store = useCmsStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/** 超管全局视图(工作台入口带入 scope=all) */
|
||||
const scopeAll = computed(() => route.query.scope === "all" && store.isSuperAdmin);
|
||||
|
||||
/** B1 建站权限拆分:校级(学校上下文内)可自助创建院系子站;超管向导行为不变 */
|
||||
const isSchoolAdmin = computed(() => !store.isSuperAdmin && !!store.currentSchool);
|
||||
const canCreateSite = computed(() => store.isSuperAdmin || isSchoolAdmin.value);
|
||||
|
||||
const rows = ref<SiteVo[]>([]);
|
||||
const loading = ref(false);
|
||||
const tableHeight = Utils.useTableHeight({ isOperat: true });
|
||||
@@ -43,7 +51,18 @@ const themeMeta = computed(() => {
|
||||
|
||||
const columns = [
|
||||
{ title: "站点编码", key: "code", width: 140 },
|
||||
{ title: "站点名称", key: "name", minWidth: 200, tooltip: true },
|
||||
{
|
||||
title: "站点名称",
|
||||
key: "name",
|
||||
minWidth: 200,
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) =>
|
||||
h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [
|
||||
h("span", p.row.name),
|
||||
// 当前操作站点标记:壳面包屑校名后缀同源(store.currentSite)
|
||||
p.row.id === store.siteId ? h(Tag, { color: "success" }, () => "当前") : null,
|
||||
]),
|
||||
},
|
||||
{ title: "学校", key: "schName", width: 150, tooltip: true },
|
||||
{
|
||||
title: "所属主站",
|
||||
@@ -60,13 +79,13 @@ const columns = [
|
||||
const t = themeMeta.value[p.row.theme || "default"];
|
||||
return h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [
|
||||
t
|
||||
? h("img", { src: t.screenshotUrl, style: "width:44px;height:28px;object-fit:cover;border-radius:3px;border:1px solid #eee", alt: "" })
|
||||
? h("img", { src: assetUrl(t.screenshotUrl), style: "width:44px;height:28px;object-fit:cover;border-radius:3px;border:1px solid #eee", alt: "" })
|
||||
: h("span", { style: "display:inline-block;width:44px;height:28px;background:#f0f2f5;border-radius:3px" }),
|
||||
h("span", t ? `${t.title}(${p.row.theme})` : p.row.theme || "default"),
|
||||
]);
|
||||
},
|
||||
},
|
||||
{ title: "门户路径", key: "portalPath", minWidth: 200, tooltip: true, render: (h: any, p: any) => h("span", `/portal/${p.row.code}/`) },
|
||||
{ title: "门户路径", key: "portalPath", minWidth: 200, tooltip: true, render: (h: any, p: any) => h("span", portalUrlOf(p.row)) },
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
@@ -84,6 +103,7 @@ const columns = [
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
render: (h: any, p: any) => [
|
||||
Utils.operatingHandle(h, "进入管理", "icon-apps", () => enterManage(p.row), p.row.id === store.siteId),
|
||||
Utils.operatingHandle(h, "编辑", "icon-edit", () => openEdit(p.row)),
|
||||
Utils.operatingHandle(h, "门户", "icon-browse", () => openPortal(p.row)),
|
||||
],
|
||||
@@ -112,6 +132,9 @@ const form = reactive({
|
||||
const editThemeParams = ref<FormModelValue>({});
|
||||
const editTheme = computed(() => themeMeta.value[form.theme]);
|
||||
|
||||
/** B3 自定义样式(config_json.custom_css):面板为唯一编辑入口,SiteConfigForm 侧该键为保留键 */
|
||||
const customCss = ref("");
|
||||
|
||||
/** 编辑内预览 */
|
||||
const previewVisible = ref(false);
|
||||
|
||||
@@ -153,15 +176,23 @@ async function loadThemes() {
|
||||
}
|
||||
}
|
||||
|
||||
const editSitePath = ref<string>("");
|
||||
|
||||
function openWizard() {
|
||||
wizardVisible.value = true;
|
||||
}
|
||||
|
||||
/** W1:皮肤包管理页入口 */
|
||||
function goThemeManage() {
|
||||
router.push(`/sch/${store.schoolCode}/theme`);
|
||||
}
|
||||
|
||||
function onWizardCreated() {
|
||||
run();
|
||||
}
|
||||
|
||||
function openEdit(row: SiteVo) {
|
||||
editSitePath.value = row.path || row.code;
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
@@ -173,11 +204,35 @@ function openEdit(row: SiteVo) {
|
||||
configJson: row.configJson || "",
|
||||
status: row.status,
|
||||
});
|
||||
customCss.value = readCustomCss(form.configJson);
|
||||
const t = editTheme.value;
|
||||
editThemeParams.value = t ? themeParamsFromConfigJson(row.configJson, t) : {};
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function readCustomCss(configJson: string): string {
|
||||
try {
|
||||
const obj = configJson && configJson.trim() ? JSON.parse(configJson) : {};
|
||||
return obj && typeof obj === "object" && typeof obj.custom_css === "string" ? obj.custom_css : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 把面板值写入/移出 configJson(空值移除键;与 SiteConfigForm 的 preserved 往返兼容) */
|
||||
function withCustomCss(configJson: string, css: string): string {
|
||||
let obj: Record<string, unknown>;
|
||||
try {
|
||||
obj = configJson && configJson.trim() ? JSON.parse(configJson) : {};
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
|
||||
} catch {
|
||||
obj = {};
|
||||
}
|
||||
if (css.trim()) obj.custom_css = css;
|
||||
else delete obj.custom_css;
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
|
||||
function pickTheme(name: string) {
|
||||
if (form.theme === name) return;
|
||||
form.theme = name;
|
||||
@@ -194,16 +249,18 @@ async function save() {
|
||||
try {
|
||||
const t = editTheme.value;
|
||||
const themeParams = t ? editThemeParams.value : {};
|
||||
const baseConfig = withCustomCss(form.configJson || "", customCss.value);
|
||||
await siteApi
|
||||
.save({
|
||||
id: form.id,
|
||||
code: form.code,
|
||||
parentSiteId: form.parentSiteId || 0,
|
||||
schId: form.schId,
|
||||
schName: form.schName || undefined,
|
||||
// B1:校级角色隐藏学校信息字段,留空由服务端继承主站(防伪造通道)
|
||||
schId: isSchoolAdmin.value ? undefined : form.schId,
|
||||
schName: isSchoolAdmin.value ? undefined : form.schName || undefined,
|
||||
name: form.name,
|
||||
theme: form.theme || undefined,
|
||||
configJson: t ? mergeThemeParamsIntoConfigJson(form.configJson || "", t, themeParams) : form.configJson || undefined,
|
||||
configJson: t ? mergeThemeParamsIntoConfigJson(baseConfig, t, themeParams) : baseConfig || undefined,
|
||||
status: form.status,
|
||||
})
|
||||
.send();
|
||||
@@ -216,7 +273,31 @@ async function save() {
|
||||
}
|
||||
|
||||
function openPortal(row: SiteVo) {
|
||||
window.open(`${config.serviceHost}/portal/${row.code}/`, "_blank");
|
||||
window.open(`${config.serviceHost}${portalUrlOf(row)}`, "_blank");
|
||||
}
|
||||
|
||||
/** 行级「进入管理」(docs/09 §3.3 子站管理入口):切当前操作站点后落文章列表;
|
||||
* 全局视图跨校行先重建学校上下文(面包屑/左侧菜单跟随),子站行向上找主站行定学校壳 */
|
||||
function enterManage(row: SiteVo) {
|
||||
const main = row.parentSiteId ? rows.value.find(s => s.id === row.parentSiteId) : row;
|
||||
if (!main) {
|
||||
Message.warning("未找到所属主站,无法进入管理");
|
||||
return;
|
||||
}
|
||||
if (main.code !== store.schoolCode) {
|
||||
store.enterSchool(
|
||||
{
|
||||
schId: row.schId,
|
||||
schName: row.schName,
|
||||
mainSiteId: main.id,
|
||||
mainSiteCode: main.code,
|
||||
mainSiteName: main.name,
|
||||
},
|
||||
rows.value.filter(s => s.id === main.id || s.parentSiteId === main.id)
|
||||
);
|
||||
}
|
||||
store.setCurrentSite({ id: row.id, code: row.code, path: row.path || row.code, name: row.name });
|
||||
router.push(`/sch/${main.code}/article`);
|
||||
}
|
||||
|
||||
function openEditPreview() {
|
||||
@@ -246,10 +327,15 @@ watch(() => store.currentSchool?.schId, () => run());
|
||||
<a href="javascript:void(0);" @click="goBackWorkbench">返回学校工作台</a>
|
||||
</Alert>
|
||||
</div>
|
||||
<div class="fix">
|
||||
<div class="fix" v-if="canCreateSite">
|
||||
<Button class="fl" type="primary" size="large" @click="openWizard">
|
||||
<Icon type="ios-add" />
|
||||
新建站点(向导)
|
||||
{{ isSchoolAdmin ? "新建院系子站" : "新建站点(向导)" }}
|
||||
</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>
|
||||
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无站点" />
|
||||
@@ -264,15 +350,15 @@ watch(() => store.currentSchool?.schId, () => run());
|
||||
<Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" />
|
||||
</FormItem>
|
||||
<FormItem label="上级站点">
|
||||
<Select v-model="form.parentSiteId" clearable placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
|
||||
<Select v-model="form.parentSiteId" :clearable="!isSchoolAdmin" placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
|
||||
<Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option>
|
||||
</Select>
|
||||
<p class="form-tip">院系子站挂在主站下,门户 header 右上出现"专业院系"下拉;层级固定两层。</p>
|
||||
<p class="form-tip">院系子站挂在主站下,门户 header 右上出现"专业院系"下拉;层级固定两层。{{ isSchoolAdmin ? "校级角色仅可在本校主站之间改挂。" : "" }}</p>
|
||||
</FormItem>
|
||||
<FormItem label="学校ID">
|
||||
<FormItem v-if="store.isSuperAdmin" label="学校ID">
|
||||
<InputNumber v-model="form.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
|
||||
</FormItem>
|
||||
<FormItem label="学校名称">
|
||||
<FormItem v-if="store.isSuperAdmin" label="学校名称">
|
||||
<Input v-model="form.schName" :maxlength="128" />
|
||||
</FormItem>
|
||||
<FormItem label="皮肤包">
|
||||
@@ -288,6 +374,23 @@ watch(() => store.currentSchool?.schId, () => run());
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<!-- B3 自定义样式(config_json.custom_css,服务端黑名单强校验) -->
|
||||
<Collapse class="css-collapse">
|
||||
<Panel name="css" header="自定义样式(站点级 CSS 覆盖)">
|
||||
<Input
|
||||
v-model="customCss"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:maxlength="10240"
|
||||
placeholder="如:--primary:#59aeeb; 或 .site-header{height:80px}"
|
||||
class="css-editor"
|
||||
/>
|
||||
<div class="css-meta">
|
||||
<span>{{ customCss.length }}/10240 字符</span>
|
||||
<span>保存后门户立即生效(缓存自动失效);服务端将拒绝 </style、<script、@import、expression( 等危险内容</span>
|
||||
</div>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
<!-- 站点配置结构化表单(独立 Form 分区,v-model 直绑 configJson) -->
|
||||
<SiteConfigForm ref="configFormRef" v-model="form.configJson" />
|
||||
<template #footer>
|
||||
@@ -298,11 +401,11 @@ watch(() => store.currentSchool?.schId, () => run());
|
||||
|
||||
<!-- 编辑预览弹窗(iframe 加载 /api/theme/preview,no-store) -->
|
||||
<Modal v-model="previewVisible" title="皮肤预览" :mask-closable="true" width="980px" footer-hide>
|
||||
<ThemePreviewFrame v-if="previewVisible && editTheme" :site-code="form.code" :theme="form.theme" :theme-params="editThemeParams" />
|
||||
<ThemePreviewFrame v-if="previewVisible && editTheme" :site-path="editSitePath" :theme="form.theme" :theme-params="editThemeParams" />
|
||||
</Modal>
|
||||
|
||||
<!-- 新建站点三步向导 -->
|
||||
<SiteWizard v-model:visible="wizardVisible" :sites="rows" @created="onWizardCreated" />
|
||||
<!-- 新建站点三步向导(B1:校级锁定为本校主站下建院系子站) -->
|
||||
<SiteWizard v-model:visible="wizardVisible" :sites="rows" :lock-parent-id="isSchoolAdmin ? store.currentSchool?.mainSiteId : undefined" @created="onWizardCreated" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -316,4 +419,20 @@ watch(() => store.currentSchool?.schId, () => run());
|
||||
.preview-btn {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.css-collapse {
|
||||
margin: 4px 0 12px;
|
||||
.css-editor :deep(textarea) {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.css-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Message } from "view-ui-plus";
|
||||
import { siteApi, themeApi } from "@/api/module/cms";
|
||||
import { SiteVo, ThemeVo } from "@/types/cms";
|
||||
import { formModelFromTheme, FormModelValue, mergeThemeParamsIntoConfigJson } from "@/utils/themeParamForm";
|
||||
import { previewPortalUrl } from "@/utils/sitePath";
|
||||
import ThemePicker from "@/components/theme/ThemePicker.vue";
|
||||
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
|
||||
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
|
||||
@@ -17,6 +18,8 @@ import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
sites: SiteVo[];
|
||||
/** B1 建站权限拆分:校级锁定在本校主站下建院系子站(上级禁改、学校信息由服务端继承) */
|
||||
lockParentId?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -40,18 +43,31 @@ const form = reactive({
|
||||
|
||||
const themeName = ref("default");
|
||||
const themeParams = ref<FormModelValue>({});
|
||||
const sourceSiteCode = ref<string>("");
|
||||
const sourceSitePath = ref<string>("");
|
||||
const saving = ref(false);
|
||||
|
||||
/** 内容源候选:启用中的站点(顶层/子站皆可作内容源) */
|
||||
const sourceSites = computed(() => props.sites.filter(s => s.status === 1));
|
||||
const selectedTheme = computed(() => themes.value.find(t => t.name === themeName.value));
|
||||
const canPreview = computed(() => !!sourceSiteCode.value && !!themeName.value);
|
||||
const canPreview = computed(() => !!sourceSitePath.value && !!themeName.value);
|
||||
|
||||
const previewTick = ref(0);
|
||||
const previewParams = ref<FormModelValue>({});
|
||||
|
||||
const parentOptions = computed(() => props.sites.filter(s => !s.parentSiteId));
|
||||
/** 锁定模式:上级仅本校主站;否则全部顶层站可选 */
|
||||
const parentOptions = computed(() =>
|
||||
props.lockParentId
|
||||
? props.sites.filter(s => s.id === props.lockParentId && !s.parentSiteId)
|
||||
: props.sites.filter(s => !s.parentSiteId)
|
||||
);
|
||||
|
||||
/** W4(G5):门户地址预览——随上级与编码输入联动(子站=上级地址/编码;顶层=编码) */
|
||||
const wizardParent = computed(() =>
|
||||
form.parentSiteId ? props.sites.find(s => s.id === form.parentSiteId) : undefined
|
||||
);
|
||||
const previewUrl = computed(() =>
|
||||
previewPortalUrl(wizardParent.value?.path ?? wizardParent.value?.code, form.code)
|
||||
);
|
||||
|
||||
function debouncePreview() {
|
||||
previewParams.value = { ...themeParams.value };
|
||||
@@ -71,8 +87,8 @@ watch(
|
||||
async v => {
|
||||
if (!v) return;
|
||||
step.value = 1;
|
||||
Object.assign(form, { code: "", parentSiteId: undefined, schId: undefined, schName: "", name: "", status: 1, configJson: "" });
|
||||
sourceSiteCode.value = sourceSites.value[0]?.code || "";
|
||||
Object.assign(form, { code: "", parentSiteId: props.lockParentId, schId: undefined, schName: "", name: "", status: 1, configJson: "" });
|
||||
sourceSitePath.value = sourceSites.value[0]?.path || sourceSites.value[0]?.code || "";
|
||||
if (!themes.value.length) await loadThemes();
|
||||
themeName.value = "default";
|
||||
const t = selectedTheme.value;
|
||||
@@ -128,8 +144,9 @@ async function createSite() {
|
||||
.save({
|
||||
code: form.code,
|
||||
parentSiteId: form.parentSiteId || 0,
|
||||
schId: form.schId,
|
||||
schName: form.schName || undefined,
|
||||
// B1:校级锁定模式学校信息留空,服务端校验后继承主站(防伪造)
|
||||
schId: props.lockParentId ? undefined : form.schId,
|
||||
schName: props.lockParentId ? undefined : form.schName || undefined,
|
||||
name: form.name,
|
||||
theme: themeName.value,
|
||||
configJson,
|
||||
@@ -148,7 +165,7 @@ async function createSite() {
|
||||
<template>
|
||||
<Modal
|
||||
:model-value="visible"
|
||||
:title="`新建站点向导 · 第 ${step} / 3 步`"
|
||||
:title="`${props.lockParentId ? '新建院系子站' : '新建站点'}向导 · 第 ${step} / 3 步`"
|
||||
:mask-closable="false"
|
||||
width="980px"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
@@ -168,7 +185,7 @@ async function createSite() {
|
||||
<div class="left">
|
||||
<FormItem label="预览内容源" :label-width="90">
|
||||
<Select v-model="sourceSiteCode" style="width: 260px">
|
||||
<Option v-for="s in sourceSites" :key="s.id" :value="s.code">{{ s.name }}({{ 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>
|
||||
</FormItem>
|
||||
<ThemeParamForm :theme="selectedTheme" v-model="themeParams" />
|
||||
@@ -176,7 +193,7 @@ async function createSite() {
|
||||
<div class="right">
|
||||
<ThemePreviewFrame
|
||||
v-if="canPreview"
|
||||
:site-code="sourceSiteCode"
|
||||
:site-path="sourceSitePath"
|
||||
:theme="themeName"
|
||||
:key="previewTick"
|
||||
:theme-params="previewParams"
|
||||
@@ -199,19 +216,24 @@ async function createSite() {
|
||||
<FormItem label="站点名称" prop="name">
|
||||
<Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" style="width: 320px" />
|
||||
</FormItem>
|
||||
<FormItem label="门户地址预览">
|
||||
<span class="portal-preview-url">{{ previewUrl || "输入编码后自动生成" }}</span>
|
||||
<div class="pbu-tip">子站地址 = 上级地址 / 编码;创建后经站点管理「门户路径」列直达。</div>
|
||||
</FormItem>
|
||||
<FormItem label="上级站点">
|
||||
<Select v-model="form.parentSiteId" clearable placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
|
||||
<Select v-model="form.parentSiteId" :disabled="!!props.lockParentId" placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
|
||||
<Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option>
|
||||
</Select>
|
||||
<div v-if="props.lockParentId" class="pbu-tip">校级角色仅可在本校主站下创建院系子站,学校信息自动继承主站。</div>
|
||||
</FormItem>
|
||||
<FormItem label="学校ID">
|
||||
<FormItem v-if="!props.lockParentId" label="学校ID">
|
||||
<InputNumber v-model="form.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
|
||||
<!-- B-8:顶层站 schId 必填(校级角色按此匹配管理权限);院系子站留空自动继承主站 -->
|
||||
<div class="pbu-tip">
|
||||
顶层站点必填(否则建成即校级角色不可管理);选了上级站点的院系子站可留空,自动继承主站。
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem label="学校名称">
|
||||
<FormItem v-if="!props.lockParentId" label="学校名称">
|
||||
<Input v-model="form.schName" :maxlength="128" style="width: 320px" />
|
||||
</FormItem>
|
||||
<FormItem label="状态">
|
||||
@@ -279,6 +301,11 @@ async function createSite() {
|
||||
.info-form {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.portal-preview-url {
|
||||
color: #2d8cf0;
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
.cfg-form {
|
||||
margin-top: 12px;
|
||||
padding-top: 4px;
|
||||
|
||||
@@ -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