Compare commits
20
Commits
be19ed066a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9719009b3 | ||
|
|
02bb3ac96a | ||
|
|
fe75784d13 | ||
|
|
01d91ddd4b | ||
|
|
39dfc645ff | ||
|
|
4208f8ca89 | ||
|
|
cff4325947 | ||
|
|
b6b022e4ca | ||
|
|
6334b24d34 | ||
|
|
49678a6568 | ||
|
|
1a913490ef | ||
|
|
af451703e7 | ||
|
|
010fb39e85 | ||
|
|
3a0f1e0cc9 | ||
|
|
649a08629b | ||
|
|
31cbec152a | ||
|
|
5d048abdae | ||
|
|
d05272b820 | ||
|
|
abccca4397 | ||
|
|
e5a9506228 |
@@ -19,7 +19,10 @@ jobs:
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- name: 安装 git / curl / zip(alpine)
|
||||
run: apk add --no-cache git curl zip
|
||||
run: |
|
||||
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||
apk add --no-cache git curl zip
|
||||
|
||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
@@ -51,6 +54,8 @@ jobs:
|
||||
set -e
|
||||
cd /build
|
||||
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
||||
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
npm ci
|
||||
npm run build
|
||||
'
|
||||
|
||||
+2
-2
@@ -231,7 +231,7 @@ export function seedDb(): MockDb {
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
name: "机电学院",
|
||||
theme: "cherry-bloom",
|
||||
theme: "academic-navy",
|
||||
configJson: JSON.stringify({
|
||||
public_base_url: "http://www.yunhai-demo.edu.cn/college/jdx",
|
||||
copyright: "机电学院 · Mock 数据",
|
||||
@@ -247,7 +247,7 @@ export function seedDb(): MockDb {
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
name: "信息工程学院",
|
||||
theme: "violet-iris",
|
||||
theme: "campus-azure",
|
||||
configJson: JSON.stringify({
|
||||
public_base_url: "http://www.yunhai-demo.edu.cn/college/xxgc",
|
||||
copyright: "信息工程学院 · Mock 数据",
|
||||
|
||||
+56
-14
@@ -104,31 +104,73 @@ const THEMES: MockTheme[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "cherry-bloom",
|
||||
title: "樱语粉韵",
|
||||
description: "温柔樱粉风门户:樱花粉品牌色 + 大圆角 16px 卡片网格列表,适合幼儿园与小学。",
|
||||
name: "academic-navy",
|
||||
title: "学府藏蓝",
|
||||
description: "高校官网门户:藏蓝顶栏 + 金线标题 + 日期方块列表 + 双栏要闻,适合综合类大学/工科学院。",
|
||||
version: "1.0.0",
|
||||
author: "yunhai",
|
||||
bannerSize: "1920x560",
|
||||
screenshotUrl: "/api/theme/cherry-bloom/asset/screenshot.png",
|
||||
screenshotUrl: "/api/theme/academic-navy/asset/screenshot.png",
|
||||
params: [
|
||||
{ key: "primary", cssVar: "--primary", label: "主色", type: "color", defaultValue: "#DB5A8B" },
|
||||
{ key: "primaryDark", cssVar: "--primary-dark", label: "主色加深", type: "color", defaultValue: "#B23A68" },
|
||||
{ key: "bg", cssVar: "--bg", label: "页面底色", type: "color", defaultValue: "#FFFAFB" },
|
||||
{ key: "primary", cssVar: "--an-primary", label: "主色", type: "color", defaultValue: "#0b4a8f" },
|
||||
{ key: "primaryDark", cssVar: "--an-primary-dark", label: "主色加深", type: "color", defaultValue: "#063161" },
|
||||
{ key: "accent", cssVar: "--an-accent", label: "点缀金", type: "color", defaultValue: "#b8934a" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "violet-iris",
|
||||
title: "鸢尾紫韵",
|
||||
description: "雅致鸢尾紫门户:沉静书卷气 + 圆角 12px 卡片网格列表,适合高校与教科研机构。",
|
||||
name: "campus-azure",
|
||||
title: "青蓝校园",
|
||||
description: "高校官网门户:青蓝顶栏 + 居中双线标题 + 全宽封面头条 + 圆点密行,适合师范/农林类院校。",
|
||||
version: "1.0.0",
|
||||
author: "yunhai",
|
||||
bannerSize: "1920x560",
|
||||
screenshotUrl: "/api/theme/violet-iris/asset/screenshot.png",
|
||||
screenshotUrl: "/api/theme/campus-azure/asset/screenshot.png",
|
||||
params: [
|
||||
{ key: "primary", cssVar: "--primary", label: "主色", type: "color", defaultValue: "#6552D9" },
|
||||
{ key: "primaryDark", cssVar: "--primary-dark", label: "主色加深", type: "color", defaultValue: "#4A39B0" },
|
||||
{ key: "bg", cssVar: "--bg", label: "页面底色", type: "color", defaultValue: "#F8F7FD" },
|
||||
{ key: "primary", cssVar: "--cz-primary", label: "主色", type: "color", defaultValue: "#0d6a9e" },
|
||||
{ key: "primaryDark", cssVar: "--cz-primary-dark", label: "主色加深", type: "color", defaultValue: "#084c73" },
|
||||
{ key: "accent", cssVar: "--cz-accent", label: "点缀青绿", type: "color", defaultValue: "#2b9c8f" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "poly-blue",
|
||||
title: "理工蓝(poly-blue)",
|
||||
description: "深宝蓝+活力橙的理工院校风:受众品牌行 + 白底吸顶导航 + 双栏要闻 + 日期方块 + 快捷通道宫格(参考浙江理工大学官网)。",
|
||||
version: "1.0.0",
|
||||
author: "yunhai",
|
||||
bannerSize: "1920x560",
|
||||
screenshotUrl: "/api/theme/poly-blue/asset/screenshot.png",
|
||||
params: [
|
||||
{ key: "primary", cssVar: "--pb-primary", label: "主色", type: "color", defaultValue: "#0558A5" },
|
||||
{ key: "primaryDark", cssVar: "--pb-primary-dark", label: "主色加深", type: "color", defaultValue: "#124489" },
|
||||
{ key: "accent", cssVar: "--pb-accent", label: "活力橙", type: "color", defaultValue: "#FF7B00" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "agri-forest",
|
||||
title: "农林绿(agri-forest)",
|
||||
description: "墨绿+麦穗金的农林院校风:白页头 + 墨绿通栏导航 + SVG 图标栏目标题 + 日期方块 + 专题缩略卡(参考信阳农林学院官网)。",
|
||||
version: "1.0.0",
|
||||
author: "yunhai",
|
||||
bannerSize: "1920x560",
|
||||
screenshotUrl: "/api/theme/agri-forest/asset/screenshot.png",
|
||||
params: [
|
||||
{ key: "primary", cssVar: "--af-primary", label: "主色", type: "color", defaultValue: "#1C6E19" },
|
||||
{ key: "primaryDark", cssVar: "--af-primary-dark", label: "主色加深", type: "color", defaultValue: "#14510F" },
|
||||
{ key: "accent", cssVar: "--af-accent", label: "麦穗金", type: "color", defaultValue: "#C8A45C" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "jade-normal",
|
||||
title: "师大青(jade-normal)",
|
||||
description: "翡翠青+暖橙点缀的师范院校风:橙色日期徽标 + 快捷通道图标宫格 + 融媒行(参考信阳师范大学官网)。",
|
||||
version: "1.0.0",
|
||||
author: "yunhai",
|
||||
bannerSize: "1920x560",
|
||||
screenshotUrl: "/api/theme/jade-normal/asset/screenshot.png",
|
||||
params: [
|
||||
{ key: "primary", cssVar: "--jn-primary", label: "主色", type: "color", defaultValue: "#008D3F" },
|
||||
{ key: "primaryDark", cssVar: "--jn-primary-dark", label: "主色加深", type: "color", defaultValue: "#04682E" },
|
||||
{ key: "accent", cssVar: "--jn-accent", label: "暖橙", type: "color", defaultValue: "#F18D00" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* B5 栏目工作台组件行为(方案 P1-2):
|
||||
* ① 列表型栏目 → 右侧 ArticleListPanel 且 categoryId 正确传递;
|
||||
* ② 切换树节点 → 面板 watch categoryId 联动 reload;
|
||||
* B5 栏目工作台组件行为(方案 P1-2)+ P-D1 内容管理 IA 切分(v2 §B):
|
||||
* ① 列表型栏目 → 右侧「栏目内容概览」(统计 + 最近 5 条只读 + CTA),不再渲染完整操作表;
|
||||
* ② 切换树节点 → 概览 watch categoryId 联动 reload;
|
||||
* ③ 单页型 → 摘要面板(含"编辑内容"入口)不渲染文章列表;
|
||||
* ④ 外链型 → linkUrl + 打开链接;
|
||||
* ⑤ 新建文章按钮 → 路由跳转带 categoryId query。
|
||||
* ⑤ 新建文章按钮 → 路由跳转带 categoryId query;
|
||||
* ⑥ T-D-02/03:/article 读 ?categoryId= 预筛选;概览"查看全部"深链到该地址。
|
||||
*
|
||||
* 无 @vue/test-utils 依赖:createApp 手写挂载 + vi.mock 桩(树面板/排序弹窗/图标/确认框/api/router)。
|
||||
*/
|
||||
@@ -14,20 +15,26 @@ import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
const { pageMock, routerPush, openMock, saveMock, saveRejection } = vi.hoisted(() => ({
|
||||
const { pageMock, routerPush, openMock, saveMock, saveRejection, pageResult, treeFixture, routeMock } = vi.hoisted(() => ({
|
||||
pageMock: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
openMock: vi.fn(),
|
||||
saveMock: vi.fn(),
|
||||
/** 第 N 次(0 起)save.send() 拒绝;-1=全部成功(批量添加"失败继续"用) */
|
||||
saveRejection: { value: -1 },
|
||||
/** articleApi.page 返回值桩:默认空列表(各用例按需覆盖 list/total) */
|
||||
pageResult: { value: { list: [] as any[], total: 0 } },
|
||||
/** categoryApi.tree 返回值桩:默认空树(用例按需给栏目层级) */
|
||||
treeFixture: { value: [] as any[] },
|
||||
/** useRoute 桩:可变 query,测 ?categoryId= 深链初始化 */
|
||||
routeMock: { value: { params: {}, query: {} as Record<string, string> } },
|
||||
}));
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
articleApi: {
|
||||
page: (query: any) => {
|
||||
pageMock(query);
|
||||
return { send: () => Promise.resolve({ list: [], total: 0 }) };
|
||||
return { send: () => Promise.resolve(pageResult.value) };
|
||||
},
|
||||
publish: () => ({ send: () => Promise.resolve({}) }),
|
||||
offline: () => ({ send: () => Promise.resolve({}) }),
|
||||
@@ -35,7 +42,7 @@ vi.mock("@/api/module/cms", () => ({
|
||||
delete: () => ({ send: () => Promise.resolve({}) }),
|
||||
},
|
||||
categoryApi: {
|
||||
tree: () => ({ send: () => Promise.resolve([]) }),
|
||||
tree: () => ({ send: () => Promise.resolve(treeFixture.value) }),
|
||||
save: (payload: any) => {
|
||||
const nth = saveMock.mock.calls.length;
|
||||
saveMock(payload);
|
||||
@@ -54,7 +61,7 @@ vi.mock("@/api/module/cms", () => ({
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRouter: () => ({ push: routerPush, replace: vi.fn() }),
|
||||
useRoute: () => ({ params: {}, query: {} }),
|
||||
useRoute: () => routeMock.value,
|
||||
}));
|
||||
|
||||
/** 树面板桩:把 emit 挂到全局,测试用它模拟"选中节点";捕获 props 供 treeApi 断言 */
|
||||
@@ -67,6 +74,8 @@ vi.mock("@/components/CategoryTreePanel.vue", () => ({
|
||||
emit("update:curTreeNode", node);
|
||||
emit("on-change", node);
|
||||
};
|
||||
// P-D-01:概览统计要"直属+子孙",用 on-tree-data 把(含虚拟根的)栏目树喂给 CategoryManage.treeData
|
||||
(globalThis as any).__treeEmitData = (list: any[]) => emit("on-tree-data", list);
|
||||
(globalThis as any).__treeProps = props;
|
||||
// CategoryManage 会经模板 ref 调这些方法(真实组件的 exposed API)
|
||||
expose({ loadTree: () => {}, refreshTree: () => {}, reloadTree: () => {} });
|
||||
@@ -126,7 +135,11 @@ async function flush(frames = 5) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(globalThis as any).__treeEmit = undefined;
|
||||
(globalThis as any).__treeEmitData = undefined;
|
||||
(globalThis as any).__treeProps = undefined;
|
||||
pageResult.value = { list: [], total: 0 };
|
||||
treeFixture.value = [];
|
||||
routeMock.value = { params: {}, query: {} };
|
||||
currentStore = null;
|
||||
window.open = openMock as any;
|
||||
});
|
||||
@@ -168,6 +181,19 @@ describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () =
|
||||
expect(pageMock.mock.calls[1][0]).toMatchObject({ categoryIds: 7 });
|
||||
});
|
||||
|
||||
it("T-D-02 文章页 ?categoryId= 深链:读 query 初始化栏目筛选并回显下拉", async () => {
|
||||
treeFixture.value = [{ id: 5, name: "新闻中心", type: 1, children: [{ id: 51, name: "校园时讯", type: 1 }] }];
|
||||
routeMock.value = { params: {}, query: { categoryId: "5" } };
|
||||
const el = mountApp(defineComponent({
|
||||
setup: () => () => h(ArticleListPanel, {}),
|
||||
}));
|
||||
await flush();
|
||||
|
||||
// query 里的字符串 id 须转成数字:既对齐后端 categoryIds,也让栏目下拉回显选中项
|
||||
expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryIds: 5 });
|
||||
expect(el.querySelector(".ivu-select-selected-value")?.textContent).toContain("新闻中心");
|
||||
});
|
||||
|
||||
it("⑤ 面板新建文章:非固定模式带 categoryId query 跳转;固定模式入口收敛到工作台面板头(面板内无重复按钮)", async () => {
|
||||
const state = reactive({ cid: 5, fixed: false });
|
||||
const host = defineComponent({
|
||||
@@ -218,15 +244,75 @@ describe("categoryWorkbench - CategoryManage 右侧类型化面板", () => {
|
||||
expect(openMock).toHaveBeenCalledWith("https://www.moe.gov.cn", "_blank");
|
||||
});
|
||||
|
||||
it("列表型栏目:右侧渲染 ArticleListPanel(categoryId=选中栏目)", async () => {
|
||||
it("T-D-01 列表型栏目:右侧降级为「栏目内容概览」(统计 + 最近 5 条只读),不再渲染操作表", async () => {
|
||||
pageResult.value = {
|
||||
total: 12,
|
||||
list: [1, 2, 3, 4, 5, 6].map(i => ({
|
||||
id: 900 + i,
|
||||
title: `云海大学第${i}次党代会筹备推进会`,
|
||||
categoryId: 5,
|
||||
categoryName: "新闻中心",
|
||||
status: 1,
|
||||
publishTime: "2026-09-20 10:00",
|
||||
topmost: 0,
|
||||
})),
|
||||
};
|
||||
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
|
||||
await flush();
|
||||
// 统计口径 = 直属 + 子孙栏目:树经 on-tree-data 下发(含 W2 虚拟根),子孙 51/52 须并入计数
|
||||
(globalThis as any).__treeEmitData([
|
||||
{
|
||||
id: 0,
|
||||
name: "演示学校",
|
||||
virtual: true,
|
||||
children: [
|
||||
{
|
||||
id: 5,
|
||||
name: "新闻中心",
|
||||
type: 1,
|
||||
children: [
|
||||
{ id: 51, name: "校园时讯", type: 1 },
|
||||
{ id: 52, name: "通知公告", type: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
(globalThis as any).__treeEmit({ id: 5, parentId: 0, name: "新闻中心", type: 1, sortNo: 0, status: 1 });
|
||||
await flush();
|
||||
|
||||
const box = el.querySelector(".category-content-summary");
|
||||
expect(box).toBeTruthy();
|
||||
// 完整的文章操作表不再出现在栏目页(唯一操作台是 /article)
|
||||
expect(el.querySelector(".article-list-panel")).toBeNull();
|
||||
expect(box!.querySelector(".ivu-table")).toBeNull();
|
||||
// 概览内不得出现文章操作入口(发布/下架/置顶/删除按钮);状态说明文字不算
|
||||
const affordances = [...box!.querySelectorAll("button, a")].map(n => (n.textContent || "").trim());
|
||||
expect(affordances.some(t => ["发布", "下架", "取消置顶", "置顶", "删除"].includes(t))).toBe(false);
|
||||
|
||||
// 统计查询:直属+子孙(逗号分隔,后端 List<Integer> 契约)、全状态(不带 status)、只要最近 5 条
|
||||
expect(pageMock).toHaveBeenCalled();
|
||||
const q = pageMock.mock.calls[0][0];
|
||||
expect(q).toMatchObject({ siteId: 1, categoryIds: "5,51,52", page: 1, pageSize: 5 });
|
||||
expect(q.status).toBeUndefined();
|
||||
|
||||
expect(box!.textContent).toContain("共 12 篇");
|
||||
// 只读 5 条(第 6 条不渲染)
|
||||
expect(box!.querySelectorAll(".ccs-row")).toHaveLength(5);
|
||||
expect(box!.textContent).toContain("云海大学第1次党代会筹备推进会");
|
||||
});
|
||||
|
||||
it("T-D-03 概览「查看全部 →」跳 /article 深链(带 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({ categoryIds: 5 });
|
||||
const more = [...el.querySelectorAll("a, button")].find(b => b.textContent?.includes("查看全部"));
|
||||
expect(more).toBeTruthy();
|
||||
more!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article?categoryId=5");
|
||||
});
|
||||
|
||||
it("未选中节点:右侧显示空态引导", async () => {
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 首页布局编辑器(v2 工作项 B → docs/20 F2 三栏升级)挂载回归:
|
||||
* T-B-09 quick_links/notice_bar 属性面板行为不变;
|
||||
* F2 新增:三栏结构(序列/预览/属性)、预览视口三档切换、类型卡 SVG 缩略图(R-40)、
|
||||
* 展示形态 RadioCard、首用自动编排(无 home_layout + 有列表栏目 → 自动生成)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
|
||||
|
||||
const treeMock = vi.hoisted(() => ({ send: vi.fn() }));
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
categoryApi: { tree: () => treeMock },
|
||||
}));
|
||||
|
||||
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
/** 排空微任务 + 一个宏任务(view-ui-plus 的 Select 标签回填走 nextTick/setTimeout) */
|
||||
async function settle(frames = 4) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
const CONFIG = JSON.stringify({
|
||||
logo: "/a.png",
|
||||
home_layout: {
|
||||
version: 1,
|
||||
blocks: [
|
||||
{ id: "q1", type: "quick_links", items: [{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }] },
|
||||
{ id: "n1", type: "notice_bar", text: "国庆放假安排", url: "/notice/gq", level: "warn" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const TREE: CategoryTreeNode[] = [
|
||||
{ id: 11, name: "校园时讯", type: 1, children: [] },
|
||||
{ id: 12, name: "媒体聚焦", type: 1, children: [] },
|
||||
] as unknown as CategoryTreeNode[];
|
||||
|
||||
async function mountEditor(
|
||||
configJson: string,
|
||||
opts?: { siteId?: number; portalUrl?: string; siteIdRef?: ReturnType<typeof ref<number | undefined>> },
|
||||
) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const model = ref(configJson);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
render: () =>
|
||||
h(HomeLayoutEditor, {
|
||||
modelValue: model.value,
|
||||
siteId: opts?.siteIdRef ? opts.siteIdRef.value : opts?.siteId,
|
||||
portalUrl: opts?.portalUrl,
|
||||
"onUpdate:modelValue": (v: string) => (model.value = v),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
});
|
||||
await settle(6);
|
||||
return { el, model };
|
||||
}
|
||||
|
||||
/** 取属性面板中指定 label 的字段容器 */
|
||||
function fieldOf(el: HTMLElement, label: string): HTMLElement | null {
|
||||
return (
|
||||
Array.from(el.querySelectorAll<HTMLElement>(".hle-field")).find(
|
||||
f => f.querySelector(".hle-field-label")?.textContent?.trim() === label,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
/** 第 index 行的删除按钮 */
|
||||
function del(field: HTMLElement, index: number): HTMLElement {
|
||||
return field.querySelectorAll<HTMLElement>(".hle-link-del")[index];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
treeMock.send.mockReset();
|
||||
treeMock.send.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach(fn => fn());
|
||||
});
|
||||
|
||||
describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09,F2 改造后回归)", () => {
|
||||
it("quick_links:入口行编辑器渲染 name+url 双输入,添加/删除可用且回写 items", async () => {
|
||||
const { el, model } = await mountEditor(CONFIG);
|
||||
// 序列行摘要走 blockSummary 新增分支
|
||||
expect(el.textContent).toContain("1 个入口");
|
||||
expect(el.textContent).toContain("醒目提示");
|
||||
|
||||
// 选中 quick_links 行 → 属性面板出现入口列表编辑器
|
||||
(el.querySelectorAll(".hle-row")[0] as HTMLElement).click();
|
||||
await settle();
|
||||
const field = fieldOf(el as HTMLElement, "入口列表");
|
||||
expect(field).not.toBeNull();
|
||||
expect(field!.querySelectorAll(".hle-link-row").length).toBe(1);
|
||||
expect(field!.querySelectorAll(".hle-link-row input").length).toBe(2);
|
||||
const add = field!.querySelector(".hle-link-add") as HTMLElement;
|
||||
expect(add.textContent).toContain("添加入口");
|
||||
|
||||
add.click();
|
||||
await settle();
|
||||
expect(field!.querySelectorAll(".hle-link-row").length).toBe(2);
|
||||
// 新增行是空行:回写布局时空行由 cleanBlock 剔除,故提交形态仍只 1 项
|
||||
const blocks = JSON.parse(model.value).home_layout.blocks;
|
||||
expect(blocks[0].items).toEqual([{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }]);
|
||||
// 其余键无损
|
||||
expect(JSON.parse(model.value).logo).toBe("/a.png");
|
||||
|
||||
// 删掉尾部的空行 → 回到 1 行,提交形态不变
|
||||
del(field!, 1).click();
|
||||
await settle();
|
||||
expect(field!.querySelectorAll(".hle-link-row").length).toBe(1);
|
||||
expect(JSON.parse(model.value).home_layout.blocks[0].items).toEqual([{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }]);
|
||||
|
||||
// 再删唯一入口行 → 空列表(提交时 items 不落键,由后端校验拦下「至少 1 个入口」)
|
||||
del(field!, 0).click();
|
||||
await settle();
|
||||
expect(field!.querySelectorAll(".hle-link-row").length).toBe(0);
|
||||
expect(JSON.parse(model.value).home_layout.blocks[0].items).toBeUndefined();
|
||||
});
|
||||
|
||||
it("quick_links:12 项上限时添加按钮禁用", async () => {
|
||||
const items = Array.from({ length: 12 }, (_, i) => ({ name: `入口${i}`, url: `/e${i}` }));
|
||||
const config = JSON.stringify({ home_layout: { version: 1, blocks: [{ id: "q1", type: "quick_links", items }] } });
|
||||
const { el } = await mountEditor(config);
|
||||
(el.querySelectorAll(".hle-row")[0] as HTMLElement).click();
|
||||
await settle();
|
||||
const field = fieldOf(el as HTMLElement, "入口列表")!;
|
||||
expect(field.querySelectorAll(".hle-link-row").length).toBe(12);
|
||||
expect((field.querySelector(".hle-link-add") as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("notice_bar:正文输入放宽到 200 字、跳转可选、level 为 info/warn 选择器", async () => {
|
||||
const { el } = await mountEditor(CONFIG);
|
||||
(el.querySelectorAll(".hle-row")[1] as HTMLElement).click();
|
||||
await settle();
|
||||
const text = fieldOf(el as HTMLElement, "公告正文")!.querySelector("input")!;
|
||||
expect(text.getAttribute("maxlength")).toBe("200");
|
||||
expect(fieldOf(el as HTMLElement, "跳转地址")).not.toBeNull();
|
||||
const levelField = fieldOf(el as HTMLElement, "提示级别")!;
|
||||
expect(levelField.querySelector(".ivu-select")).not.toBeNull();
|
||||
expect(levelField.textContent).toContain("醒目");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HomeLayoutEditor - F2 三栏 + 预览 + 缩略图 + 自动编排(docs/20)", () => {
|
||||
it("三栏结构:序列 / 实时预览 iframe / 属性面板;视口三档与刷新按钮齐备", async () => {
|
||||
const { el } = await mountEditor(CONFIG, { portalUrl: "http://x/portal/demo/" });
|
||||
expect(el.querySelector(".hle-sequence")).toBeTruthy();
|
||||
expect(el.querySelector(".hle-props")).toBeTruthy();
|
||||
const preview = el.querySelector(".hle-preview")!;
|
||||
expect(preview).toBeTruthy();
|
||||
expect(preview.querySelector("iframe")).toBeTruthy();
|
||||
expect(preview.querySelector("iframe")!.getAttribute("src")).toBe("http://x/portal/demo/");
|
||||
// 视口三档
|
||||
expect(preview.textContent).toContain("桌面 1200");
|
||||
expect(preview.textContent).toContain("平板 768");
|
||||
expect(preview.textContent).toContain("手机 390");
|
||||
const refresh = [...preview.querySelectorAll("button")].find(b => b.textContent?.includes("刷新预览"));
|
||||
expect(refresh).toBeTruthy();
|
||||
});
|
||||
|
||||
it("添加抽屉:每个类型卡配 SVG 缩略示意图(R-40,注册表全量)", async () => {
|
||||
const { el } = await mountEditor(CONFIG);
|
||||
const addBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("添加区块"))!;
|
||||
addBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await settle();
|
||||
// Drawer 渲染到 body 级传送门
|
||||
const drawer = document.querySelector(".hle-drawer") || document.body;
|
||||
const cards = drawer.querySelectorAll(".hle-type-card");
|
||||
expect(cards.length).toBeGreaterThanOrEqual(6);
|
||||
cards.forEach(c => {
|
||||
expect(c.querySelector(".hle-type-thumb svg")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("展示形态 = RadioCard 可视化选择(5 张缩略图 + 文字,非纯文字下拉)", async () => {
|
||||
const config = JSON.stringify({
|
||||
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 }] },
|
||||
});
|
||||
const { el, model } = await mountEditor(config);
|
||||
(el.querySelector(".hle-row") as HTMLElement).click();
|
||||
await settle();
|
||||
const styleField = fieldOf(el as HTMLElement, "展示形态")!;
|
||||
expect(styleField.querySelector(".hle-style-cards")).toBeTruthy();
|
||||
expect(styleField.querySelectorAll(".hle-style-card").length).toBe(5);
|
||||
expect(styleField.querySelectorAll(".hle-style-card svg").length).toBe(5);
|
||||
// 点击第 2 张卡(news_grid)→ 选中态 + 回写布局
|
||||
(styleField.querySelectorAll(".hle-style-card")[1] as HTMLElement).click();
|
||||
await settle();
|
||||
expect(styleField.querySelectorAll(".hle-style-card")[1].className).toContain("active");
|
||||
expect(JSON.parse(model.value).home_layout.blocks[0].style).toBe("news_grid");
|
||||
});
|
||||
|
||||
it("首用自动编排:无 home_layout 键 + 站点有列表栏目 → 自动生成初始序列(D-4)", async () => {
|
||||
treeMock.send.mockResolvedValue(TREE);
|
||||
const noLayoutConfig = JSON.stringify({ logo: "/a.png" });
|
||||
const { model } = await mountEditor(noLayoutConfig, { siteId: 1 });
|
||||
const layout = JSON.parse(model.value).home_layout;
|
||||
expect(layout).toBeTruthy();
|
||||
expect(layout.blocks.length).toBeGreaterThanOrEqual(4); // ticker + 2 栏目区块 + gallery
|
||||
expect(layout.blocks[0].type).toBe("ticker");
|
||||
expect(layout.blocks.some((b: { type: string; title?: string }) => b.type === "article_list" && b.title === "校园时讯")).toBe(true);
|
||||
expect(layout.blocks.some((b: { type: string }) => b.type === "gallery")).toBe(true);
|
||||
// 其余键无损
|
||||
expect(JSON.parse(model.value).logo).toBe("/a.png");
|
||||
});
|
||||
|
||||
it("显式空布局(blocks=[])不触发自动编排(用户选择优先)", async () => {
|
||||
treeMock.send.mockResolvedValue(TREE);
|
||||
const emptyLayout = JSON.stringify({ home_layout: { version: 1, blocks: [] } });
|
||||
const { model } = await mountEditor(emptyLayout, { siteId: 1 });
|
||||
expect(JSON.parse(model.value).home_layout.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("无列表栏目不触发自动编排", async () => {
|
||||
treeMock.send.mockResolvedValue([]);
|
||||
const { model } = await mountEditor(JSON.stringify({ logo: "/a.png" }), { siteId: 1 });
|
||||
expect(JSON.parse(model.value).home_layout).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HomeLayoutEditor - 栏目树加载与首用编排解耦(docs/20 返工 #1)", () => {
|
||||
it("已有布局 + 有列表栏目 + siteId:栏目树仍无条件加载,数据栏目可勾选", async () => {
|
||||
treeMock.send.mockResolvedValue(TREE);
|
||||
const config = JSON.stringify({
|
||||
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 }] },
|
||||
});
|
||||
const { el, model } = await mountEditor(config, { siteId: 1 });
|
||||
// 树加载发生(幂等标记下 treeMock 恰好 1 次调用)
|
||||
expect(treeMock.send).toHaveBeenCalledTimes(1);
|
||||
(el.querySelector(".hle-row") as HTMLElement).click();
|
||||
await settle();
|
||||
const cats = fieldOf(el as HTMLElement, "数据栏目")!;
|
||||
expect(cats).not.toBeNull();
|
||||
expect(cats.querySelectorAll(".hle-cat").length).toBe(2);
|
||||
expect(cats.textContent).toContain("校园时讯");
|
||||
expect(cats.textContent).toContain("媒体聚焦");
|
||||
// 已有布局不被自动编排改写
|
||||
expect(JSON.parse(model.value).home_layout.blocks[0].id).toBe("a1");
|
||||
});
|
||||
|
||||
it("siteId 晚于挂载到位(父级异步种入):watch 补跑加载,数据栏目可用", async () => {
|
||||
treeMock.send.mockResolvedValue(TREE);
|
||||
const siteIdRef = ref<number | undefined>(undefined);
|
||||
const config = JSON.stringify({
|
||||
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [], style: "card", size: 8 }] },
|
||||
});
|
||||
const { el } = await mountEditor(config, { siteIdRef });
|
||||
await settle();
|
||||
// siteId 未到位时不请求
|
||||
expect(treeMock.send).not.toHaveBeenCalled();
|
||||
siteIdRef.value = 1;
|
||||
await settle(6);
|
||||
expect(treeMock.send).toHaveBeenCalledTimes(1);
|
||||
(el.querySelector(".hle-row") as HTMLElement).click();
|
||||
await settle();
|
||||
const cats = fieldOf(el as HTMLElement, "数据栏目")!;
|
||||
expect(cats.querySelectorAll(".hle-cat").length).toBe(2);
|
||||
});
|
||||
|
||||
it("watch + onMounted 双触发下幂等:同 siteId 只请求一次", async () => {
|
||||
treeMock.send.mockResolvedValue(TREE);
|
||||
const siteIdRef = ref<number | undefined>(1);
|
||||
await mountEditor(JSON.stringify({ logo: "/a.png" }), { siteIdRef });
|
||||
await settle(6);
|
||||
expect(treeMock.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HomeLayoutEditor - 区块序列序号徽标(docs/20 返工 #3)", () => {
|
||||
it("每行右上角显示连续序号(1 起始、含隐藏区块不重排),与校验报错「第 N 个区块」对齐", async () => {
|
||||
const config = JSON.stringify({
|
||||
home_layout: {
|
||||
version: 1,
|
||||
blocks: [
|
||||
{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 },
|
||||
{ id: "n1", type: "notice_bar", text: "公告", visible: false },
|
||||
{ id: "g1", type: "gallery" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const { el } = await mountEditor(config);
|
||||
const nos = Array.from(el.querySelectorAll(".hle-row-no")).map(n => n.textContent!.trim());
|
||||
expect(nos).toEqual(["1", "2", "3"]);
|
||||
// 隐藏区块(第 2 行)保留序号、行整体置灰,编号不重排
|
||||
const rows = Array.from(el.querySelectorAll(".hle-row"));
|
||||
expect(rows[1].className).toContain("hidden");
|
||||
expect(rows[1].querySelector(".hle-row-no")!.textContent!.trim()).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HomeLayoutEditor - D 组件属性面板(docs/20 D-1/D-8)", () => {
|
||||
it("stats_band:统计项三列编辑器(名称/数值/单位)渲染并可增行", async () => {
|
||||
const config = JSON.stringify({
|
||||
home_layout: { version: 1, blocks: [
|
||||
{ id: "s1", type: "stats_band", stats: [{ label: "在校生", value: "3.2", unit: "万" }] },
|
||||
] },
|
||||
});
|
||||
const { el } = await mountEditor(config);
|
||||
(el.querySelector(".hle-row") as HTMLElement).click();
|
||||
await settle();
|
||||
const field = fieldOf(el as HTMLElement, "统计项")!;
|
||||
expect(field.querySelector(".hle-stat-row")).toBeTruthy();
|
||||
expect((field.querySelector(".hle-stat-row input") as HTMLInputElement)).toBeTruthy();
|
||||
const add = field.querySelector(".hle-stat-add, .hle-link-add") as HTMLElement;
|
||||
add.click();
|
||||
await settle();
|
||||
expect(field.querySelectorAll(".hle-stat-row").length).toBe(2);
|
||||
});
|
||||
|
||||
it("gallery:布局形态下拉含瀑布流(campus_wall)选项且选中回写", async () => {
|
||||
const config = JSON.stringify({
|
||||
home_layout: { version: 1, blocks: [{ id: "g1", type: "gallery" }] },
|
||||
});
|
||||
const { el, model } = await mountEditor(config);
|
||||
(el.querySelector(".hle-row") as HTMLElement).click();
|
||||
await settle();
|
||||
const field = fieldOf(el as HTMLElement, "布局形态")!;
|
||||
const select = field.querySelector(".ivu-select") as HTMLElement;
|
||||
expect(select).toBeTruthy();
|
||||
// 展开下拉点选 masonry
|
||||
select.querySelector(".ivu-select-selection").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await settle(8);
|
||||
const opt = [...document.querySelectorAll(".ivu-select-item")].find(n => n.textContent?.includes("瀑布流")) as HTMLElement;
|
||||
expect(opt).toBeTruthy();
|
||||
opt.click();
|
||||
await settle(8);
|
||||
expect(JSON.parse(model.value).home_layout.blocks[0].layout).toBe("masonry");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* SiteConfigForm 组件级回归(docs/20 返工 #4):
|
||||
* - 本分区无必填字段:全空 → validate() 通过(返回 null),不因「必填」拦保存;
|
||||
* - 全合法值 → validate() 通过;
|
||||
* - 填了但格式非法 → validate() 返回该字段的明确错误(「字段名:原因」),
|
||||
* 且表单内出现 iView 内联红框(.ivu-form-item-error),不再有「红星必填」式误导。
|
||||
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 homeLayoutEditor.spec.ts 一致)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
|
||||
|
||||
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
|
||||
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
/** 排空微任务 + 一个宏任务(iView 校验态/DOM 类名更新走 nextTick) */
|
||||
async function settle(frames = 4) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
async function mountForm(modelValue: string, groups: string[]) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const model = ref(modelValue);
|
||||
const childRef = ref<any>(null);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(SiteConfigForm, {
|
||||
ref: childRef,
|
||||
modelValue: model.value,
|
||||
groups,
|
||||
"onUpdate:modelValue": (v: string) => (model.value = v),
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
});
|
||||
await settle(6);
|
||||
return { el, model, child: childRef };
|
||||
}
|
||||
|
||||
/** 模拟真实输入:走原生 input 事件(iView Input 据此回填 v-model 并触发 on-change 回写) */
|
||||
async function typeByPlaceholder(el: HTMLElement, placeholder: string, value: string) {
|
||||
const input = Array.from(el.querySelectorAll<HTMLInputElement>("input, textarea")).find(
|
||||
n => n.getAttribute("placeholder") === placeholder,
|
||||
);
|
||||
expect(input, `placeholder ${placeholder} 对应的输入框应存在`).toBeTruthy();
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input!, value);
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
beforeEach(() => {});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach(fn => fn());
|
||||
});
|
||||
|
||||
describe("SiteConfigForm 校验(docs/20 返工 #4)", () => {
|
||||
it("全空(无必填字段)→ validate() 通过返回 null", async () => {
|
||||
const { child } = await mountForm("", ["display"]);
|
||||
const err = await child.value.validate();
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
|
||||
it("全合法值 → validate() 通过返回 null", async () => {
|
||||
const { el, child } = await mountForm("", ["display"]);
|
||||
await typeByPlaceholder(el, "门户顶栏展示的一句话标语(可选)", "云海大学欢迎您");
|
||||
await typeByPlaceholder(el, "留空默认「专业院系」", "院系导航");
|
||||
const err = await child.value.validate();
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
|
||||
it("合法 configJson 种入 + 手填非法 URL → 返回「字段名:原因」,出现内联红框", async () => {
|
||||
const { el, child } = await mountForm(JSON.stringify({ logo: "/yschcms/upload/2026/06/logo.png" }), ["footer"]);
|
||||
await typeByPlaceholder(el, "微博主页链接 https://weibo.com/…(留空不展示图标)", "不是链接");
|
||||
const err = await child.value.validate();
|
||||
expect(typeof err).toBe("string");
|
||||
expect(err).toContain("微博");
|
||||
expect(err).toContain("http");
|
||||
// 内联红框 + 字段下方错误文案存在
|
||||
expect(el.querySelector(".ivu-form-item-error .ivu-form-item-error-tip")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("asset 字段非法值同样精确报错(种入非法 logo 值)", async () => {
|
||||
// REWORK-DECISION: 经控件 UI 填 asset 值的路径依赖上传(jsdom 不可行)与「手动粘贴」通道
|
||||
// (其「填入」按钮因 slot="append" 旧语法在 Vue 3 下不渲染,属本次返工范围外的存量缺陷,
|
||||
// 见返工报告遗留项),故从种入 configJson 驱动:等价覆盖「存在非法 asset 值 → 精确报错」。
|
||||
const { el, child } = await mountForm(JSON.stringify({ logo: "随便写的路径" }), ["display"]);
|
||||
const err = await child.value.validate();
|
||||
expect(typeof err).toBe("string");
|
||||
expect(err).toContain("站点 Logo");
|
||||
expect(err).toContain("上传路径");
|
||||
expect(el.querySelector(".ivu-form-item-error")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -30,9 +30,10 @@ vi.mock("vue-router", () => ({
|
||||
import SchoolLayout from "@/components/SchoolLayout.vue";
|
||||
import { useCmsStore } from "@/store";
|
||||
|
||||
/** 站点卡皮肤包名取 user.sites[].theme(后端 AuthUserVo.sites = List<SiteVo>,字段已具备,零后端改动) */
|
||||
const SITES = [
|
||||
{ id: 1, code: "demo", name: "云海大学门户", path: "demo", parentSiteId: 0, schId: 1001, schName: "云海大学" },
|
||||
{ id: 2, code: "jdxy", name: "机电学院", path: "demo/jdxy", parentSiteId: 1, schId: 1001, schName: "云海大学" },
|
||||
{ id: 1, code: "demo", name: "云海大学门户", path: "demo", parentSiteId: 0, theme: "scholar-blue", schId: 1001, schName: "云海大学" },
|
||||
{ id: 2, code: "jdxy", name: "机电学院", path: "demo/jdxy", parentSiteId: 1, theme: "campus-glow", schId: 1001, schName: "云海大学" },
|
||||
{ id: 3, code: "wtxy", name: "文学院", path: "demo/wtxy", parentSiteId: 1, schId: 1001, schName: "云海大学" },
|
||||
];
|
||||
|
||||
@@ -296,3 +297,57 @@ describe("docs/13 身份条次级入口与幂等切换(docs/15 T5)", () => {
|
||||
expect(box.push).toHaveBeenCalledWith("/sch/demo/site/jdxy/site");
|
||||
});
|
||||
});
|
||||
|
||||
/* ===== docs/22 v3:页头身份「学校名.站点名」 + 侧栏站点卡撤除 =====
|
||||
口径:页头 .school-cap(> .sc-sep + .sc-site)是唯一常驻身份点,主站不写站全名统一「主站」;
|
||||
切站入口收敛到面包屑站点身份条(T-B-* / SiteSwitcher 用例)与站点工作台卡片两处。
|
||||
原 v2 的卡内切站用例(T-C-01b)随卡撤除而删除。 */
|
||||
describe("P-C1 页头身份与侧栏卡撤除(docs/22 v3)", () => {
|
||||
it("T-C-01 主站态:侧栏 .menu-top-box 复归空档(卡已撤),页头 = 学校名.主站", async () => {
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
// 站点卡撤除:侧栏顶部空档恢复为空 div(回到加卡之前的 layout.css 形态)
|
||||
const topBox = el.querySelector(".left-menu .menu-top-box");
|
||||
expect(topBox).toBeTruthy();
|
||||
expect(topBox!.children).toHaveLength(0);
|
||||
|
||||
// 页头身份:学校名.站点名 —— 主站档位统一「主站」,不写站全名
|
||||
expect(el.querySelector(".header-container h4")!.textContent).toContain("云海大学.主站");
|
||||
expect(el.querySelector(".header-container .school-cap")!.textContent).toContain("云海大学");
|
||||
expect(el.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("主站");
|
||||
});
|
||||
|
||||
it("T-C-02 身份染色:子站态 .school-cap 挂 band-sub、.sc-site=站名;主站态 band-main、.sc-site=主站", async () => {
|
||||
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
|
||||
const sub = mountLayout();
|
||||
await flush();
|
||||
// 侧栏/面包屑色带档位保留(identityClass 仍在 .left-menu / .path-nav 上工作)
|
||||
expect(sub.querySelector(".left-menu")!.className).toContain("band-sub");
|
||||
expect(sub.querySelector(".header-container .school-cap")!.classList.contains("band-sub")).toBe(true);
|
||||
expect(sub.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("机电学院");
|
||||
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const main = mountLayout();
|
||||
await flush();
|
||||
expect(main.querySelector(".left-menu")!.className).toContain("band-main");
|
||||
expect(main.querySelector(".header-container .school-cap")!.classList.contains("band-sub")).toBe(false);
|
||||
expect(main.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("主站");
|
||||
});
|
||||
|
||||
it("T-C-03 标题带站点:页头与 document.title 均为「页面名 · 学校名.站点名」,切站后跟随", async () => {
|
||||
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
expect(document.title).toBe("文章列表 · 云海大学.机电学院");
|
||||
expect(el.querySelector(".header-container h4")!.textContent).toContain("云海大学.机电学院");
|
||||
|
||||
// 同一壳内切回主站形态:标题与 document.title 同步为「学校名.主站」
|
||||
box.route.path = "/sch/demo/article";
|
||||
box.route.fullPath = "/sch/demo/article";
|
||||
box.route.params = { schCode: "demo" };
|
||||
await flush();
|
||||
expect(document.title).toBe("文章列表 · 云海大学.主站");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 站点管理列表页回归(docs/16 主线A 整页化后):
|
||||
* 行「编辑」→ 跳站点设置中心 site/setting/:id(弹窗已退役);「新建站点」→ site/new 创建模式;
|
||||
* 站点管理页回归:docs/22 站点工作台(默认卡片视图)+ 表格视图保留。
|
||||
* 行「站点设置」→ 跳站点设置中心 site/setting/:id(弹窗已退役);「新建站点」→ site/new 创建模式;
|
||||
* 子站行带 URL 站点段(docs/13 adminPathOf 口径)。沿用手写 createApp 挂载桩风格。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -93,6 +93,13 @@ function clickRowAction(el: ParentNode, iconClass: string, rowIndex = 0) {
|
||||
return btn;
|
||||
}
|
||||
|
||||
/** docs/22 后默认视图为卡片;表格行操作用例先切到表格视图 */
|
||||
async function switchToTable(el: ParentNode) {
|
||||
const btn = [...el.querySelectorAll(".seg button")].find(b => b.textContent?.includes("表格"));
|
||||
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([siteRow])));
|
||||
@@ -105,9 +112,10 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SiteManage 列表页(docs/16 整页化后)", () => {
|
||||
it("① 行「编辑」跳站点设置中心(主站形态无站点段)", async () => {
|
||||
it("① 行「站点设置」跳站点设置中心(主站形态无站点段)", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
await switchToTable(el);
|
||||
|
||||
const btn = clickRowAction(el, "icon-edit");
|
||||
expect(btn).toBeTruthy();
|
||||
@@ -116,13 +124,14 @@ describe("SiteManage 列表页(docs/16 整页化后)", () => {
|
||||
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/setting/1");
|
||||
});
|
||||
|
||||
it("② 子站行「编辑」带 URL 站点段(/sch/{main}/site/{sub}/site/setting/{id})", async () => {
|
||||
it("② 子站行「站点设置」带 URL 站点段(/sch/{main}/site/{sub}/site/setting/{id})", async () => {
|
||||
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([
|
||||
siteRow,
|
||||
{ ...siteRow, id: 2, code: "jdxy", path: "demo/jdxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "机械学院" },
|
||||
])));
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
await switchToTable(el);
|
||||
|
||||
clickRowAction(el, "icon-edit", 1);
|
||||
await flush();
|
||||
@@ -143,3 +152,58 @@ describe("SiteManage 列表页(docs/16 整页化后)", () => {
|
||||
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("站点工作台(docs/22 v3)", () => {
|
||||
/** 1 主站 + 2 子站:卡片工作台的最小分组样本 */
|
||||
function mockOneMainTwoSubs() {
|
||||
siteListMock.mockResolvedValue(
|
||||
JSON.parse(
|
||||
JSON.stringify([
|
||||
siteRow,
|
||||
{ ...siteRow, id: 2, code: "jdxy", path: "demo/jdxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "机电学院" },
|
||||
{ ...siteRow, id: 3, code: "wtxy", path: "demo/wtxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "文学院" },
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
it("T-SM-01 默认卡片视图:主站宽卡 1 + 子站网格 2,当前站唯一高亮;表格视图可切回", async () => {
|
||||
mockOneMainTwoSubs();
|
||||
const el = mountPage(store => {
|
||||
store.user = { superAdmin: true } as any;
|
||||
store.currentSite = { id: 2, code: "jdxy", name: "机电学院" } as any;
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(el.querySelectorAll(".main-card")).toHaveLength(1);
|
||||
expect(el.querySelectorAll(".site-card")).toHaveLength(2);
|
||||
expect(el.querySelectorAll(".site-card.is-cur")).toHaveLength(1);
|
||||
// 主站卡「子站」统计 = 过滤前真实规模(SiteVo 无栏目/文章字段,仅子站数必显)
|
||||
expect(el.querySelector(".main-card .mc-stats")!.textContent).toContain("2");
|
||||
|
||||
// 切表格视图:卡片网格消失、ivu-table 出现
|
||||
await switchToTable(el);
|
||||
expect(el.querySelector(".card-grid")).toBeNull();
|
||||
expect(el.querySelector(".ivu-table")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("T-SM-02 搜索过滤:命中子站保留、主站宽卡不消失(层级锚点),子站统计不随过滤变化", async () => {
|
||||
mockOneMainTwoSubs();
|
||||
const el = mountPage(store => {
|
||||
store.user = { superAdmin: true } as any;
|
||||
store.currentSite = { id: 1, code: "demo", name: "云海大学门户网站" } as any;
|
||||
});
|
||||
await flush();
|
||||
|
||||
const input = el.querySelector(".seg-search input") as HTMLInputElement;
|
||||
input.value = "机电";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(el.querySelectorAll(".main-card")).toHaveLength(1);
|
||||
expect(el.querySelectorAll(".site-card")).toHaveLength(1);
|
||||
expect(el.querySelector(".site-card")!.textContent).toContain("机电学院");
|
||||
// rawSubs 按过滤前全量行统计:仍是 2 个子站
|
||||
expect(el.querySelector(".main-card .mc-stats")!.textContent).toContain("2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 站点设置中心(docs/16 主线A)挂载回归:
|
||||
* ① 创建模式:③~⑦锁定("创建站点后开放"×5)、主按钮=「创建站点」、
|
||||
* 校级锁定本校主站时门户地址预览随编码联动(原向导 T-W4-18 能力收编);
|
||||
* ② 编辑模式:基本信息回填、未注册皮肤告警保留原值(任务三行为迁入②分区);
|
||||
* ③ 分区保存:保存基本信息 = 当前 configJson 事实源全量提交(其他分区键不丢)。
|
||||
* 站点设置中心(docs/16 主线A → docs/20 F1 Tab 化 + 渐进式向导)挂载回归:
|
||||
* ① 创建模式(渐进式向导):无任何「创建站点后开放」灰态;主按钮=「创建站点,下一步」;
|
||||
* 创建成功自动进入第二步(皮肤 Tab + Steps + 「保存并完成」);
|
||||
* 校级锁定本校主站时门户地址预览随编码联动;
|
||||
* ② 编辑模式:7 Tab 结构、基本信息回填、未注册皮肤告警保留原值;
|
||||
* ③ Tab 独立保存:保存基本信息 = 以服务端事实源 configJson 提交(其他 Tab 键不丢、不连带提交);
|
||||
* ④ 自定义样式独立 Tab(textarea 代码编辑器,非折叠面板)。
|
||||
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 siteManage.spec.ts 一致)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -12,8 +14,6 @@ import { createApp, defineComponent, h, nextTick } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
|
||||
|
||||
const routeMock = vi.hoisted(() => ({ params: {} as Record<string, string>, query: {} as Record<string, string> }));
|
||||
const routerMock = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn() }));
|
||||
|
||||
@@ -69,8 +69,9 @@ vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||
}));
|
||||
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["modelValue", "groups", "showExtras", "siteId"],
|
||||
methods: { validate: async () => true },
|
||||
props: ["modelValue", "groups", "siteId"],
|
||||
// 返工 #4 起 validate 契约:null=通过;失败返回首个错误文案
|
||||
methods: { validate: async () => null, ownFields: () => ({}) },
|
||||
render: () => h("div", "config-form-stub"),
|
||||
}),
|
||||
}));
|
||||
@@ -135,16 +136,15 @@ afterEach(() => {
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("SiteSetting 创建模式(site/new,向导退役收编)", () => {
|
||||
it("③~⑦分区锁定(创建站点后开放×5),主按钮=创建站点", async () => {
|
||||
describe("SiteSetting 创建模式(docs/20 F1 渐进式向导)", () => {
|
||||
it("第一步仅基本信息表单:无任何灰态锁定提示,主按钮=创建站点,下一步", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const tips = (el.textContent.match(/创建站点后开放/g) || []).length;
|
||||
expect(tips).toBe(5);
|
||||
expect(el.textContent).not.toContain("创建站点后开放");
|
||||
expect(el.textContent).toContain("新建站点");
|
||||
const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点"));
|
||||
expect(createBtn).toBeTruthy();
|
||||
expect(el.textContent).toContain("创建站点,下一步");
|
||||
expect(el.querySelector('input[placeholder="URL 路径用,如 demo"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("无上级时门户地址预览随编码联动(/portal/{code}/)", async () => {
|
||||
@@ -179,7 +179,7 @@ describe("SiteSetting 创建模式(site/new,向导退役收编)", () => {
|
||||
expect(selectEl).toBeTruthy();
|
||||
});
|
||||
|
||||
it("创建成功:replace 到站点设置页并解锁分区", async () => {
|
||||
it("创建成功:自动进入第二步(皮肤 Tab + Steps + 保存并完成),全 Tab 开放", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
@@ -189,26 +189,34 @@ describe("SiteSetting 创建模式(site/new,向导退役收编)", () => {
|
||||
setInputValue(nameInput, "机械学院门户");
|
||||
await flush();
|
||||
|
||||
const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点"))!;
|
||||
const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点,下一步"))!;
|
||||
createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
await flush(10);
|
||||
|
||||
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
|
||||
const payload = mocks.siteSave.mock.calls[0][0];
|
||||
expect(payload.code).toBe("jdxy");
|
||||
expect(payload.name).toBe("机械学院门户");
|
||||
expect(routerMock.replace).toHaveBeenCalledWith("/sch/jdxy/site/setting/99");
|
||||
// 向导第二步:Steps 可见 + 皮肤 Tab 激活 + 完成按钮;无灰态
|
||||
expect(el.textContent).toContain("皮肤与外观");
|
||||
expect(el.textContent).toContain("保存并完成");
|
||||
expect(el.textContent).toContain("跳过,稍后配置");
|
||||
expect(el.textContent).not.toContain("创建站点后开放");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SiteSetting 编辑模式(site/setting/:id)", () => {
|
||||
it("基本信息回填 + 已注册皮肤无告警", async () => {
|
||||
describe("SiteSetting 编辑模式(site/setting/:id,docs/20 F1 7 Tab)", () => {
|
||||
it("7 个 Tab 标题齐备(基本信息…自定义样式),基本信息回填,无灰态提示", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
["基本信息", "皮肤与外观", "首页布局", "基础展示", "页脚信息", "SEO 与域名", "自定义样式"].forEach(t =>
|
||||
expect(el.textContent).toContain(t)
|
||||
);
|
||||
expect(el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!.value).toBe("demo");
|
||||
expect((el.textContent || "")).not.toContain("未在注册表中");
|
||||
expect(el.textContent || "").not.toContain("未在注册表中");
|
||||
expect(el.textContent).toContain("云海大学门户网站");
|
||||
expect(el.textContent).not.toContain("创建站点后开放");
|
||||
});
|
||||
@@ -223,7 +231,7 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => {
|
||||
expect(el.textContent).toContain("skin-x");
|
||||
});
|
||||
|
||||
it("仅保存基本信息:以当前 configJson 事实源全量提交(home_layout 等其他分区键随行)", async () => {
|
||||
it("保存基本信息:以服务端事实源 configJson 提交(home_layout 等其他 Tab 键随行不丢)", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
@@ -232,7 +240,7 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => {
|
||||
setInputValue(nameInput, "改名后的门户");
|
||||
await flush();
|
||||
|
||||
const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("仅保存基本信息"))!;
|
||||
const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存基本信息"))!;
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
@@ -244,6 +252,79 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => {
|
||||
const config = JSON.parse(payload.configJson);
|
||||
expect(config.logo).toBe("/cms/upload/logo.png");
|
||||
expect(config.gallery_title).toBe("线上展厅"); // 被吸收旧键随事实源保留
|
||||
expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局分区未动
|
||||
expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局 Tab 未动
|
||||
});
|
||||
|
||||
it("T-T-05: 皮肤 Tab 只留选皮肤(无参数微调),保存不写 theme_params", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
// ThemeParamForm 已被桩化为 theme-param-stub:站点设置页不再出现该桩
|
||||
expect(el.textContent).not.toContain("theme-param-stub");
|
||||
expect(el.textContent).not.toContain("参数微调");
|
||||
expect(el.textContent).toContain("theme-picker-stub");
|
||||
|
||||
const saveBtn = [...el.querySelectorAll("button")].find(b =>
|
||||
b.textContent?.includes("保存皮肤与外观")
|
||||
)!;
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
|
||||
const config = JSON.parse(mocks.siteSave.mock.calls[0][0].configJson);
|
||||
expect(config).not.toHaveProperty("theme_params"); // 参数值不再由站点提交
|
||||
expect(config.logo).toBe("/cms/upload/logo.png"); // 其余键仍随事实源保留
|
||||
});
|
||||
|
||||
it("F1:自定义样式为独立 Tab 的代码编辑器(textarea + 字符计数),非折叠面板", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const cssTextarea = el.querySelector("textarea");
|
||||
expect(cssTextarea).toBeTruthy();
|
||||
expect(el.textContent).toContain("自定义样式");
|
||||
expect(el.textContent).toContain("/10240 字符");
|
||||
// 独立保存按钮
|
||||
const cssSaveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存自定义样式"))!;
|
||||
expect(cssSaveBtn).toBeTruthy();
|
||||
|
||||
cssSaveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
|
||||
const config = JSON.parse(mocks.siteSave.mock.calls[0][0].configJson);
|
||||
expect(config).not.toHaveProperty("custom_css"); // 空样式 = 删除键
|
||||
expect(config.home_layout).toBeTruthy(); // 其他 Tab 键不受影响
|
||||
});
|
||||
});
|
||||
|
||||
describe("SiteSetting 假脏防护(docs/20 返工 #2)", () => {
|
||||
it("冷加载不标脏:7 个 Tab 标题均无 ●(种值触发的 watcher 落在 seeding 窗口内)", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const labels = Array.from(el.querySelectorAll(".ivu-tabs-tab")).map(t => t.textContent!.trim());
|
||||
expect(labels.length).toBe(7);
|
||||
labels.forEach(t => expect(t.includes("●")).toBe(false));
|
||||
});
|
||||
|
||||
it("真实编辑才标脏:改站点名称 → 基本信息 ●;保存成功后 ● 消失", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
const labels = () => Array.from(el.querySelectorAll(".ivu-tabs-tab")).map(t => t.textContent!.trim());
|
||||
|
||||
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="如 XX学校门户网站"]')!;
|
||||
setInputValue(nameInput, "改名后的门户");
|
||||
await flush();
|
||||
expect(labels().find(t => t.includes("基本信息"))!.includes("●")).toBe(true);
|
||||
|
||||
const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存基本信息"))!;
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
|
||||
expect(labels().find(t => t.includes("基本信息"))!.includes("●")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* F5(docs/20)页脚/基础展示视觉位置示意:
|
||||
* ① 页脚分组底部渲染 FooterPreview 结构示意图(六处位置标注,已填内容回显);
|
||||
* ② asset 字段的 aspect 占位框(favicon 1:1 / 二维码 4:3,无值虚线框、有值比例预览)。
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
assetUrl: (u: string) => u,
|
||||
uploadUrl: () => "/api/attachment/upload",
|
||||
}));
|
||||
|
||||
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
|
||||
|
||||
const cleanups: (() => void)[] = [];
|
||||
async function settle(frames = 4) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
const FOOTER_CONFIG = JSON.stringify({
|
||||
contact: "电话 0571-88888888",
|
||||
footer_addresses: "潭山校区:南湖路 237 号\n淮河校区:大学路 1 号",
|
||||
qr_wechat: "/cms/upload/qr-wechat.png",
|
||||
social_weibo: "https://weibo.com/xxx",
|
||||
copyright: "XX学校 · 版权所有 © 2026",
|
||||
beian: "浙ICP备XXXXXXXX号-1",
|
||||
logo: "/cms/upload/logo.png",
|
||||
});
|
||||
|
||||
async function mountForm(configJson: string, groups?: string[]) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const model = ref(configJson);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
render: () =>
|
||||
h(SiteConfigForm, {
|
||||
modelValue: model.value,
|
||||
groups,
|
||||
"onUpdate:modelValue": (v: string) => (model.value = v),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
});
|
||||
await settle();
|
||||
return el;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach(fn => fn());
|
||||
});
|
||||
|
||||
describe("F5 视觉位置示意(docs/20)", () => {
|
||||
it("页脚分组:底部渲染结构示意图,六处位置标注 + 已填内容回显", async () => {
|
||||
const el = await mountForm(FOOTER_CONFIG, ["footer"]);
|
||||
const preview = el.querySelector(".footer-preview")!;
|
||||
expect(preview).toBeTruthy();
|
||||
const text = preview.textContent || "";
|
||||
// 已填字段实时回显内容(而非占位标注)
|
||||
expect(text).toContain("电话 0571-88888888");
|
||||
expect(text).toContain("潭山校区:南湖路 237 号");
|
||||
expect(text).toContain("XX学校 · 版权所有 © 2026");
|
||||
expect(text).toContain("浙ICP备XXXXXXXX号-1");
|
||||
expect(preview.querySelector('img[src="/cms/upload/qr-wechat.png"]')).toBeTruthy();
|
||||
// 未填字段显示位置标注(微博二维码空 → 占位)
|
||||
expect(text).toContain("微博二维码");
|
||||
expect(text).toContain("社媒图标行");
|
||||
});
|
||||
|
||||
it("页脚全空配置:六处位置标注完整(① 联系方式/② 地址/③ 二维码/④ 社媒/⑤ 版权/⑥ 备案)", async () => {
|
||||
const el = await mountForm("{}", ["footer"]);
|
||||
const text = el.querySelector(".footer-preview")!.textContent || "";
|
||||
expect(text).toContain("① 联系方式");
|
||||
expect(text).toContain("② 多校区地址");
|
||||
expect(text).toContain("微信二维码");
|
||||
expect(text).toContain("③ 4:3");
|
||||
expect(text).toContain("④ 社媒图标行");
|
||||
expect(text).toContain("⑤ 版权文字");
|
||||
expect(text).toContain("⑥ 备案号");
|
||||
});
|
||||
|
||||
it("基础展示分组:无示意图(仅页脚有)", async () => {
|
||||
const el = await mountForm(FOOTER_CONFIG, ["display"]);
|
||||
expect(el.querySelector(".footer-preview")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("aspect 占位框:二维码无值时 4:3 虚线占位、favicon 无值时 1:1 占位", async () => {
|
||||
const el = await mountForm("{}", ["display", "footer"]);
|
||||
const holders = el.querySelectorAll(".iaf-holder");
|
||||
expect(holders.length).toBe(3); // favicon 1:1 + 微信/微博 4:3
|
||||
expect(el.querySelector(".iaf-holder-1x1")).toBeTruthy();
|
||||
expect(el.querySelectorAll(".iaf-holder-4x3").length).toBe(2);
|
||||
expect(holders[0].textContent).toContain("1:1");
|
||||
});
|
||||
});
|
||||
@@ -8,21 +8,34 @@ import { resolveEditableTheme } from "@/utils/themeEdit";
|
||||
*/
|
||||
describe("resolveEditableTheme 站点编辑主题归一(任务三)", () => {
|
||||
it("注册中的主题原值保留且不告警", () => {
|
||||
expect(resolveEditableTheme("mint-breeze", ["default", "mint-breeze"])).toEqual({
|
||||
theme: "mint-breeze",
|
||||
expect(resolveEditableTheme("scholar-blue", ["default", "scholar-blue"])).toEqual({
|
||||
theme: "scholar-blue",
|
||||
unregistered: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("docs/20 E:下架主题解析为保留主题 + colorScheme 预设(不误报未注册)", () => {
|
||||
expect(resolveEditableTheme("classic-red", ["default", "scholar-blue", "campus-glow"])).toEqual({
|
||||
theme: "default",
|
||||
unregistered: false,
|
||||
aliasColorScheme: "classic-red",
|
||||
});
|
||||
expect(resolveEditableTheme("poly-blue", ["default", "scholar-blue", "campus-glow"])).toEqual({
|
||||
theme: "scholar-blue",
|
||||
unregistered: false,
|
||||
aliasColorScheme: "poly",
|
||||
});
|
||||
});
|
||||
|
||||
it("未注册主题保留原值并标记 unregistered(不再归一 default)", () => {
|
||||
expect(resolveEditableTheme("ghost-theme", ["default", "mint-breeze"])).toEqual({
|
||||
expect(resolveEditableTheme("ghost-theme", ["default", "scholar-blue"])).toEqual({
|
||||
theme: "ghost-theme",
|
||||
unregistered: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("空主题归一 default(站点未选过皮肤的场景)", () => {
|
||||
expect(resolveEditableTheme(undefined, ["default", "mint-breeze"])).toEqual({
|
||||
expect(resolveEditableTheme(undefined, ["default", "scholar-blue"])).toEqual({
|
||||
theme: "default",
|
||||
unregistered: false,
|
||||
});
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* ① 内置卡按钮为"预览+编辑"(任务四:内置包可改显示名/描述);
|
||||
* ② 被用外部包删除禁用且 tooltip 含站点名;
|
||||
* ③ 上传改为表单弹窗(zip + 命名),提交走手动上传通道;
|
||||
* ④ 内置编辑弹窗无参数区、保存不带 params(后端强校验拒改);
|
||||
* ④ 内置编辑弹窗不开放参数 schema 区、保存不带 params(后端强校验拒改);
|
||||
* ⑤ 添加皮肤包表单标识名为空时拦截。
|
||||
* ⑥ 工作项 T(T-T-06):编辑弹窗内嵌参数微调(内置包亦开放),回显/提交包级 paramValues,
|
||||
* 未触碰不带 paramValues(null=不改),卡片显示「已微调 M 项」。
|
||||
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 categoryWorkbench.spec.ts 一致)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -28,6 +30,8 @@ const themes = [
|
||||
description: "",
|
||||
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
|
||||
// 工作项 T:包级参数值(仅含被微调过的 key)
|
||||
paramValues: { primary: "#0a5c36" },
|
||||
source: "builtin",
|
||||
inUseCount: 2,
|
||||
editable: true,
|
||||
@@ -86,7 +90,41 @@ vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["sitePath", "theme", "themeParams", "fill"],
|
||||
setup: (props: any) => () =>
|
||||
h("div", { "data-preview-stub": "true", "data-fill": String(props.fill !== undefined && props.fill !== false) }, "preview-stub"),
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
"data-preview-stub": "true",
|
||||
"data-fill": String(props.fill !== undefined && props.fill !== false),
|
||||
"data-params": JSON.stringify(props.themeParams || {}),
|
||||
},
|
||||
"preview-stub"
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* T-T-06:参数微调迁皮肤包级后,编辑弹窗内嵌 ThemeParamForm。
|
||||
* 桩件把当前表单模型回显在 data-model 上,并给一个「改主色」按钮模拟用户改动。
|
||||
*/
|
||||
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["theme", "modelValue"],
|
||||
emits: ["update:modelValue"],
|
||||
setup: (props: any, { emit }: any) => () =>
|
||||
h(
|
||||
"div",
|
||||
{ "data-param-stub": "true", "data-model": JSON.stringify(props.modelValue || {}) },
|
||||
[
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
class: "stub-tweak",
|
||||
onClick: () => emit("update:modelValue", { ...(props.modelValue || {}), primary: "#111111" }),
|
||||
},
|
||||
"改主色"
|
||||
),
|
||||
]
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -207,7 +245,7 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
|
||||
expect(listMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("任务四: 内置包编辑弹窗无参数区,保存调用 updateMeta 不带 params", async () => {
|
||||
it("任务四: 内置包编辑弹窗不开放参数 schema 编辑区,保存调用 updateMeta 不带 params", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
@@ -215,7 +253,7 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
|
||||
clickButtonByText(builtin, "编辑");
|
||||
await flush();
|
||||
|
||||
// 内置提示条存在、无 ColorPicker(参数 schema 区不渲染)
|
||||
// 内置提示条存在、无 ColorPicker(参数 schema 编辑区不渲染;包级参数值是另一区,见 T-T-06)
|
||||
const metaModal = modalContaining("内置包内容(模板/资产)不可修改");
|
||||
expect(metaModal).toBeTruthy();
|
||||
expect(document.body.textContent).toContain("内置包内容(模板/资产)不可修改");
|
||||
@@ -229,6 +267,50 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("T-T-06: 编辑弹窗内嵌参数微调,回显包级 paramValues(缺项回落默认),保存提交 paramValues", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
clickButtonByText([...el.querySelectorAll(".card")][0], "编辑");
|
||||
await flush();
|
||||
const metaModal = modalContaining("内置包内容(模板/资产)不可修改");
|
||||
const stub = metaModal!.querySelector('[data-param-stub]');
|
||||
expect(stub).toBeTruthy();
|
||||
// paramValuesOf:已微调的 primary 取包值,未声明微调的走 theme.json 默认
|
||||
expect(JSON.parse(stub!.getAttribute("data-model")!)).toEqual({ primary: "#0a5c36" });
|
||||
|
||||
// 改主色 → 保存:只提交「与默认值不同」的 key(=「已微调」口径,与后端整体替换语义对齐)
|
||||
clickButtonByText(metaModal!, "改主色");
|
||||
await flush();
|
||||
clickButtonByText(metaModal!, "保存");
|
||||
await flush();
|
||||
expect(updateMetaMock).toHaveBeenCalledWith("default", expect.objectContaining({ paramValues: { primary: "#111111" } }));
|
||||
});
|
||||
|
||||
it("T-T-06: 未触碰参数区时保存不带 paramValues(后端 null=不改,防止改名顺带清空微调)", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
clickButtonByText([...el.querySelectorAll(".card")][2], "编辑"); // free-skin(外部包,无微调)
|
||||
await flush();
|
||||
const metaModal = modalContaining("编辑皮肤:free-skin");
|
||||
expect(metaModal).toBeTruthy();
|
||||
expect(JSON.parse(metaModal!.querySelector('[data-param-stub]')!.getAttribute("data-model")!)).toEqual({});
|
||||
clickButtonByText(metaModal!, "保存");
|
||||
await flush();
|
||||
const payload = updateMetaMock.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect("paramValues" in payload).toBe(false);
|
||||
});
|
||||
|
||||
it("T-T-06: 卡片显示「已微调 M 项」,无微调的包显示未微调", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
const cards = [...el.querySelectorAll(".card")];
|
||||
expect(cards[0].textContent).toContain("参数 1 个 · 已微调 1 项");
|
||||
expect(cards[1].textContent).toContain("未微调"); // my-skin 无 paramValues
|
||||
expect(cards[2].textContent).toContain("参数 0 个");
|
||||
});
|
||||
|
||||
it("任务四: 添加皮肤包表单——标识名为空被拦截,不调用 derive", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
@@ -261,4 +343,19 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
|
||||
expect(stub).toBeTruthy();
|
||||
expect(stub!.getAttribute("data-fill")).toBe("true");
|
||||
});
|
||||
|
||||
it("T-T-06 佐证: 预览转发包级 paramValues(后端 /api/theme/preview 只认 p_*,不转发则预览丢包色)", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
clickButtonByText([...el.querySelectorAll(".card")][0], "预览"); // default:paramValues={primary:#0a5c36}
|
||||
await flush();
|
||||
const stub = modalContaining("内容源站点")!.querySelector("[data-preview-stub]")!;
|
||||
expect(JSON.parse(stub.getAttribute("data-params")!)).toEqual({ primary: "#0a5c36" });
|
||||
|
||||
clickButtonByText([...el.querySelectorAll(".card")][2], "预览"); // free-skin:未微调
|
||||
await flush();
|
||||
const free = modalContaining("内容源站点")!.querySelector("[data-preview-stub]")!;
|
||||
expect(JSON.parse(free.getAttribute("data-params")!)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
+29
-3
@@ -14,6 +14,7 @@ import {
|
||||
DeleteRequest,
|
||||
LinkVo,
|
||||
PageResult,
|
||||
PersonVo,
|
||||
SchoolCard,
|
||||
SchoolOption,
|
||||
SiteVo,
|
||||
@@ -34,6 +35,7 @@ const ARTICLE = "/api/article";
|
||||
const ATTACHMENT = "/api/attachment";
|
||||
const BANNER = "/api/banner";
|
||||
const LINK = "/api/link";
|
||||
const PERSON = "/api/person";
|
||||
const THEME = "/api/theme";
|
||||
|
||||
/** GET(query 参数) */
|
||||
@@ -106,7 +108,8 @@ class SiteApi {
|
||||
return get<SiteVo[]>(`${SITE}/list`);
|
||||
}
|
||||
|
||||
/** 入参对齐 SiteSaveRequest(themeParams 服务端合并进 config_json.theme_params,方案 §5.3) */
|
||||
/** 入参对齐 SiteSaveRequest。themeParams 已随工作项 T 退役(参数值改存皮肤包级 paramValues),
|
||||
* 服务端忽略该字段——保留仅为与 @Deprecated 的后端 DTO 同形,新代码不要再传 */
|
||||
save(data: Partial<SiteVo> & { themeParams?: Record<string, string> }) {
|
||||
return postJSON<SiteVo>(`${SITE}/save`, data);
|
||||
}
|
||||
@@ -207,6 +210,21 @@ class LinkApi {
|
||||
}
|
||||
}
|
||||
|
||||
/** 名师风采人物(docs/20 D-7) */
|
||||
class PersonApi {
|
||||
list(siteId: number) {
|
||||
return get<PersonVo[]>(`${PERSON}/list`, { siteId });
|
||||
}
|
||||
|
||||
save(data: Partial<PersonVo>) {
|
||||
return postJSON<PersonVo>(`${PERSON}/save`, data);
|
||||
}
|
||||
|
||||
delete(data: DeleteRequest) {
|
||||
return postJSON<void>(`${PERSON}/delete`, data);
|
||||
}
|
||||
}
|
||||
|
||||
class ThemeApi {
|
||||
/** 可用主题列表(含 params schema,建站向导/站点编辑共用,方案 §5.2) */
|
||||
list() {
|
||||
@@ -230,8 +248,14 @@ class ThemeApi {
|
||||
}
|
||||
|
||||
/** W1 + 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema),
|
||||
* 内置包显示名/描述走 .meta 覆盖层(title/description 全空=恢复默认) */
|
||||
updateMeta(name: string, data: { title?: string; description?: string; params?: unknown[] }) {
|
||||
* 内置包显示名/描述走 .meta 覆盖层(title/description 全空=恢复默认)。
|
||||
* 工作项 T:paramValues=包级参数微调值,省略=不改,{}=清空恢复 theme.json 默认值 */
|
||||
updateMeta(name: string, data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
params?: unknown[];
|
||||
paramValues?: Record<string, string>;
|
||||
}) {
|
||||
return alovaInstance.Put<ThemeVo>(`${THEME}/${name}/meta`, data, {
|
||||
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||
});
|
||||
@@ -264,6 +288,7 @@ export const categoryApi = new CategoryApi();
|
||||
export const articleApi = new ArticleApi();
|
||||
export const attachmentApi = new AttachmentApi();
|
||||
export const bannerApi = new BannerApi();
|
||||
export const personApi = new PersonApi();
|
||||
export const linkApi = new LinkApi();
|
||||
export const themeApi = new ThemeApi();
|
||||
|
||||
@@ -276,6 +301,7 @@ export default {
|
||||
articleApi,
|
||||
attachmentApi,
|
||||
bannerApi,
|
||||
personApi,
|
||||
linkApi,
|
||||
themeApi,
|
||||
uploadUrl,
|
||||
|
||||
@@ -159,11 +159,12 @@ html.side-plain{--strip-bg:linear-gradient(180deg,#f2f4f8 0,#fff 100%)}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb a:hover{color:var(--brand)}
|
||||
.layout-wrap .path-nav .crumb-home{color:var(--brand)}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb-item-separator{color:#ccd4e0}
|
||||
/* 定稿:不再画独立 3px 横杠——顶部 3px 由接缝条承接顶栏渐变;子站让位身份橙 */
|
||||
/* 定稿:不再画独立 3px 横杠——顶部 3px 由接缝条承接顶栏渐变。
|
||||
v3:站点身份已由页头「学校名.站点名」承担,原先 band-sub 让位身份橙的覆盖已删,
|
||||
否则子站态上面包屑顶部常驻一条橙色横杠,与页头身份重复,且被误读成边框。 */
|
||||
.layout-wrap .path-nav::before{
|
||||
content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--topbar-bg);z-index:1;
|
||||
}
|
||||
.layout-wrap .path-nav.band-sub::before{background:#ff9900}
|
||||
.layout-wrap .path-nav::after{display:none}
|
||||
|
||||
/* ---- 内容区:灰底 + 白卡 ---- */
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 区块类型缩略示意图(docs/20 F2 / R-40):添加抽屉类型卡 + 属性面板形态 RadioCard 共用。
|
||||
* 纯内联 SVG 线框示意(本地资产,无外部 CDN——皮肤包契约红线仅约束门户,管理端同样不引);
|
||||
* 每种 type/variant 一幅极简骨架图,新组件类型 = 在 THUMBS 补一支绘制函数(注册表驱动配套)。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 区块类型(BLOCK_TYPES.type) */
|
||||
type: string;
|
||||
/** article_list 展示形态(style),其余类型忽略 */
|
||||
variant?: string;
|
||||
}>(),
|
||||
{ variant: "" },
|
||||
);
|
||||
|
||||
/** 图底色板(示意线框用,与皮肤无关) */
|
||||
const C = {
|
||||
band: "#dce6f5",
|
||||
block: "#e8eef8",
|
||||
strong: "#9db8e0",
|
||||
text: "#c3cfe2",
|
||||
accent: "#5b8dd9",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="block-type-thumb" viewBox="0 0 120 64" preserveAspectRatio="xMidYMid meet" role="img" aria-hidden="true">
|
||||
<!-- article_list 五形态 -->
|
||||
<template v-if="type === 'article_list'">
|
||||
<!-- card:头条图 + 摘要 + 行列表 -->
|
||||
<template v-if="variant === 'card' || !variant">
|
||||
<rect x="6" y="6" width="34" height="22" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="45" y="9" width="44" height="5" rx="1.5" :fill="C.strong" />
|
||||
<rect x="45" y="18" width="66" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="45" y="24" width="56" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="6" y="34" width="108" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="6" y="42" width="100" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="6" y="50" width="104" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="6" y="58" width="92" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
<!-- news_grid:色带底 + 双栏行 -->
|
||||
<template v-else-if="variant === 'news_grid'">
|
||||
<rect x="0" y="0" width="120" height="64" :fill="C.band" />
|
||||
<rect x="8" y="8" width="104" height="48" rx="2" fill="#ffffff" />
|
||||
<rect x="14" y="14" width="40" height="4" rx="2" :fill="C.accent" />
|
||||
<rect x="14" y="26" width="42" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="14" y="34" width="38" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="14" y="42" width="40" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="64" y="26" width="42" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="64" y="34" width="38" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="64" y="42" width="40" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
<!-- media_focus:来源徽标 + 行 -->
|
||||
<template v-else-if="variant === 'media_focus'">
|
||||
<rect x="6" y="8" width="24" height="8" rx="2" :fill="C.accent" />
|
||||
<rect x="36" y="11" width="60" height="3" rx="1.5" :fill="C.strong" />
|
||||
<rect x="6" y="24" width="20" height="8" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="32" y="27" width="70" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="6" y="40" width="18" height="8" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="30" y="43" width="64" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="6" y="54" width="34" height="4" rx="1.5" :fill="C.band" />
|
||||
</template>
|
||||
<!-- compact:纯文字行 -->
|
||||
<template v-else-if="variant === 'compact'">
|
||||
<rect x="8" y="10" width="8" height="3" rx="1.5" :fill="C.accent" />
|
||||
<rect x="20" y="10" width="84" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="8" y="21" width="8" height="3" rx="1.5" :fill="C.accent" />
|
||||
<rect x="20" y="21" width="76" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="8" y="32" width="8" height="3" rx="1.5" :fill="C.accent" />
|
||||
<rect x="20" y="32" width="88" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="8" y="43" width="8" height="3" rx="1.5" :fill="C.accent" />
|
||||
<rect x="20" y="43" width="72" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="8" y="54" width="8" height="3" rx="1.5" :fill="C.accent" />
|
||||
<rect x="20" y="54" width="80" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
<!-- image_wall:图片格阵 -->
|
||||
<template v-else-if="variant === 'image_wall'">
|
||||
<rect x="6" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="35" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="64" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="93" y="8" width="21" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="6" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="35" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="64" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="93" y="34" width="21" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ticker:横条 + 滚动标题 -->
|
||||
<template v-else-if="type === 'ticker'">
|
||||
<rect x="0" y="20" width="120" height="24" :fill="C.accent" />
|
||||
<rect x="6" y="28" width="26" height="8" rx="2" fill="#ffffff" opacity="0.9" />
|
||||
<rect x="38" y="30" width="34" height="4" rx="2" fill="#ffffff" opacity="0.75" />
|
||||
<rect x="78" y="30" width="30" height="4" rx="2" fill="#ffffff" opacity="0.6" />
|
||||
</template>
|
||||
|
||||
<!-- gallery:居中标题 + 图墙 -->
|
||||
<template v-else-if="type === 'gallery'">
|
||||
<rect x="46" y="4" width="28" height="5" rx="2" :fill="C.strong" />
|
||||
<rect x="38" y="11" width="44" height="2.5" rx="1.25" :fill="C.text" />
|
||||
<rect x="8" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="37" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="66" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="95" y="20" width="19" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="8" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="37" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="66" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="95" y="44" width="19" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
</template>
|
||||
|
||||
<!-- links:胶囊按钮行 -->
|
||||
<template v-else-if="type === 'links'">
|
||||
<rect x="46" y="6" width="28" height="5" rx="2" :fill="C.strong" />
|
||||
<rect x="10" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="38" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="66" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="94" y="22" width="18" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="24" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="52" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="80" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
</template>
|
||||
|
||||
<!-- quick_links:图标宫格 -->
|
||||
<template v-else-if="type === 'quick_links'">
|
||||
<circle cx="26" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="18" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="60" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="52" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="94" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="86" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="26" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="18" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="60" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="52" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="94" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="86" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
|
||||
<!-- notice_bar:通栏提示条 -->
|
||||
<template v-else-if="type === 'notice_bar'">
|
||||
<rect x="0" y="22" width="120" height="20" :fill="C.band" />
|
||||
<rect x="0" y="22" width="120" height="1" :fill="C.strong" />
|
||||
<rect x="0" y="41" width="120" height="1" :fill="C.strong" />
|
||||
<rect x="8" y="29" width="20" height="6" rx="2" :fill="C.accent" />
|
||||
<rect x="34" y="31" width="70" height="3" rx="1.5" :fill="C.strong" />
|
||||
</template>
|
||||
|
||||
<!-- D 系新组件(docs/20 D-1~D-9):D 步上线时同步补绘 -->
|
||||
<!-- D-1 stats_band:大数字横排 -->
|
||||
<template v-else-if="type === 'stats_band'">
|
||||
<rect x="0" y="10" width="120" height="44" :fill="C.band" />
|
||||
<text x="24" y="34" font-size="13" font-weight="700" :fill="C.accent">3.2万</text>
|
||||
<rect x="20" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
|
||||
<text x="60" y="34" font-size="13" font-weight="700" :fill="C.accent">2800</text>
|
||||
<rect x="56" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
|
||||
<text x="94" y="34" font-size="13" font-weight="700" :fill="C.accent">12</text>
|
||||
<rect x="90" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
<!-- D-2 icon_nav:六边形图标阵 -->
|
||||
<template v-else-if="type === 'icon_nav'">
|
||||
<polygon points="26,6 38,13 38,27 26,34 14,27 14,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="16" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
<polygon points="60,6 72,13 72,27 60,34 48,27 48,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="50" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
<polygon points="94,6 106,13 106,27 94,34 82,27 82,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="84" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
<polygon points="26,46 38,53 38,67 26,74 14,67 14,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="16" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
<polygon points="60,46 72,53 72,67 60,74 48,67 48,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="50" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
<polygon points="94,46 106,53 106,67 94,74 82,67 82,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="84" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
<!-- D-3 featured_topics:专题大卡 -->
|
||||
<template v-else-if="type === 'featured_topics'">
|
||||
<rect x="6" y="8" width="52" height="48" rx="3" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="12" y="40" width="30" height="4" rx="2" fill="#ffffff" />
|
||||
<rect x="12" y="48" width="18" height="4" rx="2" :fill="C.accent" />
|
||||
<rect x="62" y="8" width="52" height="48" rx="3" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="68" y="40" width="30" height="4" rx="2" fill="#ffffff" />
|
||||
<rect x="68" y="48" width="18" height="4" rx="2" :fill="C.accent" />
|
||||
</template>
|
||||
<!-- D-5 video_tour:视频封面 + 播放键 -->
|
||||
<template v-else-if="type === 'video_tour'">
|
||||
<rect x="6" y="8" width="108" height="48" rx="3" :fill="C.band" />
|
||||
<circle cx="60" cy="32" r="12" fill="#ffffff" opacity="0.9" />
|
||||
<polygon points="56,25 70,32 56,39" :fill="C.accent" />
|
||||
</template>
|
||||
<!-- D-6 events:大日期块 + 活动列表 -->
|
||||
<template v-else-if="type === 'events'">
|
||||
<rect x="6" y="8" width="26" height="26" rx="2" :fill="C.accent" />
|
||||
<rect x="10" y="13" width="18" height="5" rx="1.5" fill="#ffffff" opacity="0.9" />
|
||||
<rect x="38" y="12" width="50" height="4" rx="2" :fill="C.strong" />
|
||||
<rect x="38" y="21" width="66" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="6" y="40" width="26" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="38" y="44" width="50" height="4" rx="2" :fill="C.strong" />
|
||||
<rect x="38" y="53" width="66" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
<!-- D-7 people:圆头像卡 -->
|
||||
<template v-else-if="type === 'people'">
|
||||
<circle cx="24" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="12" y="43" width="24" height="4" rx="2" :fill="C.strong" />
|
||||
<rect x="14" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="60" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="48" y="43" width="24" height="4" rx="2" :fill="C.strong" />
|
||||
<rect x="50" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="96" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="84" y="43" width="24" height="4" rx="2" :fill="C.strong" />
|
||||
<rect x="86" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
<!-- D-8 campus_wall:瀑布流(错落图) -->
|
||||
<template v-else-if="type === 'campus_wall'">
|
||||
<rect x="8" y="6" width="24" height="28" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="37" y="6" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="66" y="6" width="24" height="32" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="95" y="6" width="19" height="22" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="8" y="40" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="37" y="30" width="24" height="28" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="66" y="44" width="24" height="14" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
<rect x="95" y="34" width="19" height="24" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
|
||||
</template>
|
||||
<!-- D-9 badges:徽章横排 -->
|
||||
<template v-else-if="type === 'badges'">
|
||||
<circle cx="26" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="18" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="60" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="52" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
<circle cx="94" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
|
||||
<rect x="86" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
|
||||
<!-- 兜底:区块头 + 行 -->
|
||||
<template v-else>
|
||||
<rect x="8" y="8" width="32" height="5" rx="2" :fill="C.strong" />
|
||||
<rect x="8" y="22" width="104" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="8" y="32" width="96" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="8" y="42" width="100" height="3" rx="1.5" :fill="C.text" />
|
||||
<rect x="8" y="52" width="88" height="3" rx="1.5" :fill="C.text" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.block-type-thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fbfcfe;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
import { assetUrl } from "@/api/module/cms";
|
||||
/**
|
||||
* F5(docs/20)页脚结构示意图:页脚 Tab 内的固定示意图,标注各字段在门户页脚中的视觉位置——
|
||||
* 品牌区(站名+联系方式+多校区地址行)/ 二维码区(微信+微博,4:3)/ 社媒图标行 / 版权备案行。
|
||||
* 已填字段实时回显内容(填完即知页脚长什么样);空字段以灰字占位标注。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
contact?: string;
|
||||
footerAddresses?: string;
|
||||
qrWechat?: string;
|
||||
qrWeibo?: string;
|
||||
socials?: { label: string; value?: string }[];
|
||||
copyright?: string;
|
||||
beian?: string;
|
||||
}>();
|
||||
|
||||
const addressLines = (props.footerAddresses || "")
|
||||
.split(/\r?\n/)
|
||||
.map(l => l.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="footer-preview">
|
||||
<div class="fp-caption">页脚结构示意(下图与字段一一对应,已填内容实时回显)</div>
|
||||
<div class="fp-frame" role="img" aria-label="页脚结构示意图">
|
||||
<div class="fp-brand">
|
||||
<div class="fp-logo">
|
||||
<span class="fp-logo-mark">LOGO</span>
|
||||
<span class="fp-site">XX学校</span>
|
||||
</div>
|
||||
<p class="fp-line" :class="{ filled: contact }">
|
||||
{{ contact || "① 联系方式(页脚品牌区)" }}
|
||||
</p>
|
||||
<p v-for="(l, i) in addressLines" :key="i" class="fp-line small filled">{{ l }}</p>
|
||||
<p v-if="!addressLines.length" class="fp-line small">② 多校区地址(每行一个校区)</p>
|
||||
</div>
|
||||
<div class="fp-qr">
|
||||
<div class="fp-qr-box">
|
||||
<img v-if="qrWechat" :src="assetUrl(qrWechat)" alt="微信二维码预览" />
|
||||
<span v-else>微信二维码<br />③ 4:3</span>
|
||||
</div>
|
||||
<div class="fp-qr-box">
|
||||
<img v-if="qrWeibo" :src="assetUrl(qrWeibo)" alt="微博二维码预览" />
|
||||
<span v-else>微博二维码<br />③ 4:3</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fp-social">
|
||||
<span class="fp-social-label">④ 社媒图标行:</span>
|
||||
<span v-for="s in socials" :key="s.label" class="fp-social-item" :class="{ on: s.value }">
|
||||
{{ s.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="fp-bottom">
|
||||
<span :class="{ filled: copyright }">{{ copyright || "⑤ 版权文字" }}</span>
|
||||
<span :class="{ filled: beian }" class="fp-beian">{{ beian || "⑥ 备案号" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.footer-preview {
|
||||
margin-top: 6px;
|
||||
.fp-caption {
|
||||
color: #8a93a6;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.fp-frame {
|
||||
border: 1px solid #e9edf5;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #1f3a5f 0%, #16294a 100%);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
padding: 18px 22px 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px 18px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
.fp-brand {
|
||||
.fp-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
.fp-logo-mark {
|
||||
border: 1px dashed rgba(255, 255, 255, 0.35);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.fp-site {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.fp-line {
|
||||
margin: 2px 0;
|
||||
&.small {
|
||||
font-size: 11px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
&.filled {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
.fp-qr {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
.fp-qr-box {
|
||||
width: 84px;
|
||||
height: 63px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.4);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
.fp-social {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
.fp-social-label {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.fp-social-item {
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 1px 10px;
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
&.on {
|
||||
opacity: 1;
|
||||
border-color: rgba(255, 255, 255, 0.85);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
.fp-bottom {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 11px;
|
||||
.filled {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* D-2(docs/20)内置图标选择器:图标网格预览 + 点选即绑定图标标识符(§3.3 定案 2 综合方案 A+C)。
|
||||
* 数据源 icons.ts(与门户骨架 fragment 同 key);再次点击已选图标 = 清除(回 null = 首字徽章兜底)。
|
||||
*/
|
||||
import { ICON_DEFS } from "@/views/cms/homeLayout/icons";
|
||||
|
||||
const model = defineModel<string | null>("modelValue", { default: null });
|
||||
|
||||
function pick(key: string) {
|
||||
model.value = model.value === key ? null : key;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="icon-grid">
|
||||
<button
|
||||
v-for="icon in ICON_DEFS"
|
||||
:key="icon.key"
|
||||
type="button"
|
||||
class="icon-cell"
|
||||
:class="{ active: model === icon.key }"
|
||||
:title="`${icon.label}(${icon.key})`"
|
||||
@click="pick(icon.key)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path v-for="(d, i) in icon.paths" :key="i" :d="d" />
|
||||
</svg>
|
||||
<span class="icon-name">{{ icon.label }}</span>
|
||||
</button>
|
||||
<p class="icon-tip">未选图标时入口渲染「名称首字徽章」;再次点击已选图标可清除</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
.icon-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
border: 1px solid #e2e8f2;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 7px 2px 5px;
|
||||
cursor: pointer;
|
||||
color: #515a6e;
|
||||
transition: border-color 0.2s, color 0.2s, box-shadow 0.2s;
|
||||
&:hover {
|
||||
border-color: #5cadff;
|
||||
color: #2d8cf0;
|
||||
}
|
||||
&.active {
|
||||
border-color: #2d8cf0;
|
||||
color: #2d8cf0;
|
||||
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.12);
|
||||
}
|
||||
svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.icon-name {
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
.icon-tip {
|
||||
grid-column: 1 / -1;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,9 @@ const props = defineProps<{
|
||||
/** 附件场景(空=不带 scene;svc 场景已迁办事大厅,CMS 内为 article/banner 等) */
|
||||
scene?: string;
|
||||
placeholder?: string;
|
||||
/** F5(docs/20):预览/占位框比例("1:1" favicon / "4:3" 二维码;缺省 8:5 通用横图),
|
||||
* 无值时按比例显示虚线占位框,帮助确认最终在门户中的尺寸感 */
|
||||
aspect?: "1:1" | "4:3";
|
||||
}>();
|
||||
const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>();
|
||||
|
||||
@@ -76,12 +79,16 @@ function clearValue() {
|
||||
>
|
||||
<Button size="small" icon="ios-cloud-upload-outline" :loading="uploading">上传图片</Button>
|
||||
</Upload>
|
||||
<span v-else class="iaf-thumb">
|
||||
<span v-else class="iaf-thumb" :class="aspect ? 'iaf-thumb-' + aspect.replace(':', 'x') : ''">
|
||||
<img :src="previewSrc" alt="" />
|
||||
<span class="iaf-thumb-del" title="清除" @click="clearValue">
|
||||
<Icon type="ios-close" />
|
||||
</span>
|
||||
</span>
|
||||
<!-- F5:无值时按字段比例显示虚线占位框(尺寸感示意) -->
|
||||
<span v-if="!modelValue && aspect" class="iaf-holder" :class="'iaf-holder-' + aspect!.replace(':', 'x')">
|
||||
{{ aspect }} 位
|
||||
</span>
|
||||
<Button size="small" type="text" class="iaf-toggle" @click="manualOpen = !manualOpen">
|
||||
{{ manualOpen ? "收起" : "手动粘贴路径" }}
|
||||
</Button>
|
||||
@@ -123,6 +130,16 @@ function clearValue() {
|
||||
border: 1px solid #dcdee2;
|
||||
background: #f8f8f9;
|
||||
}
|
||||
&.iaf-thumb-1x1 img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
&.iaf-thumb-4x3 img {
|
||||
width: 88px;
|
||||
height: 66px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.iaf-thumb-del {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
@@ -142,6 +159,24 @@ function clearValue() {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.iaf-holder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed #cfd8e6;
|
||||
border-radius: 4px;
|
||||
color: #b4bfce;
|
||||
font-size: 11px;
|
||||
background: #fafbfd;
|
||||
&.iaf-holder-1x1 {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
&.iaf-holder-4x3 {
|
||||
width: 88px;
|
||||
height: 66px;
|
||||
}
|
||||
}
|
||||
.iaf-manual {
|
||||
max-width: 420px;
|
||||
margin-top: 6px;
|
||||
|
||||
@@ -42,9 +42,34 @@ const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%"
|
||||
const school = computed(() => store.currentSchool);
|
||||
const isSuper = computed(() => store.isSuperAdmin);
|
||||
|
||||
/** 当前落点是否院系子站(store 契约):决定身份条徽章与底边色带 */
|
||||
/** 当前落点是否院系子站(store 契约):决定身份条徽章、侧栏/面包屑身份色带与页头站点名(v2 §C.3 #2/#3) */
|
||||
const isSubSite = computed(() => store.isSubSite);
|
||||
const pathNavClass = computed(() => (isSubSite.value ? "band-sub" : "band-main"));
|
||||
/** 身份档位类:同名类挂在 .left-menu 与 .path-nav 两处壳层节点,主站不染色(默认为主站、偏离才提示) */
|
||||
const identityClass = computed(() => (isSubSite.value ? "band-sub" : "band-main"));
|
||||
|
||||
/* 页头身份(docs/22 v3):学校名.站点名 —— 一处文字同时回答「哪个学校 + 哪个站点」。
|
||||
子站取站点名(云海大学.机电学院),主站固定写「主站」(云海大学.主站);
|
||||
沿用既有 .shell-site 胶囊语汇(band-sub 橙 / band-main 中性白),不引新色值。 */
|
||||
const siteName = computed(() => store.currentSite?.name || "");
|
||||
const schoolName = computed(() => school.value?.schName || school.value?.mainSiteName || "");
|
||||
/** 页头站点档位文案:主站不写站全名(会很长),统一「主站」 */
|
||||
const siteLabel = computed(() => (isSubSite.value ? siteName.value : "主站"));
|
||||
/** 完整身份串:学校名.站点名(学校未就绪时退化为站点名) */
|
||||
const identityText = computed(() => {
|
||||
const label = siteLabel.value;
|
||||
if (!label) return "";
|
||||
return schoolName.value ? `${schoolName.value}.${label}` : label;
|
||||
});
|
||||
const shellTitle = computed(() =>
|
||||
identityText.value ? `${appName.value} · ${identityText.value}` : appName.value
|
||||
);
|
||||
const docTitle = computed(() => {
|
||||
const t = String((route.meta as any)?.title || "");
|
||||
// 多标签页可分辨:文档标题同样带「学校名.站点名」,与页头口径一致
|
||||
if (!identityText.value) return t;
|
||||
return t ? `${t} · ${identityText.value}` : identityText.value;
|
||||
});
|
||||
watch(docTitle, v => { if (v) document.title = v; }, { immediate: true });
|
||||
|
||||
/** 菜单/面包屑 page(主站形态完整路径)→ 当前站点形态:子站插入 /site/{code} 段(docs/13 §4)。
|
||||
* 非 /sch/ 前缀(外链模块)原样返回,防拼坏 */
|
||||
@@ -312,10 +337,18 @@ watch(
|
||||
title="返回学校列表"
|
||||
@click="goWorkbench"
|
||||
>
|
||||
<Icon type="ios-undo" :size="18" />
|
||||
<Icon type="ios-arrow-back" :size="20" />
|
||||
</a>
|
||||
<div v-if="isSuper" class="delimiter fl align-middle"></div>
|
||||
<h4 class="fl align-middle">{{ appName }}</h4>
|
||||
<h4 class="fl align-middle" :title="shellTitle">
|
||||
{{ appName }}<template v-if="identityText"
|
||||
><span class="h-delimiter"></span
|
||||
><span class="school-cap" :class="identityClass"
|
||||
>{{ schoolName }}<span class="sc-sep">.</span
|
||||
><span class="sc-site">{{ siteLabel }}</span></span
|
||||
></template
|
||||
>
|
||||
</h4>
|
||||
<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">
|
||||
@@ -329,7 +362,8 @@ watch(
|
||||
<!-- 配色切换器(fr 顺序:DOM 后置即位于用户胶囊左侧) -->
|
||||
<div class="theme-cap fr"><ThemeSwitcher /></div>
|
||||
</header>
|
||||
<div class="left-menu" v-if="!isAlone">
|
||||
<div class="left-menu" :class="identityClass" v-if="!isAlone">
|
||||
<!-- 站点卡已于 docs/22 v3 撤除(与站点工作台重复);身份改由页头「学校名.站点名」承担 -->
|
||||
<div class="menu-top-box"></div>
|
||||
<ul>
|
||||
<template v-for="(item, index) in modList" :key="'menu_li_' + index">
|
||||
@@ -366,7 +400,7 @@ watch(
|
||||
<div class="clear-box"></div>
|
||||
</div>
|
||||
|
||||
<div class="path-nav" :class="pathNavClass" :style="pathNavStyle">
|
||||
<div class="path-nav" :class="identityClass" :style="pathNavStyle">
|
||||
<Breadcrumb separator=">">
|
||||
<!-- 首页段(常驻第一段):旧 BschHome 已下线,统一跳用户中心 userhome -->
|
||||
<a href="javascript:;" @click="linkHomePage">
|
||||
@@ -429,13 +463,23 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 超管返回学校列表图标:header 常驻入口(校级账号不渲染) */
|
||||
/* 超管返回学校列表:header 常驻入口(校级账号不渲染)。
|
||||
与「首页」按钮同规格的可点方块(26×26 + hover 底)+ 左右 10px 呼吸 ——
|
||||
原先是裸 18×18 图标夹在两条分隔线之间(x=126/146),既无点击热区、又比首页按钮小一档;
|
||||
图标同时从 ios-undo(字形像「拐角」)换成 ios-arrow-back。 */
|
||||
.back-workbench {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin: 0 10px;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.16s;
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.no-site-tip {
|
||||
@@ -478,6 +522,39 @@ watch(
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 页头身份(docs/22 v3):云海CMS | 学校名.站点名
|
||||
h4 截断——学校名+站名可能很长,完整串在 :title 与 document.title。
|
||||
胶囊色沿用原 .shell-site 取值(主站 rgba(255,255,255,.18) / 子站橙 rgba(255,153,0,.32)),不引新色值。 */
|
||||
.header-container h4 {
|
||||
max-width: 46vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.h-delimiter {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
margin: 0 10px;
|
||||
vertical-align: -2px;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.school-cap {
|
||||
font-size: 13px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.sc-sep {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.sc-site {
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.school-cap.band-sub .sc-site {
|
||||
background: rgba(255, 153, 0, 0.32);
|
||||
}
|
||||
|
||||
/* 面包屑「首页」垂直居中:layout.css 遗留的 .path-nav .icon-category{position:relative;top:1px}
|
||||
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
|
||||
这里用 inline-flex 真居中并关掉该补偿(不动压缩产物 layout.css,覆盖收在本组件内)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message, Tag } from "view-ui-plus";
|
||||
import Utils from "@/utils/index";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
@@ -23,6 +23,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useCmsStore();
|
||||
|
||||
const rows = ref<ArticlePageItem[]>([]);
|
||||
@@ -32,7 +33,9 @@ const pageNum = ref(1);
|
||||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||
|
||||
const search = reactive({
|
||||
categoryId: undefined as number | undefined,
|
||||
// P-D1:栏目页概览「查看全部 →」以 /article?categoryId={id} 深链进入,读 query 预筛选
|
||||
// (query 是字符串,转数字才能对上 Option 的 value 并回显下拉选中项)
|
||||
categoryId: Number((route.query?.categoryId as any)) || undefined,
|
||||
status: undefined as number | undefined,
|
||||
keyword: "",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Tag } from "view-ui-plus";
|
||||
import { articleApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { ArticlePageItem } from "@/types/cms";
|
||||
import { adminPathOf } from "@/utils/sitePath";
|
||||
|
||||
/**
|
||||
* 栏目内容概览(P-D1,v2 §B.3):/category 右侧列表型栏目的降级面板。
|
||||
* 定位=只读概览;文章的发布/下架/置顶/删除只在 /article(唯一操作台)做。
|
||||
*
|
||||
* 统计口径(手册 P-D1 定稿,勿自行改):
|
||||
* - 范围 = 选中栏目的**直属 + 全部子孙栏目**。后端 ArticlePageQuery.categoryIds 是不递归的 IN 查询,
|
||||
* 故子孙 id 由前端从栏目树展开(与门户 PortalPageRenderer.collectListDescendantIds 同语义)。
|
||||
* - 状态 = **全状态计数**(草稿/已发布/已下架全部计入,不带 status 过滤;系统内无"回收站"概念)。
|
||||
* - 一次 page 请求同时拿到总数(total)与最近 5 条(list,后端按置顶降序 + 创建时间降序)。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
categoryId: number;
|
||||
categoryName?: string;
|
||||
/** 全量栏目树(CategoryManage 的 treeData,含 W2 虚拟根;按 id 定位,虚拟根 id=0 不会命中真实栏目) */
|
||||
tree?: any[];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const store = useCmsStore();
|
||||
|
||||
const total = ref(0);
|
||||
const rows = ref<ArticlePageItem[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
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";
|
||||
|
||||
function findNode(list: any[], id: number): any {
|
||||
for (const n of list || []) {
|
||||
if (Number(n.id) === Number(id)) return n;
|
||||
const hit = findNode(n.children || [], id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectIds(node: any, acc: number[]): number[] {
|
||||
acc.push(Number(node.id));
|
||||
(node.children || []).forEach((c: any) => collectIds(c, acc));
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** 统计范围 id:树未就绪(或未命中)时退化为仅直属栏目 */
|
||||
const scopeIds = computed<number[]>(() => {
|
||||
const node = findNode(props.tree || [], props.categoryId);
|
||||
return node ? collectIds(node, []) : [Number(props.categoryId)];
|
||||
});
|
||||
const scopeKey = computed(() => scopeIds.value.join(","));
|
||||
|
||||
async function run() {
|
||||
if (!store.siteId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const page = await articleApi
|
||||
.page({
|
||||
siteId: store.siteId,
|
||||
categoryIds: scopeKey.value,
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
})
|
||||
.send();
|
||||
total.value = page?.total || 0;
|
||||
rows.value = (page?.list || []).slice(0, 5);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳 /article 深链预筛选(子栏目行按自身栏目过滤,父栏目"查看全部"按父栏目过滤) */
|
||||
function goArticles(categoryId?: number) {
|
||||
const code = store.schoolCode;
|
||||
if (!code) return;
|
||||
const id = categoryId ?? props.categoryId;
|
||||
router.push(adminPathOf(code, store.siteSegCode, "article") + `?categoryId=${id}`);
|
||||
}
|
||||
|
||||
// 树稍后到达(子孙变化)与切换栏目都重取;站点切换由 CategoryManage 清选中后重建本组件
|
||||
watch(scopeKey, run);
|
||||
onMounted(run);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="category-content-summary">
|
||||
<div class="ccs-stat fix">
|
||||
<span class="ccs-total">共 <b>{{ total }}</b> 篇</span>
|
||||
<span class="ccs-scope">
|
||||
{{ categoryName || "本栏目" }}及子栏目({{ scopeIds.length }} 个栏目)· 草稿/已发布/已下架全部计入
|
||||
</span>
|
||||
<Button class="fr" size="small" @click="goArticles()">查看全部 →</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="ccs-empty">加载中…</div>
|
||||
<ul v-else-if="rows.length" class="ccs-list">
|
||||
<li v-for="row in rows" :key="row.id" class="ccs-row fix">
|
||||
<a class="ccs-title operating-link" :title="row.title" @click="goArticles(row.categoryId)">{{ row.title }}</a>
|
||||
<Tag class="ccs-tag" :color="statusColor(row.status)">{{ statusText(row.status) }}</Tag>
|
||||
<span class="ccs-time">{{ row.publishTime || row.createTime || "-" }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="ccs-empty">
|
||||
<p>本栏目及子栏目下暂无文章</p>
|
||||
<p class="ccs-empty-tip">可在上方「新建文章」直接发布到本栏目,或到「文章列表」页检索全站内容。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.category-content-summary {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 4px 16px 16px;
|
||||
}
|
||||
|
||||
.ccs-stat {
|
||||
padding: 14px 0 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.ccs-total {
|
||||
font-size: 14px;
|
||||
color: #515a6e;
|
||||
|
||||
b {
|
||||
font-size: 20px;
|
||||
color: #2d8cf0;
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ccs-scope {
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
|
||||
.ccs-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ccs-row {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ccs-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ccs-tag {
|
||||
flex: none;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.ccs-time {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
|
||||
.ccs-empty {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
color: #808695;
|
||||
}
|
||||
|
||||
.ccs-empty-tip {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { ThemeVo } from "@/types/cms";
|
||||
import ImageAssetField from "@/components/ImageAssetField.vue";
|
||||
import { effectiveValue, invalidParamKeys, FormModelValue } from "@/utils/themeParamForm";
|
||||
|
||||
/**
|
||||
* 主题参数微调表单(方案 §6.3):按 theme.json params schema 动态生成。
|
||||
* color→ColorPicker、select→Select、image→Input(手填 /cms/upload/ 路径,走附件上传后回填)、
|
||||
* text→Input。父级 v-model 绑定 FormModelValue(声明过的 key,值已兜底为默认)。
|
||||
* 主题参数微调表单(方案 §6.3 + 工作项 T):按 theme.json params schema 动态生成,
|
||||
* 值语义为「皮肤包级参数值」(`ThemeVo.paramValues`,落 themes/.meta/{name}.json)——
|
||||
* 一包一份值,引用同包的站点共享;需要差异走「复制皮肤包」。
|
||||
* 2026-09-20 布局重排(用户反馈"颜色设置竖着太长"):
|
||||
* - color 参数 → 色卡网格(横排自动换行,auto-fill 150px;真实主题 12 个色参数不再拉出 12 行长蛇);
|
||||
* - select/image/text → 两列行式(label + 控件同行);image 走 ImageAssetField(上传 + 手填双通道);
|
||||
* - 非法值红框 + 行内提示;「已修改」标记 + 单项恢复默认 + 整体重置(对照 params[].defaultValue)。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
theme?: ThemeVo;
|
||||
modelValue: FormModelValue;
|
||||
/** image 参数上传归属站点(皮肤包管理借用内容源站点;缺省则仅可手填路径) */
|
||||
siteId?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -19,9 +26,12 @@ const emit = defineEmits<{
|
||||
|
||||
const themeName = computed(() => props.theme?.title || props.theme?.name || "");
|
||||
const params = computed(() => props.theme?.params || []);
|
||||
const colorParams = computed(() => params.value.filter(p => p.type === "color"));
|
||||
const otherParams = computed(() => params.value.filter(p => p.type !== "color"));
|
||||
const badKeys = computed(() =>
|
||||
props.theme ? invalidParamKeys(props.theme, props.modelValue) : []
|
||||
);
|
||||
const modifiedCount = computed(() => params.value.filter(p => isModified(p.key)).length);
|
||||
|
||||
function setValue(key: string, value: string) {
|
||||
emit("update:modelValue", { ...props.modelValue, [key]: value });
|
||||
@@ -31,6 +41,25 @@ function displayValue(key: string): string {
|
||||
return effectiveValue(props.theme?.params.find(p => p.key === key), props.modelValue);
|
||||
}
|
||||
|
||||
/** 是否偏离包内声明的默认值(= 会被写进 .meta paramValues 的那些项) */
|
||||
function isModified(key: string): boolean {
|
||||
const p = params.value.find(x => x.key === key);
|
||||
if (!p) return false;
|
||||
return displayValue(key) !== (p.defaultValue ?? "");
|
||||
}
|
||||
|
||||
function resetOne(key: string) {
|
||||
const p = params.value.find(x => x.key === key);
|
||||
setValue(key, p?.defaultValue ?? "");
|
||||
}
|
||||
|
||||
/** 整体重置 = 提交全默认值模型(父级据此给后端空 Map,恢复 theme.json 默认) */
|
||||
function resetAll() {
|
||||
const next: FormModelValue = {};
|
||||
params.value.forEach(p => (next[p.key] = p.defaultValue ?? ""));
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
function isBad(key: string): boolean {
|
||||
return badKeys.value.includes(key);
|
||||
}
|
||||
@@ -38,71 +67,236 @@ function isBad(key: string): boolean {
|
||||
|
||||
<template>
|
||||
<div v-if="!theme" class="empty-tip">请先选择皮肤包</div>
|
||||
<Form v-else :label-width="90" class="theme-param-form">
|
||||
<div v-else class="theme-param-form">
|
||||
<div class="param-head">
|
||||
参数微调 · {{ themeName }}
|
||||
<span class="param-head-title">参数微调 · {{ themeName }}</span>
|
||||
<span v-if="badKeys.length" class="param-warn">
|
||||
有 {{ badKeys.length }} 个非法值未生效(将回退皮肤默认)
|
||||
{{ badKeys.length }} 个非法值未生效(保存时将回退皮肤默认)
|
||||
</span>
|
||||
<span class="param-head-tip">
|
||||
{{ modifiedCount ? `已修改 ${modifiedCount} 项` : "未修改(全部为包内默认值)" }}
|
||||
· 保存后引用该包的站点门户立即生效
|
||||
</span>
|
||||
<Button v-if="modifiedCount" size="small" ghost class="reset-all" @click="resetAll">整体恢复默认</Button>
|
||||
</div>
|
||||
<FormItem v-for="p in params" :key="p.key" :label="p.label">
|
||||
<!-- color:色板 -->
|
||||
<ColorPicker
|
||||
v-if="p.type === 'color'"
|
||||
:model-value="displayValue(p.key)"
|
||||
recommend
|
||||
@on-change="(c: string) => setValue(p.key, c)"
|
||||
/>
|
||||
<!-- select:下拉 -->
|
||||
<Select
|
||||
v-else-if="p.type === 'select'"
|
||||
:model-value="displayValue(p.key)"
|
||||
style="width: 260px"
|
||||
@on-change="(v: string) => setValue(p.key, v)"
|
||||
|
||||
<!-- color:色卡网格(横排) -->
|
||||
<div v-if="colorParams.length" class="param-grid">
|
||||
<div
|
||||
v-for="p in colorParams"
|
||||
:key="p.key"
|
||||
class="param-chip"
|
||||
:class="{ bad: isBad(p.key), modified: isModified(p.key) }"
|
||||
:title="p.key"
|
||||
>
|
||||
<Option v-for="o in p.options" :key="o.value" :value="o.value">{{ o.label }}</Option>
|
||||
</Select>
|
||||
<!-- image:附件路径(上传后回填 /cms/upload/**) -->
|
||||
<Input
|
||||
v-else-if="p.type === 'image'"
|
||||
:model-value="displayValue(p.key)"
|
||||
placeholder="/cms/upload/cms/... (仅接受 /cms/upload/** 路径)"
|
||||
style="width: 320px"
|
||||
@on-change="(v: string) => setValue(p.key, v)"
|
||||
/>
|
||||
<!-- text:短文本 -->
|
||||
<Input
|
||||
v-else
|
||||
:model-value="displayValue(p.key)"
|
||||
:maxlength="256"
|
||||
style="width: 320px"
|
||||
@on-change="(v: string) => setValue(p.key, v)"
|
||||
/>
|
||||
<span v-if="isBad(p.key)" class="bad-mark">当前值非法({{ p.type }} 类型校验失败)</span>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<ColorPicker
|
||||
:model-value="displayValue(p.key)"
|
||||
recommend
|
||||
@on-change="(c: string) => setValue(p.key, c)"
|
||||
/>
|
||||
<span class="chip-name">
|
||||
{{ p.label }}
|
||||
<span v-if="isModified(p.key)" class="chip-flag">已修改</span>
|
||||
</span>
|
||||
<span class="chip-hex">{{ displayValue(p.key) }}</span>
|
||||
<Button
|
||||
v-if="isModified(p.key)"
|
||||
size="small"
|
||||
ghost
|
||||
class="chip-reset"
|
||||
@click="resetOne(p.key)"
|
||||
>
|
||||
恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- select / image / text:两列行式 -->
|
||||
<div v-if="otherParams.length" class="param-rows">
|
||||
<div v-for="p in otherParams" :key="p.key" class="param-row" :class="{ bad: isBad(p.key) }">
|
||||
<label class="row-label">
|
||||
{{ p.label }}
|
||||
<span v-if="isModified(p.key)" class="chip-flag">已修改</span>
|
||||
</label>
|
||||
<div class="row-control">
|
||||
<Select
|
||||
v-if="p.type === 'select'"
|
||||
:model-value="displayValue(p.key)"
|
||||
size="small"
|
||||
transfer
|
||||
@on-change="(v: string) => setValue(p.key, v)"
|
||||
>
|
||||
<Option v-for="o in p.options" :key="o.value" :value="o.value">{{ o.label }}</Option>
|
||||
</Select>
|
||||
<ImageAssetField
|
||||
v-else-if="p.type === 'image'"
|
||||
:model-value="displayValue(p.key)"
|
||||
:site-id="siteId"
|
||||
placeholder="/cms/upload/cms/... (上传或粘贴路径,仅接受 /cms/upload/** 与 /yschcms/upload/**)"
|
||||
@update:model-value="(v: string) => setValue(p.key, v)"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
:model-value="displayValue(p.key)"
|
||||
size="small"
|
||||
:maxlength="256"
|
||||
@on-change="(v: string) => setValue(p.key, v)"
|
||||
/>
|
||||
<Button v-if="isModified(p.key)" size="small" ghost @click="resetOne(p.key)">恢复默认</Button>
|
||||
<span v-if="isBad(p.key)" class="bad-mark">非法值({{ p.type }} 校验失败)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.empty-tip {
|
||||
color: #999;
|
||||
color: #8a93a6;
|
||||
font-size: 13px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.param-head {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
.theme-param-form {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.param-warn {
|
||||
color: #ed4014;
|
||||
font-weight: 400;
|
||||
margin-left: 8px;
|
||||
.param-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
.param-head-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e2635;
|
||||
}
|
||||
.param-head-tip {
|
||||
color: #8a93a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.param-warn {
|
||||
color: #e54d42;
|
||||
font-size: 12px;
|
||||
}
|
||||
.reset-all {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* color 色卡网格:横排自动换行(150px 起步,宽屏一行 4~5 张) */
|
||||
.param-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.param-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e9edf5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
&:hover {
|
||||
border-color: #b9c8f7;
|
||||
box-shadow: 0 4px 12px -8px rgba(87, 122, 251, 0.45);
|
||||
}
|
||||
&.bad {
|
||||
border-color: #e54d42;
|
||||
}
|
||||
&.modified {
|
||||
border-color: #ffd591;
|
||||
background: #fffdf7;
|
||||
}
|
||||
.chip-name {
|
||||
font-size: 12px;
|
||||
color: #1e2635;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chip-hex {
|
||||
font-size: 11px;
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
color: #8a93a6;
|
||||
}
|
||||
.chip-reset {
|
||||
align-self: flex-start;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
/* ColorPicker 触发器整条拉宽成色条 */
|
||||
:deep(.ivu-color-picker) {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
:deep(.ivu-color-picker .ivu-input-wrapper) {
|
||||
display: block;
|
||||
}
|
||||
:deep(.ivu-color-picker .ivu-input) {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* 「已修改」小徽标:色卡与行式 label 共用 */
|
||||
.chip-flag {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
padding: 0 5px;
|
||||
border-radius: 7px;
|
||||
background: #fff7e6;
|
||||
color: #d46b08;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
/* select/image/text:两列行式 */
|
||||
.param-rows {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 8px 18px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.param-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
&.bad .row-label {
|
||||
color: #e54d42;
|
||||
}
|
||||
.row-label {
|
||||
flex: none;
|
||||
width: 76px;
|
||||
font-size: 13px;
|
||||
color: #1e2635;
|
||||
text-align: right;
|
||||
}
|
||||
.row-control {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.ivu-select,
|
||||
.ivu-input-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
:deep(.image-asset-field) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.bad-mark {
|
||||
color: #ed4014;
|
||||
color: #e54d42;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -67,6 +67,14 @@ const cmsChildren: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/cms/BannerManage.vue"),
|
||||
meta: { title: "轮播管理", modId: 3 },
|
||||
},
|
||||
{
|
||||
// docs/20 D-7:名师风采数据维护(people 区块数据源)。菜单挂靠轮播管理族(平台 UserBase 未配
|
||||
// 独立模块前经首页布局编辑器 people 区块属性面板直达;modId 3 = 轮播管理,面包屑归属一致)
|
||||
path: "person",
|
||||
name: "PersonManage",
|
||||
component: () => import("@/views/cms/PersonManage.vue"),
|
||||
meta: { title: "人物管理", modId: 3, subOf: "/banner" },
|
||||
},
|
||||
{
|
||||
path: "link",
|
||||
name: "LinkManage",
|
||||
|
||||
+71
-1
@@ -170,7 +170,7 @@ export interface ArticleSaveRequest {
|
||||
export interface ArticlePageQuery {
|
||||
siteId: number;
|
||||
/** 栏目 id(后端字段 categoryIds: List<Integer>,多选逗号分隔;空=全部栏目) */
|
||||
categoryIds?: number;
|
||||
categoryIds?: number | string;
|
||||
status?: number;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
@@ -211,6 +211,17 @@ export interface BannerVo {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
status: number;
|
||||
// ===== docs/20 D-3/D-6:topic/event 展示位扩展字段 =====
|
||||
/** 专题大卡描述(position=topic) */
|
||||
description?: string;
|
||||
/** 专题大卡 CTA 按钮文字 */
|
||||
ctaText?: string;
|
||||
/** 专题大卡 CTA 跳转地址 */
|
||||
ctaUrl?: string;
|
||||
/** 活动日期(position=event,yyyy-MM-dd) */
|
||||
eventDate?: string;
|
||||
/** 活动地点(position=event) */
|
||||
location?: string;
|
||||
}
|
||||
|
||||
/* ===== 友链 ===== */
|
||||
@@ -227,4 +238,63 @@ export interface LinkVo {
|
||||
status: number;
|
||||
}
|
||||
|
||||
/* ===== 皮肤包(ThemeVo,S1 交接契约:参数值为包级)===== */
|
||||
|
||||
/** select 型参数的候选项 */
|
||||
export interface ThemeParamOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 参数 schema 项(后端 ThemeVo.ThemeParamVo):schema 只来自包内 theme.json */
|
||||
export interface ThemeParamVo {
|
||||
key: string;
|
||||
/** color 型才有,CSS 变量名(如 --primary) */
|
||||
cssVar?: string;
|
||||
label: string;
|
||||
/** color | select | image | text */
|
||||
type: string;
|
||||
/** 包内默认值:前端「已修改」标记与恢复默认的基准 */
|
||||
defaultValue?: string;
|
||||
options?: ThemeParamOption[];
|
||||
}
|
||||
|
||||
/** 皮肤包(GET/PUT /api/theme/**) */
|
||||
export interface ThemeVo {
|
||||
/** 标识名 = 主题路径段,创建后不可改 */
|
||||
name: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
author?: string;
|
||||
bannerSize?: string;
|
||||
/** 预览图(管理端资产代理 /api/theme/{name}/asset/screenshot.png) */
|
||||
screenshotUrl?: string;
|
||||
params: ThemeParamVo[];
|
||||
/** 包级参数值(工作项 T):只含被微调过的 key,存 themes/.meta/{name}.json;缺项按 defaultValue 回落 */
|
||||
paramValues?: Record<string, string>;
|
||||
/** builtin=内置(模板/资产只读)/ external=外部可管 */
|
||||
source?: string;
|
||||
inUseCount?: number;
|
||||
editable?: boolean;
|
||||
usedBySiteNames?: string[];
|
||||
}
|
||||
|
||||
/* ===== 事项库(svc,事项库详细设计 §7.4)===== */
|
||||
|
||||
/* ===== 名师风采(docs/20 D-7,PersonVo) ===== */
|
||||
export interface PersonVo {
|
||||
id?: number;
|
||||
siteId: number;
|
||||
name: string;
|
||||
/** 头衔/职称 */
|
||||
title?: string;
|
||||
/** 一句话简介 */
|
||||
bio?: string;
|
||||
/** 头像 URL(空 = 首字徽章兜底) */
|
||||
avatar?: string;
|
||||
/** 所属栏目 id(空 = 未分组) */
|
||||
categoryId?: number;
|
||||
sortNo: number;
|
||||
status: number;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EXTRA_KEY_RE,
|
||||
SITE_CONFIG_FIELD_DEFS,
|
||||
extraRowError,
|
||||
firstExtraError,
|
||||
parseSiteConfig,
|
||||
serializeSiteConfig,
|
||||
validateFieldValue,
|
||||
} from "./siteConfigForm";
|
||||
|
||||
/** docs/09 §3.1 站点配置结构化:parse/serialize/校验 */
|
||||
/** docs/09 §3.1 站点配置结构化:parse/serialize/校验;docs/20 F6 扩展参数区退役(未知键静默保留) */
|
||||
describe("siteConfigForm", () => {
|
||||
it("parse:已知键入 fields、未知字符串键入扩展行、theme_params/对象键无损入 preserved", () => {
|
||||
it("parse:已知键入 fields、未知键(字符串/对象)与系统托管键均无损入 preserved(F6)", () => {
|
||||
const json = JSON.stringify({
|
||||
logo: "/cms/upload/logo.png",
|
||||
contact: "电话 0571-88888888",
|
||||
@@ -26,14 +23,14 @@ describe("siteConfigForm", () => {
|
||||
expect(m.fields.contact).toBe("电话 0571-88888888");
|
||||
expect(m.fields.domain).toBe("www.xxx.edu.cn");
|
||||
expect(m.fields.public_base_url).toBe("https://www.xxx.edu.cn");
|
||||
expect(m.extras).toEqual([{ key: "my_ext", value: "自定义值" }]);
|
||||
expect(m.preserved.my_ext).toBe("自定义值"); // F6:未知字符串键不再进扩展行,静默保留
|
||||
expect(m.preserved.theme_params).toEqual({ primary: "#b01a2e" });
|
||||
expect(m.preserved.wechat).toEqual({ appid: "wx123" });
|
||||
});
|
||||
|
||||
it("parse:非法 JSON / 空值按空配置处理,已知键非字符串值无损保留", () => {
|
||||
expect(parseSiteConfig("这不是json{").fields).toEqual({});
|
||||
expect(parseSiteConfig(null).extras).toEqual([]);
|
||||
expect(parseSiteConfig(null).preserved).toEqual({});
|
||||
expect(parseSiteConfig("").preserved).toEqual({});
|
||||
const m = parseSiteConfig(JSON.stringify({ logo: 123, other: [1, 2] }));
|
||||
expect(m.fields.logo).toBeUndefined();
|
||||
@@ -41,22 +38,17 @@ describe("siteConfigForm", () => {
|
||||
expect(m.preserved.other).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("serialize:空值剔除、扩展行空键/空值剔除、preserved 永不丢失", () => {
|
||||
it("serialize:空值剔除、preserved 永不丢失", () => {
|
||||
const m = parseSiteConfig(JSON.stringify({ logo: "/cms/upload/logo.png", theme_params: { primary: "#fff" }, wechat: { x: 1 } }));
|
||||
m.fields.contact = " "; // 空白 → 剔除
|
||||
m.extras.push({ key: "k1", value: "v1" });
|
||||
m.extras.push({ key: "", value: "被剔除" });
|
||||
m.extras.push({ key: "k2", value: " " }); // 空值 → 剔除
|
||||
const out = JSON.parse(serializeSiteConfig(m));
|
||||
expect(out.logo).toBe("/cms/upload/logo.png");
|
||||
expect(out).not.toHaveProperty("contact");
|
||||
expect(out.k1).toBe("v1");
|
||||
expect(out).not.toHaveProperty("k2");
|
||||
expect(out.theme_params).toEqual({ primary: "#fff" });
|
||||
expect(out.wechat).toEqual({ x: 1 });
|
||||
});
|
||||
|
||||
it("roundtrip:parse→serialize→parse 内容不丢(含扩展行与 preserved)", () => {
|
||||
it("roundtrip:parse→serialize→parse 内容不丢(未知键与 preserved 全保留)", () => {
|
||||
const json = JSON.stringify({
|
||||
copyright: "XX学校 · 版权所有 © 2026",
|
||||
seo_description: "校讯",
|
||||
@@ -68,12 +60,12 @@ describe("siteConfigForm", () => {
|
||||
const twice = parseSiteConfig(serializeSiteConfig(once));
|
||||
expect(twice.fields.slogan).toBe("厚德博学");
|
||||
expect(twice.fields.copyright).toBe("XX学校 · 版权所有 © 2026");
|
||||
expect(twice.extras).toEqual([{ key: "my_ext", value: "v" }]);
|
||||
expect(twice.preserved.my_ext).toBe("v");
|
||||
expect(twice.preserved.theme_params).toEqual({ bg: "#f7f5f2" });
|
||||
});
|
||||
|
||||
it("validateFieldValue:url/host/asset/text 各类型通过与拒绝", () => {
|
||||
const def = (kind: string, maxLength?: number) =>
|
||||
const def = (kind: string) =>
|
||||
SITE_CONFIG_FIELD_DEFS.find(d => d.key === { url: "public_base_url", host: "domain", asset: "logo", text: "copyright" }[kind])!;
|
||||
expect(validateFieldValue(def("url"), "https://www.xxx.edu.cn")).toBe("");
|
||||
expect(validateFieldValue(def("url"), "www.xxx.edu.cn")).toContain("http");
|
||||
@@ -87,26 +79,6 @@ describe("siteConfigForm", () => {
|
||||
expect(validateFieldValue(def("url"), "")).toBe(""); // 空值一律放行(非必填)
|
||||
});
|
||||
|
||||
it("extraRowError:格式/重名/重复校验;空键行放行", () => {
|
||||
const rows = [{ key: "my_ext", value: "v" }];
|
||||
expect(extraRowError(rows, 0)).toBe("");
|
||||
expect(extraRowError([{ key: "", value: "v" }], 0)).toBe("");
|
||||
expect(extraRowError([{ key: "my-ext", value: "v" }], 0)).toContain("下划线");
|
||||
expect(extraRowError([{ key: "1bad", value: "v" }], 0)).toContain("下划线");
|
||||
expect(extraRowError([{ key: "logo", value: "v" }], 0)).toContain("重名");
|
||||
expect(extraRowError([{ key: "theme_params", value: "v" }], 0)).toContain("重名");
|
||||
expect(extraRowError([{ key: "a", value: "1" }, { key: "a", value: "2" }], 1)).toContain("重复");
|
||||
expect(EXTRA_KEY_RE.test("wechat_appid")).toBe(true);
|
||||
expect(EXTRA_KEY_RE.test("_priv")).toBe(true);
|
||||
expect(EXTRA_KEY_RE.test("9bad")).toBe(false);
|
||||
});
|
||||
|
||||
it("firstExtraError:返回第一条错误;全部合法返回空串", () => {
|
||||
expect(firstExtraError([{ key: "ok_key", value: "1" }, { key: "bad-key", value: "2" }])).toContain("下划线");
|
||||
expect(firstExtraError([{ key: "a", value: "1" }, { key: "b", value: "2" }])).toBe("");
|
||||
expect(firstExtraError([])).toBe("");
|
||||
});
|
||||
|
||||
it("已知键清单与 docs/09 §0 键盘点表一致(防漂移;docs/16 D4 吸收三旧键后收敛)", () => {
|
||||
expect(SITE_CONFIG_FIELD_DEFS.map(d => d.key)).toEqual([
|
||||
"logo", "favicon", "slogan", "colleges_label",
|
||||
@@ -128,7 +100,6 @@ describe("siteConfigForm", () => {
|
||||
expect(m.fields.footer_addresses).toContain("淮河校区");
|
||||
expect(m.fields.qr_wechat).toBe("/cms/upload/qr-wechat.png");
|
||||
expect(m.fields.social_weibo).toBe("https://weibo.com/xxx");
|
||||
expect(m.extras).toEqual([]); // 新键均为已知键,不落扩展行
|
||||
const qrDef = SITE_CONFIG_FIELD_DEFS.find(d => d.key === "qr_weibo")!;
|
||||
const socialDef = SITE_CONFIG_FIELD_DEFS.find(d => d.key === "social_douyin")!;
|
||||
expect(validateFieldValue(qrDef, "javascript:alert(1)")).toContain("路径");
|
||||
@@ -139,19 +110,18 @@ describe("siteConfigForm", () => {
|
||||
expect(out.footer_addresses).toContain("邮编:464300");
|
||||
expect(out).not.toHaveProperty("social_bilibili");
|
||||
});
|
||||
it("docs/16 D4:gallery_title/gallery_subtitle/news_bg_image 被吸收为静默保留键(不落扩展行、无损回写)", () => {
|
||||
it("docs/16 D4:gallery_title/gallery_subtitle/news_bg_image 被吸收为静默保留键(无损回写)", () => {
|
||||
const json = JSON.stringify({ gallery_title: "线上展厅", news_bg_image: "/cms/upload/campus.jpg" });
|
||||
const m = parseSiteConfig(json);
|
||||
expect(m.fields).toEqual({}); // 不进任何分区表单
|
||||
expect(m.extras).toEqual([]); // 也不落扩展行
|
||||
expect(m.preserved.gallery_title).toBe("线上展厅");
|
||||
expect(m.preserved.news_bg_image).toBe("/cms/upload/campus.jpg");
|
||||
expect(JSON.parse(serializeSiteConfig(m))).toEqual(JSON.parse(json)); // 存量值无损保留
|
||||
});
|
||||
it("B3:custom_css 为保留键,不入扩展行,parse/serialize 无损往返", () => {
|
||||
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.my_ext).toBe("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}");
|
||||
});
|
||||
|
||||
+48
-63
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 站点配置结构化表单纯函数模块(docs/09 §3.1)。
|
||||
* 已知键与模板/Java 实际消费方对齐(docs/09 §0 键清单表);未知字符串键 → 可编辑扩展行
|
||||
* (Projman「添加项」模式);未知非字符串键(theme_params 等系统托管键)原样保留无损回写。
|
||||
* 站点配置结构化表单纯函数模块(docs/09 §3.1;docs/20 F6 扩展参数分区退役)。
|
||||
* 已知键与模板/Java 实际消费方对齐(docs/09 §0 键清单表);
|
||||
* 未知键(字符串与非字符串)一律无损入 preserved 静默保留——不再提供「扩展参数」编辑区
|
||||
* (后端对未知键的宽容策略不变:不拦截、不渲染、不丢失)。
|
||||
* vitest 单测对象:siteConfigForm.spec.ts,不依赖组件挂载基建。
|
||||
*/
|
||||
|
||||
@@ -16,6 +17,8 @@ export interface SiteConfigFieldDef {
|
||||
multiline?: boolean;
|
||||
/** url: http(s) URL;host: 域名(不带协议);asset: / 开头路径或 http(s) URL;text: 仅长度 */
|
||||
kind?: "text" | "url" | "host" | "asset";
|
||||
/** F5(docs/20):asset 类字段的预览/占位框比例(favicon 1:1、二维码 4:3) */
|
||||
aspect?: "1:1" | "4:3";
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
@@ -27,8 +30,9 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
|
||||
tip: "门户顶栏与页脚品牌位展示;建议先在「附件管理」上传,复制路径填到这里。",
|
||||
},
|
||||
{
|
||||
key: "favicon", label: "浏览器图标 favicon", group: "display", kind: "asset", maxLength: 500,
|
||||
key: "favicon", label: "浏览器图标 favicon", group: "display", kind: "asset", maxLength: 500, aspect: "1:1",
|
||||
placeholder: "如 /yschcms/upload/…/favicon.png(留空用主题内置图标)",
|
||||
tip: "浏览器标签页与收藏夹的小图标,不显示在页面正文中。",
|
||||
},
|
||||
{
|
||||
key: "slogan", label: "首页标语", group: "display", kind: "text", maxLength: 200,
|
||||
@@ -42,6 +46,7 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
|
||||
{
|
||||
key: "contact", label: "联系方式", group: "footer", kind: "text", maxLength: 200, multiline: true,
|
||||
placeholder: "如:电话 0571-88888888 · 地址杭州市 XX 路 100 号",
|
||||
tip: "显示在页脚品牌区(页脚第一行位置)。",
|
||||
},
|
||||
{
|
||||
key: "footer_addresses", label: "多校区地址", group: "footer", kind: "text", maxLength: 500, multiline: true,
|
||||
@@ -49,37 +54,44 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
|
||||
tip: "逐行展示在页脚品牌区旁;换行即分行(最多 500 字)。",
|
||||
},
|
||||
{
|
||||
key: "qr_wechat", label: "微信二维码", group: "footer", kind: "asset", maxLength: 500,
|
||||
key: "qr_wechat", label: "微信二维码", group: "footer", kind: "asset", maxLength: 500, aspect: "4:3",
|
||||
placeholder: "如 /yschcms/upload/…/qr-wechat.png(留空不展示)",
|
||||
tip: "页脚二维码,悬停放大预览;建议先在「附件管理」上传。",
|
||||
},
|
||||
{
|
||||
key: "qr_weibo", label: "微博二维码", group: "footer", kind: "asset", maxLength: 500,
|
||||
key: "qr_weibo", label: "微博二维码", group: "footer", kind: "asset", maxLength: 500, aspect: "4:3",
|
||||
placeholder: "如 /yschcms/upload/…/qr-weibo.png(留空不展示)",
|
||||
tip: "显示在页脚右侧二维码区,与微信二维码并排,悬停放大预览。",
|
||||
},
|
||||
{
|
||||
key: "social_wechat", label: "微信公众号", group: "footer", kind: "url", maxLength: 500,
|
||||
placeholder: "微信公众号主页链接,如 https://mp.weixin.qq.com/…(留空不展示图标)",
|
||||
tip: "页脚社媒图标行(点击跳转链接地址)。",
|
||||
},
|
||||
{
|
||||
key: "social_weibo", label: "微博", group: "footer", kind: "url", maxLength: 500,
|
||||
placeholder: "微博主页链接 https://weibo.com/…(留空不展示图标)",
|
||||
tip: "页脚社媒图标行(点击跳转链接地址)。",
|
||||
},
|
||||
{
|
||||
key: "social_douyin", label: "抖音", group: "footer", kind: "url", maxLength: 500,
|
||||
placeholder: "抖音主页链接 https://www.douyin.com/…(留空不展示图标)",
|
||||
tip: "页脚社媒图标行(点击跳转链接地址)。",
|
||||
},
|
||||
{
|
||||
key: "social_bilibili", label: "哔哩哔哩", group: "footer", kind: "url", maxLength: 500,
|
||||
placeholder: "B 站主页链接 https://space.bilibili.com/…(留空不展示图标)",
|
||||
tip: "页脚社媒图标行(点击跳转链接地址)。",
|
||||
},
|
||||
{
|
||||
key: "copyright", label: "版权文字", group: "footer", kind: "text", maxLength: 200,
|
||||
placeholder: "如:XX学校 · 版权所有 © 2026",
|
||||
tip: "页脚最底行的版权声明文字。",
|
||||
},
|
||||
{
|
||||
key: "beian", label: "备案号", group: "footer", kind: "text", maxLength: 100,
|
||||
placeholder: "如:浙ICP备XXXXXXXX号-1",
|
||||
tip: "页脚最底行版权文字旁的 ICP 备案号(可点击性质由模板决定,通常为纯文字)。",
|
||||
},
|
||||
{
|
||||
key: "seo_description", label: "SEO 描述", group: "seo", kind: "text", maxLength: 500, multiline: true,
|
||||
@@ -121,28 +133,22 @@ export const RESERVED_SITE_CONFIG_KEYS = ["theme_params", "custom_css"];
|
||||
*/
|
||||
export const ABSORBED_LEGACY_KEYS = ["gallery_title", "gallery_subtitle", "news_bg_image"];
|
||||
|
||||
/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致) */
|
||||
/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致;供后续功能键落位时复用) */
|
||||
export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
export const HOST_RE = /^(\*\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
|
||||
export const URL_RE = /^https?:\/\/\S+$/;
|
||||
export const ASSET_RE = /^(\/.+|https?:\/\/\S+)$/;
|
||||
|
||||
export interface SiteConfigExtraRow {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SiteConfigFormModel {
|
||||
fields: Record<string, string>;
|
||||
extras: SiteConfigExtraRow[];
|
||||
/** 系统托管键 + 非字符串未知键,无损保留 */
|
||||
/** 非已知键(含系统托管键与未知键)无损保留 */
|
||||
preserved: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** configJson → 表单模型:已知键入 fields、未知字符串键入扩展行、其余无损入 preserved */
|
||||
/** configJson → 表单模型:已知键入 fields,其余(系统托管键/旧键/未知键)无损入 preserved */
|
||||
export function parseSiteConfig(configJson: string | undefined | null): SiteConfigFormModel {
|
||||
const model: SiteConfigFormModel = { fields: {}, extras: [], preserved: {} };
|
||||
const model: SiteConfigFormModel = { fields: {}, preserved: {} };
|
||||
if (!configJson || !configJson.trim()) {
|
||||
return model;
|
||||
}
|
||||
@@ -159,23 +165,18 @@ export function parseSiteConfig(configJson: string | undefined | null): SiteConf
|
||||
return model;
|
||||
}
|
||||
const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key));
|
||||
const reserved = new Set([...RESERVED_SITE_CONFIG_KEYS, ...ABSORBED_LEGACY_KEYS]);
|
||||
Object.keys(parsed).forEach(k => {
|
||||
const v = parsed![k];
|
||||
if (typeof v !== "string") {
|
||||
model.preserved[k] = v;
|
||||
} else if (known.has(k)) {
|
||||
if (known.has(k) && typeof v === "string") {
|
||||
model.fields[k] = v;
|
||||
} else if (reserved.has(k)) {
|
||||
model.preserved[k] = v;
|
||||
} else {
|
||||
model.extras.push({ key: k, value: v });
|
||||
model.preserved[k] = v;
|
||||
}
|
||||
});
|
||||
return model;
|
||||
}
|
||||
|
||||
/** 表单模型 → configJson:空值剔除;扩展行空键/空值剔除;preserved 原样回写(永不丢失) */
|
||||
/** 表单模型 → configJson:空值剔除;preserved 原样回写(永不丢失) */
|
||||
export function serializeSiteConfig(model: SiteConfigFormModel): string {
|
||||
const out: Record<string, unknown> = {};
|
||||
SITE_CONFIG_FIELD_DEFS.forEach(def => {
|
||||
@@ -184,19 +185,35 @@ export function serializeSiteConfig(model: SiteConfigFormModel): string {
|
||||
out[def.key] = v;
|
||||
}
|
||||
});
|
||||
(model.extras || []).forEach(row => {
|
||||
const k = (row.key || "").trim();
|
||||
const v = (row.value || "").trim();
|
||||
if (k && v) {
|
||||
out[k] = v;
|
||||
}
|
||||
});
|
||||
Object.keys(model.preserved || {}).forEach(k => {
|
||||
out[k] = model.preserved[k];
|
||||
});
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* F1(docs/20)Tab 独立保存:服务端事实源 base + 本 Tab 增量 patch 合并——
|
||||
* patch 值为空串/null/undefined = 删除该键;其余键原样保留。
|
||||
* 各 Tab 只提交自己的键空间,其他 Tab 未保存的内存修改不会被连带提交。
|
||||
*/
|
||||
export function mergeConfigPatch(base: string | undefined | null, patch: Record<string, unknown>): string {
|
||||
let obj: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = base && base.trim() ? JSON.parse(base) : {};
|
||||
obj = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {};
|
||||
} catch {
|
||||
obj = {};
|
||||
}
|
||||
Object.entries(patch).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null || (typeof v === "string" && !v.trim())) {
|
||||
delete obj[k];
|
||||
} else {
|
||||
obj[k] = v;
|
||||
}
|
||||
});
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
|
||||
/** 已知字段值校验:返回错误消息,空串=通过(与后端 validateConfigJson 同语义) */
|
||||
export function validateFieldValue(def: SiteConfigFieldDef, value: string): string {
|
||||
const v = (value || "").trim();
|
||||
@@ -214,35 +231,3 @@ export function validateFieldValue(def: SiteConfigFieldDef, value: string): stri
|
||||
return def.maxLength && v.length > def.maxLength ? `不能超过 ${def.maxLength} 字` : "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 扩展行校验:键名格式 / 与内置键或系统键冲突 / 行间重复;返回错误消息,空串=通过(空键行视为待填) */
|
||||
export function extraRowError(rows: SiteConfigExtraRow[], index: number): string {
|
||||
const row = rows[index];
|
||||
const key = (row?.key || "").trim();
|
||||
if (!key) {
|
||||
return "";
|
||||
}
|
||||
if (!EXTRA_KEY_RE.test(key)) {
|
||||
return "键名仅限字母/数字/下划线,且以字母或下划线开头";
|
||||
}
|
||||
const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key));
|
||||
if (known.has(key) || RESERVED_SITE_CONFIG_KEYS.includes(key)) {
|
||||
return "扩展键不能与内置键/系统键重名";
|
||||
}
|
||||
const dup = rows.findIndex(r => (r.key || "").trim() === key);
|
||||
if (dup >= 0 && dup !== index) {
|
||||
return "扩展键重复";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 保存前兜底:返回第一条扩展行错误,空串=全部通过 */
|
||||
export function firstExtraError(rows: SiteConfigExtraRow[]): string {
|
||||
for (let i = 0; i < (rows || []).length; i++) {
|
||||
const msg = extraRowError(rows, i);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
+22
-1
@@ -5,17 +5,38 @@
|
||||
* 新行为:保留原值交服务端注册表校验(保存未注册主题会被明确拒绝),仅提示告警。
|
||||
*/
|
||||
export interface EditableTheme {
|
||||
/** 编辑表单主题值(未注册时保留原值,不再偷偷归一 default) */
|
||||
/** 编辑表单主题值(未注册时保留原值,不再偷偷归一 default;下架别名解析为保留主题) */
|
||||
theme: string;
|
||||
/** 主题未在注册列表中(保存将被服务端拒绝,需提示用户改选或先上传) */
|
||||
unregistered: boolean;
|
||||
/** docs/20 E:下架主题别名解析到的 colorScheme 预设(保存后保持原配色;非别名为 undefined) */
|
||||
aliasColorScheme?: string;
|
||||
}
|
||||
|
||||
/** docs/20 E:下架主题别名表(与后端 ThemeRegistry.DEPRECATED_ALIASES 同构,两侧同步维护) */
|
||||
const DEPRECATED_ALIASES: Record<string, { target: string; colorScheme: string }> = {
|
||||
"blue-white": { target: "default", colorScheme: "blue-white" },
|
||||
"classic-red": { target: "default", colorScheme: "classic-red" },
|
||||
"fresh-mint": { target: "default", colorScheme: "fresh-mint" },
|
||||
"warm-kinder": { target: "default", colorScheme: "warm-kinder" },
|
||||
"academic-navy": { target: "scholar-blue", colorScheme: "navy" },
|
||||
"campus-azure": { target: "scholar-blue", colorScheme: "azure" },
|
||||
"poly-blue": { target: "scholar-blue", colorScheme: "poly" },
|
||||
"agri-forest": { target: "scholar-blue", colorScheme: "agri" },
|
||||
"jade-normal": { target: "scholar-blue", colorScheme: "jade" },
|
||||
"mint-breeze": { target: "scholar-blue", colorScheme: "mint" },
|
||||
};
|
||||
|
||||
export function resolveEditableTheme(rowTheme: string | undefined, themeNames: string[]): EditableTheme {
|
||||
const value = rowTheme || "default";
|
||||
if (!themeNames.length) {
|
||||
// 列表加载失败/为空:无法判定注册状态,保留原值且不误报(由服务端校验兜底)
|
||||
return { theme: value, unregistered: false };
|
||||
}
|
||||
// docs/20 E:下架主题 → 呈现归一后的保留主题(与服务端保存归一化一致,不误报"未注册")
|
||||
const alias = DEPRECATED_ALIASES[value];
|
||||
if (alias && themeNames.includes(alias.target)) {
|
||||
return { theme: alias.target, unregistered: false, aliasColorScheme: alias.colorScheme };
|
||||
}
|
||||
return { theme: value, unregistered: !themeNames.includes(value) };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as themeParamForm from "./themeParamForm";
|
||||
import {
|
||||
COLOR_RE,
|
||||
formModelFromTheme,
|
||||
effectiveValue,
|
||||
invalidParamKeys,
|
||||
isValidParamValue,
|
||||
mergeThemeParamsIntoConfigJson,
|
||||
themeParamsFromConfigJson,
|
||||
paramValuesOf,
|
||||
} from "./themeParamForm";
|
||||
import { ThemeVo } from "@/types/cms";
|
||||
|
||||
@@ -30,26 +30,69 @@ function theme(): ThemeVo {
|
||||
};
|
||||
}
|
||||
|
||||
describe("themeParamForm(方案 §6.3/§8.4)", () => {
|
||||
it("T28: schema→表单模型:四类型各映射为对应生效值 + 默认值兜底", () => {
|
||||
const model = formModelFromTheme(theme());
|
||||
expect(model.primary).toBe("#b01a2e");
|
||||
expect(model.bg).toBe("#f7f5f2");
|
||||
describe("themeParamForm(方案 §6.3/§8.4 + 工作项 T 包级参数)", () => {
|
||||
it("T-T-08: paramValuesOf 以包 schema + paramValues 组装,缺项回落 defaultValue", () => {
|
||||
const t = theme();
|
||||
t.paramValues = { primary: "#123456" };
|
||||
const model = paramValuesOf(t);
|
||||
expect(model.primary).toBe("#123456"); // 包级微调值
|
||||
expect(model.bg).toBe("#f7f5f2"); // 未微调 → theme.json 默认值
|
||||
expect(model.headerStyle).toBe("banner");
|
||||
expect(model.logoImage).toBe("");
|
||||
});
|
||||
|
||||
it("T28: 已有合法值保留,非法显式值回退默认", () => {
|
||||
const model = formModelFromTheme(theme(), {
|
||||
primary: "#123456", // 合法,保留
|
||||
bg: "red;}", // 非法,回退默认
|
||||
headerStyle: "plain", // select 枚举内合法
|
||||
logoImage: "javascript:alert(1)", // 非法 image
|
||||
it("T-T-08: 覆盖层里的非法值回退默认、未声明 key 不入模型(与后端 filterValidValues 同语义)", () => {
|
||||
const t = theme();
|
||||
t.paramValues = { primary: "red;}", bg: "#000000", zzhack: "zzvalue" };
|
||||
const model = paramValuesOf(t);
|
||||
expect(model.primary).toBe("#b01a2e"); // 非法显式值 → 默认
|
||||
expect(model.bg).toBe("#000000");
|
||||
expect(model.zzhack).toBeUndefined();
|
||||
});
|
||||
|
||||
it("T-T-08: 无 paramValues(新包/未微调)全量回落默认", () => {
|
||||
expect(paramValuesOf(theme())).toEqual({
|
||||
primary: "#b01a2e",
|
||||
bg: "#f7f5f2",
|
||||
headerStyle: "banner",
|
||||
logoImage: "",
|
||||
});
|
||||
expect(model.primary).toBe("#123456");
|
||||
expect(model.bg).toBe("#f7f5f2");
|
||||
expect(model.headerStyle).toBe("plain");
|
||||
expect(model.logoImage).toBe("");
|
||||
});
|
||||
|
||||
it("R5-1 根治:paramValuesOf 只有「一个包」一个入参(无跨包 existing 通道),换包即取新包自身值", () => {
|
||||
expect(paramValuesOf.length).toBe(1);
|
||||
const nextPack = theme();
|
||||
nextPack.name = "another-pack";
|
||||
nextPack.params = nextPack.params.map(p => ({ ...p, defaultValue: p.type === "color" ? "#0a5c36" : p.defaultValue }));
|
||||
// 上一个包微调成 #123456 后切到未微调的新包:只能是新包默认值,旧包值无处带入
|
||||
expect(paramValuesOf(nextPack).primary).toBe("#0a5c36");
|
||||
expect(paramValuesOf(nextPack).primary).not.toBe("#123456");
|
||||
});
|
||||
|
||||
it("T-T-07: 站点级装拆随参数迁包退役(formModelFromTheme / merge…IntoConfigJson / …FromConfigJson 不再导出)", () => {
|
||||
const mod = themeParamForm as unknown as Record<string, unknown>;
|
||||
expect(mod.formModelFromTheme).toBeUndefined();
|
||||
expect(mod.mergeThemeParamsIntoConfigJson).toBeUndefined();
|
||||
expect(mod.themeParamsFromConfigJson).toBeUndefined();
|
||||
// 模块出口收敛为「纯 schema + 值校验」:不再感知 configJson
|
||||
expect(Object.keys(mod).sort()).toEqual(
|
||||
[
|
||||
"COLOR_RE",
|
||||
"KEY_RE",
|
||||
"effectiveValue",
|
||||
"hasParamType",
|
||||
"invalidParamKeys",
|
||||
"isValidParamValue",
|
||||
"paramValuesOf",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("T28: 生效值兜底 —— 合法显式值优先,非法/缺失回退默认", () => {
|
||||
const primary = theme().params.find(p => p.key === "primary")!;
|
||||
expect(effectiveValue(primary, { primary: "#123456" })).toBe("#123456");
|
||||
expect(effectiveValue(primary, { primary: "red;}" })).toBe("#b01a2e");
|
||||
expect(effectiveValue(primary, {})).toBe("#b01a2e");
|
||||
});
|
||||
|
||||
it("T29: 值校验 —— color 正则通过/拒绝", () => {
|
||||
@@ -88,45 +131,4 @@ describe("themeParamForm(方案 §6.3/§8.4)", () => {
|
||||
expect(bad).toContain("headerStyle");
|
||||
expect(bad).not.toContain("primary");
|
||||
});
|
||||
|
||||
it("T30: themeParams↔configJson 装拆 —— 合并保留其余键 + 丢弃未声明键", () => {
|
||||
const t = theme();
|
||||
const merged = mergeThemeParamsIntoConfigJson(
|
||||
'{"logo":"/upload/cms/logo.png","contact":"0571-88888888"}',
|
||||
t,
|
||||
{ primary: "#ff0000", headerStyle: "plain", zzhack: "zzvalue" },
|
||||
);
|
||||
const parsed = JSON.parse(merged);
|
||||
expect(parsed.logo).toBe("/upload/cms/logo.png");
|
||||
expect(parsed.contact).toBe("0571-88888888");
|
||||
expect(parsed.theme_params.primary).toBe("#ff0000");
|
||||
expect(parsed.theme_params.headerStyle).toBe("plain");
|
||||
expect(parsed.theme_params).not.toHaveProperty("zzhack"); // 未声明 key 丢弃(后端同语义)
|
||||
});
|
||||
|
||||
it("T30: 空/非法 configJson 兼容;无有效参数不写入 theme_params 键", () => {
|
||||
const t = theme();
|
||||
const merged = mergeThemeParamsIntoConfigJson("{bad json", t, {});
|
||||
expect(JSON.parse(merged)).toEqual({});
|
||||
|
||||
const merged2 = mergeThemeParamsIntoConfigJson(
|
||||
'{"theme_params":{"primary":"#b01a2e"},"logo":"x"}',
|
||||
t,
|
||||
{ headerStyle: "banner" },
|
||||
);
|
||||
const parsed2 = JSON.parse(merged2);
|
||||
expect(parsed2.logo).toBe("x");
|
||||
expect(parsed2.theme_params).toEqual({ headerStyle: "banner" }); // 旧的非法/未更新值被替换
|
||||
});
|
||||
|
||||
it("T30: configJson → themeParams 编辑回显(仅声明 key + 非法值丢弃)", () => {
|
||||
const t = theme();
|
||||
const model = themeParamsFromConfigJson(
|
||||
'{"theme_params":{"primary":"#b01a2e","zzhack":"x","headerStyle":"plain"}}',
|
||||
t,
|
||||
);
|
||||
expect(model.primary).toBe("#b01a2e");
|
||||
expect(model.headerStyle).toBe("plain");
|
||||
expect(model.zzhack).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+16
-62
@@ -1,10 +1,11 @@
|
||||
import { ThemeParamVo, ThemeVo } from "@/types/cms";
|
||||
|
||||
/**
|
||||
* themeParams 纯函数模块(方案 §6.3,评审 R10/R6):schema → 表单模型映射、
|
||||
* 值校验(color 正则 / select 枚举 / image 仅 /upload/**)、themeParams ↔ configJson 装拆。
|
||||
* 与后端合并语义(方案 §5.3)保持一致:仅替换 theme_params 键、保留其余键、丢弃未声明键。
|
||||
* vitest 单测对象(§8.4 T28-T30),不依赖组件挂载基建。
|
||||
* 主题参数纯函数模块(方案 §6.3 + 工作项 T):包级 `paramValues` → 表单模型、值校验
|
||||
* (color 正则 / select 枚举 / image 仅 /cms|/yschcms + /upload/** / text 长度)。
|
||||
* 工作项 T 起参数值归属皮肤包(`themes/.meta/{name}.json`),站点级 `config_json.theme_params`
|
||||
* 装拆函数(formModelFromTheme / mergeThemeParamsIntoConfigJson / themeParamsFromConfigJson)随之后端退役一并删除。
|
||||
* vitest 单测对象(§8.4 T28-T30、T-T-07/08),不依赖组件挂载基建。
|
||||
*/
|
||||
|
||||
/** color 合法值:#RGB/#RRGGBB/#RRGGBBAA(与后端 ThemeParam.COLOR_PATTERN 一致) */
|
||||
@@ -36,18 +37,24 @@ export function isValidParamValue(param: ThemeParamVo, value: string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 取参数生效值:显式值合法用之;否则回退 defaultValue(缺省也视为合法默认) */
|
||||
/** T28: 生效值兜底 —— 合法显式值优先,否则回退 defaultValue(缺省也视为合法默认) */
|
||||
export function effectiveValue(param: ThemeParamVo, model: FormModelValue): string {
|
||||
const raw = model[param.key];
|
||||
if (isValidParamValue(param, raw)) return raw as string;
|
||||
return param.defaultValue ?? "";
|
||||
}
|
||||
|
||||
/** T28: schema → 表单模型(声明过的 key 全部带上生效值) */
|
||||
export function formModelFromTheme(theme: ThemeVo, existing: FormModelValue = {}): FormModelValue {
|
||||
/**
|
||||
* T-T-08: 包级参数值 → 表单模型(工作项 T,方案 v1 §三)。
|
||||
* 值来源只有一个:该包自己的 `paramValues`(`.meta` 覆盖层);未微调的 key 回落 `params[].defaultValue`,
|
||||
* 非法值与未声明 key 丢弃(与后端 `ThemeInfo.filterValidValues` 同语义)。
|
||||
* 入参只有「一个包」——不存在传入上一个包值的通道,R5-1(换肤颜色继承)在函数签名层面即不成立。
|
||||
*/
|
||||
export function paramValuesOf(theme: ThemeVo): FormModelValue {
|
||||
const stored = theme.paramValues || {};
|
||||
const model: FormModelValue = {};
|
||||
theme.params.forEach(p => {
|
||||
const raw = existing[p.key];
|
||||
(theme.params || []).forEach(p => {
|
||||
const raw = stored[p.key];
|
||||
model[p.key] = isValidParamValue(p, raw) ? (raw as string) : (p.defaultValue ?? "");
|
||||
});
|
||||
return model;
|
||||
@@ -65,59 +72,6 @@ export function invalidParamKeys(theme: ThemeVo, model: FormModelValue): string[
|
||||
return result;
|
||||
}
|
||||
|
||||
/** T30a: 服务端合并语义(与后端 §5.3 一致)—— 仅替换 theme_params、保留其余键、丢弃未声明/非法值 */
|
||||
export function mergeThemeParamsIntoConfigJson(configJson: string | undefined | null,
|
||||
theme: ThemeVo,
|
||||
model: FormModelValue): string {
|
||||
let config: Record<string, unknown> = {};
|
||||
if (configJson && configJson.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(configJson);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
config = { ...parsed };
|
||||
}
|
||||
} catch {
|
||||
// 非合法 JSON 按空配置处理(与后端 ExtJsonUtils.parse 语义一致)
|
||||
}
|
||||
}
|
||||
const filtered: Record<string, string> = {};
|
||||
theme.params.forEach(p => {
|
||||
const v = model[p.key];
|
||||
if (isValidParamValue(p, v)) {
|
||||
filtered[p.key] = v as string;
|
||||
}
|
||||
});
|
||||
if (Object.keys(filtered).length === 0) {
|
||||
delete config.theme_params;
|
||||
} else {
|
||||
config.theme_params = filtered;
|
||||
}
|
||||
return JSON.stringify(config);
|
||||
}
|
||||
|
||||
/** T30b: configJson → themeParams(编辑回显:仅取声明过的 key,非法值丢弃) */
|
||||
export function themeParamsFromConfigJson(configJson: string | undefined | null,
|
||||
theme: ThemeVo): FormModelValue {
|
||||
const model: FormModelValue = {};
|
||||
if (!configJson) return model;
|
||||
try {
|
||||
const parsed = JSON.parse(configJson);
|
||||
const tp = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>).theme_params
|
||||
: undefined;
|
||||
if (tp && typeof tp === "object") {
|
||||
theme.params.forEach(p => {
|
||||
const raw = (tp as Record<string, unknown>)[p.key];
|
||||
const v = typeof raw === "string" ? raw : "";
|
||||
if (isValidParamValue(p, v)) model[p.key] = v;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 非合法 JSON 按无参数处理
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
/** 主题是否声明了某类型参数(组件渲染分支用) */
|
||||
export function hasParamType(theme: ThemeVo | undefined | null, type: string): boolean {
|
||||
return !!theme?.params?.some(p => p.type === type);
|
||||
|
||||
@@ -20,13 +20,19 @@ const linkTypeDict = [
|
||||
];
|
||||
const linkTypeText = (v: number) => linkTypeDict.find(i => i.value === v)?.label || "-";
|
||||
|
||||
/** 展示位(docs/12):home=首页轮播 / gallery=线上展厅图片画廊 */
|
||||
/** 展示位(docs/12;docs/20 F4 双 Tab 化):home=首页轮播 / gallery=线上展厅图片画廊 */
|
||||
const position = ref("home");
|
||||
/** 弹窗打开时的展示位锚点(F4:保存归属以打开时所在 Tab 为准,弹窗期间切换 Tab 不串数据) */
|
||||
const formPosition = ref("home");
|
||||
const positionDict = [
|
||||
{ value: "home", label: "首页轮播", tip: "轮播图设计尺寸建议 1920×560(皮肤包约定)" },
|
||||
{ value: "gallery", label: "线上展厅", tip: "画廊图片设计尺寸建议 800×520,多图横向轮播(炫彩校园皮肤消费)" },
|
||||
{ value: "home", label: "首页轮播", short: "轮播", addLabel: "新增轮播", tip: "轮播图设计尺寸建议 1920×560(皮肤包约定)" },
|
||||
{ value: "gallery", label: "线上展厅", short: "展厅图片", addLabel: "新增展厅图片", tip: "画廊图片设计尺寸建议 800×520,多图横向轮播(炫彩校园皮肤消费)" },
|
||||
// docs/20 D-3/D-6:新展示位(首页布局 featured_topics / events 区块消费)
|
||||
{ value: "topic", label: "专题大卡", short: "专题卡", addLabel: "新增专题卡", tip: "专题大卡图片建议 800×500;首页需添加「专题大卡」区块才会展示" },
|
||||
{ value: "event", label: "学术活动", short: "活动", addLabel: "新增活动", tip: "学术报告/活动海报位;首页需添加「学术日历」区块才会展示,按活动日期升序排列" },
|
||||
];
|
||||
const positionTip = computed(() => positionDict.find(p => p.value === position.value)?.tip || "");
|
||||
const formPositionMeta = computed(() => positionDict.find(p => p.value === formPosition.value)!);
|
||||
const modalTitle = computed(() => `${form.id ? "编辑" : "新增"}${formPositionMeta.value.short}`);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
@@ -91,6 +97,12 @@ const form = reactive({
|
||||
startTime: "" as string | "",
|
||||
endTime: "" as string | "",
|
||||
status: 1,
|
||||
// docs/20 D-3/D-6:topic/event 展示位扩展字段
|
||||
description: "",
|
||||
ctaText: "",
|
||||
ctaUrl: "",
|
||||
eventDate: "" as string | "",
|
||||
location: "",
|
||||
});
|
||||
|
||||
const rules = {
|
||||
@@ -119,7 +131,13 @@ function openCreate() {
|
||||
startTime: "",
|
||||
endTime: "",
|
||||
status: 1,
|
||||
description: "",
|
||||
ctaText: "",
|
||||
ctaUrl: "",
|
||||
eventDate: "",
|
||||
location: "",
|
||||
});
|
||||
formPosition.value = position.value;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -134,7 +152,13 @@ function openEdit(row: BannerVo) {
|
||||
startTime: row.startTime || "",
|
||||
endTime: row.endTime || "",
|
||||
status: row.status,
|
||||
description: row.description || "",
|
||||
ctaText: row.ctaText || "",
|
||||
ctaUrl: row.ctaUrl || "",
|
||||
eventDate: row.eventDate || "",
|
||||
location: row.location || "",
|
||||
});
|
||||
formPosition.value = position.value;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -154,7 +178,7 @@ async function save() {
|
||||
return;
|
||||
}
|
||||
if (!form.imageUrl) {
|
||||
Message.warning("请上传轮播图片");
|
||||
Message.warning("请上传图片");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
@@ -163,7 +187,8 @@ async function save() {
|
||||
.save({
|
||||
id: form.id,
|
||||
siteId: store.siteId!,
|
||||
position: position.value,
|
||||
// F4:保存归属以弹窗打开时所在 Tab 为准(弹窗期间切换 Tab 不串数据)
|
||||
position: formPosition.value,
|
||||
title: form.title,
|
||||
imageUrl: form.imageUrl,
|
||||
linkType: form.linkType,
|
||||
@@ -172,6 +197,12 @@ async function save() {
|
||||
startTime: form.startTime || undefined,
|
||||
endTime: form.endTime || undefined,
|
||||
status: form.status,
|
||||
// docs/20 D-3/D-6:topic/event 位扩展字段随行提交(其余位后端落库但不消费)
|
||||
description: form.description || undefined,
|
||||
ctaText: form.ctaText || undefined,
|
||||
ctaUrl: form.ctaUrl || undefined,
|
||||
eventDate: form.eventDate || undefined,
|
||||
location: form.location || undefined,
|
||||
})
|
||||
.send();
|
||||
Message.success("保存成功!");
|
||||
@@ -184,7 +215,7 @@ async function save() {
|
||||
|
||||
function doDelete(row: BannerVo) {
|
||||
MyConfirm({
|
||||
title: "删除轮播",
|
||||
title: `删除${formPositionMeta.value.short}`,
|
||||
msg: `确定删除「${row.title}」吗?`,
|
||||
async onOk() {
|
||||
await bannerApi.delete({ id: row.id!, siteId: store.siteId! }).send();
|
||||
@@ -204,34 +235,34 @@ watch(position, () => run());
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="openCreate">
|
||||
<Icon type="ios-add" />
|
||||
新增轮播
|
||||
</Button>
|
||||
<span class="size-tip fr">{{ positionTip }}</span>
|
||||
</div>
|
||||
<!-- 展示位是筛选维度不是操作,按 bsch 规范归搜索区(左侧下拉) -->
|
||||
<div class="search-wrap fix">
|
||||
<Select v-model="position" class="search-wrap_sel">
|
||||
<Option v-for="p in positionDict" :key="p.value" :value="p.value">{{ p.label }}</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<!-- F4(docs/20):首页轮播 / 线上展厅双 Tab——两个展示位尺寸与管理习惯不同,
|
||||
各自独立列表、独立新增按钮、独立尺寸提示;切换 Tab 列表即时重载(watch position) -->
|
||||
<Tabs v-model="position" class="banner-tabs">
|
||||
<TabPane v-for="p in positionDict" :key="p.value" :label="p.label" :name="p.value">
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="openCreate">
|
||||
<Icon type="ios-add" />
|
||||
{{ p.addLabel }}
|
||||
</Button>
|
||||
<span class="size-tip fr">{{ p.tip }}</span>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
<Table
|
||||
class="link-table table-bottom-box"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无轮播"
|
||||
:no-data-text="position === 'home' ? '暂无轮播' : '暂无展厅图片'"
|
||||
/>
|
||||
|
||||
<Modal v-model="modalVisible" :title="form.id ? '编辑轮播' : '新增轮播'" :mask-closable="false" width="560px">
|
||||
<Modal v-model="modalVisible" :title="modalTitle" :mask-closable="false" width="560px">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="100">
|
||||
<FormItem label="标题" prop="title">
|
||||
<Input v-model="form.title" :maxlength="200" placeholder="alt 文案/无图兜底" />
|
||||
</FormItem>
|
||||
<FormItem label="轮播图片" required>
|
||||
<FormItem :label="formPositionMeta.value.short" required>
|
||||
<Upload
|
||||
:action="uploadUrl() + '?siteId=' + store.siteId + '&scene=banner'"
|
||||
with-credentials
|
||||
@@ -264,6 +295,29 @@ watch(position, () => run());
|
||||
>
|
||||
<Input v-model="form.linkValue" :maxlength="64" :placeholder="form.linkType === 1 ? '文章 ID' : '栏目 ID'" />
|
||||
</FormItem>
|
||||
<!-- docs/20 D-3:专题大卡扩展字段(仅 topic 位) -->
|
||||
<template v-if="formPosition === 'topic'">
|
||||
<FormItem label="专题描述">
|
||||
<Input v-model="form.description" type="textarea" :rows="2" :maxlength="500"
|
||||
placeholder="卡片上一句话描述(可选)" />
|
||||
</FormItem>
|
||||
<FormItem label="按钮文字">
|
||||
<Input v-model="form.ctaText" :maxlength="20" placeholder="如「进入专题」(可选,不填不显示按钮)" />
|
||||
</FormItem>
|
||||
<FormItem label="按钮地址">
|
||||
<Input v-model="form.ctaUrl" :maxlength="500" placeholder="https://… 或站内 / 路径(可选)" />
|
||||
</FormItem>
|
||||
</template>
|
||||
<!-- docs/20 D-6:学术活动扩展字段(仅 event 位) -->
|
||||
<template v-if="formPosition === 'event'">
|
||||
<FormItem label="活动日期">
|
||||
<DatePicker type="date" v-model="form.eventDate" format="yyyy-MM-dd"
|
||||
placeholder="大日期块展示(可选)" style="width: 220px" />
|
||||
</FormItem>
|
||||
<FormItem label="活动地点">
|
||||
<Input v-model="form.location" :maxlength="200" placeholder="如 图书馆报告厅(可选)" />
|
||||
</FormItem>
|
||||
</template>
|
||||
<FormItem label="排序">
|
||||
<InputNumber v-model="form.sortNo" :min="0" />
|
||||
</FormItem>
|
||||
|
||||
@@ -8,7 +8,7 @@ import CategoryTreePanel from "@/components/CategoryTreePanel.vue";
|
||||
import CategorySortModal from "@/components/CategorySortModal.vue";
|
||||
import BatchAddCategory from "@/components/BatchAddCategory.vue";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import ArticleListPanel from "@/components/article/ArticleListPanel.vue";
|
||||
import CategoryContentSummary from "@/components/category/CategoryContentSummary.vue";
|
||||
import { categoryApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
@@ -17,7 +17,9 @@ import { rootLabelOf, wrapTreeWithRoot } from "@/utils/categoryTree";
|
||||
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
|
||||
|
||||
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除)。
|
||||
B5 栏目工作台:右侧类型化面板(列表=文章表格 / 单页=富文本预览 / 外链=直达)。 */
|
||||
B5 栏目工作台:右侧类型化面板(单页=富文本预览 / 外链=直达)。
|
||||
P-D1 内容管理 IA 切分:列表型栏目右侧降级为「栏目内容概览」(只读统计 + 最近 5 条),
|
||||
文章的检索与操作统一在 /article(内容视图)。 */
|
||||
const store = useCmsStore();
|
||||
|
||||
const refTreePanel = useTemplateRef("refTreePanel");
|
||||
@@ -35,12 +37,8 @@ const typeDict = [
|
||||
];
|
||||
const typeLabel = (t?: number) => typeDict.find(i => i.value === t)?.label || "未知";
|
||||
|
||||
/** 首页展示形态(docs/12;仅列表型栏目有意义,空=主题默认卡) */
|
||||
const homeStyleDict = [
|
||||
{ value: "", label: "默认卡片" },
|
||||
{ value: "news_grid", label: "双栏新闻卡" },
|
||||
{ value: "media_focus", label: "媒体聚焦列表" },
|
||||
];
|
||||
// F3(docs/20 §四):「首页展示形态」配置入口已收敛到「站点设置 → 首页布局」区块属性,
|
||||
// 栏目管理只管数据(名称/类型/排序/状态/SEO);存量 homeStyle 值由后端保存时保留。
|
||||
|
||||
const sortModal = ref(false);
|
||||
const sortParentId = ref<number | null>(null);
|
||||
@@ -61,7 +59,6 @@ const form = reactive({
|
||||
type: 1,
|
||||
linkUrl: "",
|
||||
content: "",
|
||||
homeStyle: "",
|
||||
sortNo: 0,
|
||||
seoTitle: "",
|
||||
seoDescription: "",
|
||||
@@ -156,7 +153,6 @@ async function showAddNode() {
|
||||
form.type = 1;
|
||||
form.linkUrl = "";
|
||||
form.content = "";
|
||||
form.homeStyle = "";
|
||||
form.sortNo = 0;
|
||||
form.seoTitle = "";
|
||||
form.seoDescription = "";
|
||||
@@ -179,7 +175,6 @@ function showEditNode() {
|
||||
type: node.type ?? 1,
|
||||
linkUrl: node.linkUrl || "",
|
||||
content: node.content || "",
|
||||
homeStyle: node.homeStyle || "",
|
||||
sortNo: node.sortNo ?? 0,
|
||||
seoTitle: node.seoTitle || "",
|
||||
seoDescription: node.seoDescription || "",
|
||||
@@ -261,7 +256,6 @@ async function save() {
|
||||
type: form.type,
|
||||
linkUrl: form.linkUrl || undefined,
|
||||
content: form.content || undefined,
|
||||
homeStyle: form.type === 1 && form.homeStyle ? form.homeStyle : undefined,
|
||||
sortNo: form.sortNo,
|
||||
seoTitle: form.seoTitle || undefined,
|
||||
seoDescription: form.seoDescription || undefined,
|
||||
@@ -440,13 +434,13 @@ function openLink(url: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表型:本栏目文章管理(联动仅经 categoryId watch,拖拽排序不触发) -->
|
||||
<ArticleListPanel
|
||||
<!-- 列表型:只读内容概览(P-D1 结构/内容视图切分;文章操作统一在 /article) -->
|
||||
<CategoryContentSummary
|
||||
v-if="selected.type === 1"
|
||||
class="panel-body"
|
||||
:category-id="selected.id"
|
||||
:extra-height="78"
|
||||
fixed-category
|
||||
:category-name="selected.name"
|
||||
:tree="treeData"
|
||||
/>
|
||||
|
||||
<!-- 单页型:正文渲染预览 + 元信息 + 编辑入口 -->
|
||||
@@ -547,12 +541,6 @@ function openLink(url: string) {
|
||||
<div v-else class="editor-placeholder">编辑器加载中……</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem v-if="form.type === 1" label="首页展示形态">
|
||||
<Select v-model="form.homeStyle" placeholder="默认卡片">
|
||||
<Option v-for="s in homeStyleDict" :key="s.value" :value="s.value">{{ s.label }}</Option>
|
||||
</Select>
|
||||
<p class="form-tip">首页该栏目区块的呈现形态;「双栏新闻卡/媒体聚焦列表」需皮肤包支持(如炫彩校园),旧主题按默认卡展示。也可在「站点设置 → 首页布局」里按区块单独指定形态(以那里为准)。</p>
|
||||
</FormItem>
|
||||
|
||||
<Divider size="small" orientation="left">SEO 设置</Divider>
|
||||
<FormItem label="SEO 标题">
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Utils from "@/utils/index";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { categoryApi, personApi, uploadUrl } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { CategoryTreeNode, PersonVo } from "@/types/cms";
|
||||
|
||||
/**
|
||||
* 名师风采人物管理(docs/20 D-7,people 区块数据源)。
|
||||
* 站点级 CRUD:姓名/头衔/简介/头像(附件上传 scene=person)/所属栏目(分组)/排序/状态。
|
||||
* 入口:首页布局编辑器 people 区块属性面板「维护人物数据」直达;菜单挂靠见路由 meta(modId 3 轮播管理族)。
|
||||
*/
|
||||
const store = useCmsStore();
|
||||
|
||||
const rows = ref<PersonVo[]>([]);
|
||||
const loading = ref(false);
|
||||
const tree = ref<CategoryTreeNode[]>([]);
|
||||
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true });
|
||||
|
||||
/** 树拍平(含虚拟根排除) */
|
||||
const flat = computed(() => {
|
||||
const out: { id: number; name: string; depth: number }[] = [];
|
||||
const walk = (nodes: CategoryTreeNode[], depth: number) => {
|
||||
(nodes || []).forEach(n => {
|
||||
out.push({ id: n.id as number, name: n.name, depth });
|
||||
walk(n.children || [], depth + 1);
|
||||
});
|
||||
};
|
||||
walk(tree.value, 0);
|
||||
return out;
|
||||
});
|
||||
const nameOfCategory = (id?: number) => (id == null ? "—" : flat.value.find(c => c.id === id)?.name || `#${id}`);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "头像",
|
||||
key: "avatar",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
p.row.avatar
|
||||
? h("img", { src: p.row.avatar, style: { width: "48px", height: "48px", borderRadius: "50%", objectFit: "cover" } })
|
||||
: h(
|
||||
"span",
|
||||
{ style: { display: "inline-flex", alignItems: "center", justifyContent: "center", width: "48px", height: "48px", borderRadius: "50%", background: "#e8f1ff", color: "#2d8cf0", fontWeight: 600 } },
|
||||
(p.row.name || "?").slice(0, 1),
|
||||
),
|
||||
},
|
||||
{ title: "姓名", key: "name", width: 120 },
|
||||
{ title: "头衔/职称", key: "title", width: 160, tooltip: true, render: (h: any, p: any) => h("span", p.row.title || "—") },
|
||||
{ title: "简介", key: "bio", minWidth: 220, tooltip: true, render: (h: any, p: any) => h("span", p.row.bio || "—") },
|
||||
{
|
||||
title: "所属栏目",
|
||||
key: "categoryId",
|
||||
width: 140,
|
||||
render: (h: any, p: any) => h("span", nameOfCategory(p.row.categoryId)),
|
||||
},
|
||||
{ title: "排序", key: "sortNo", width: 80, align: "center" },
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h(Tag, { color: p.row.status === 1 ? "success" : "default" }, () => (p.row.status === 1 ? "启用" : "停用")),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 120,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
render: (h: any, p: any) => [
|
||||
Utils.operatingHandle(h, "编辑", "icon-edit", () => openEdit(p.row)),
|
||||
Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row)),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
const formRef = ref();
|
||||
const form = reactive({
|
||||
id: undefined as number | undefined,
|
||||
name: "",
|
||||
title: "",
|
||||
bio: "",
|
||||
avatar: "",
|
||||
categoryId: undefined as number | undefined,
|
||||
sortNo: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "姓名不得为空", trigger: "blur" }],
|
||||
};
|
||||
|
||||
async function run() {
|
||||
if (!store.siteId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = (await personApi.list(store.siteId).send()) || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTree() {
|
||||
if (!store.siteId) return;
|
||||
try {
|
||||
tree.value = (await categoryApi.tree(store.siteId).send()) || [];
|
||||
} catch {
|
||||
tree.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
name: "",
|
||||
title: "",
|
||||
bio: "",
|
||||
avatar: "",
|
||||
categoryId: undefined,
|
||||
sortNo: 0,
|
||||
status: 1,
|
||||
});
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: PersonVo) {
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
title: row.title || "",
|
||||
bio: row.bio || "",
|
||||
avatar: row.avatar || "",
|
||||
categoryId: row.categoryId,
|
||||
sortNo: row.sortNo,
|
||||
status: row.status,
|
||||
});
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function avatarUploaded(res: any) {
|
||||
if (res.code === 0 && res.data) {
|
||||
form.avatar = res.data.url;
|
||||
} else {
|
||||
Message.error(res.msg || "头像上传失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) {
|
||||
Message.warning("请填写红色星号内容!"); // bsch §4
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await personApi
|
||||
.save({
|
||||
id: form.id,
|
||||
siteId: store.siteId!,
|
||||
name: form.name,
|
||||
title: form.title || undefined,
|
||||
bio: form.bio || undefined,
|
||||
avatar: form.avatar || undefined,
|
||||
categoryId: form.categoryId || undefined,
|
||||
sortNo: form.sortNo,
|
||||
status: form.status,
|
||||
})
|
||||
.send();
|
||||
Message.success("保存成功!");
|
||||
modalVisible.value = false;
|
||||
run();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function doDelete(row: PersonVo) {
|
||||
MyConfirm({
|
||||
title: "删除人物",
|
||||
msg: `确定删除「${row.name}」吗?首页名师风采区块将不再展示该人物。`,
|
||||
async onOk() {
|
||||
await personApi.delete({ id: row.id!, siteId: store.siteId! }).send();
|
||||
Message.success("操作成功!");
|
||||
run();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
run();
|
||||
loadTree();
|
||||
});
|
||||
|
||||
// 站点上下文切换即时重载(docs/09 §3.3)
|
||||
watch(() => store.siteId, () => {
|
||||
run();
|
||||
loadTree();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="openCreate">
|
||||
<Icon type="ios-add" />
|
||||
新增人物
|
||||
</Button>
|
||||
<span class="size-tip fr">名师风采数据源:首页布局「名师风采」区块按所属栏目过滤展示(未分组 = 全部人物区块不选栏目时可见)</span>
|
||||
</div>
|
||||
<Table
|
||||
class="link-table table-bottom-box"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无人物"
|
||||
/>
|
||||
|
||||
<Modal v-model="modalVisible" :title="form.id ? '编辑人物' : '新增人物'" :mask-closable="false" width="560px">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="100">
|
||||
<FormItem label="姓名" prop="name">
|
||||
<Input v-model="form.name" :maxlength="100" placeholder="如 王立群" />
|
||||
</FormItem>
|
||||
<FormItem label="头衔/职称">
|
||||
<Input v-model="form.title" :maxlength="100" placeholder="如 教授、长江学者" />
|
||||
</FormItem>
|
||||
<FormItem label="简介">
|
||||
<Input v-model="form.bio" type="textarea" :rows="2" :maxlength="500" placeholder="一句话简介(卡片下方展示)" />
|
||||
</FormItem>
|
||||
<FormItem label="头像">
|
||||
<Upload
|
||||
:action="uploadUrl() + '?siteId=' + store.siteId + '&scene=person'"
|
||||
with-credentials
|
||||
:show-upload-list="false"
|
||||
:format="['jpg', 'jpeg', 'png', 'webp']"
|
||||
:on-success="avatarUploaded"
|
||||
>
|
||||
<Button icon="ios-cloud-upload-outline">上传头像</Button>
|
||||
</Upload>
|
||||
<div v-if="form.avatar" class="avatar-preview-row">
|
||||
<img :src="form.avatar" class="avatar-preview" />
|
||||
<Button size="small" type="text" class="avatar-clear" @click="form.avatar = ''">清除</Button>
|
||||
</div>
|
||||
<p class="form-tip">建议正方形照片(如 300×300);不上传时首页显示姓名首字圆形徽章</p>
|
||||
</FormItem>
|
||||
<FormItem label="所属栏目">
|
||||
<Select v-model="form.categoryId" clearable placeholder="不选 = 未分组(people 区块选栏目时不会出现)">
|
||||
<Option v-for="c in flat" :key="c.id" :value="c.id">
|
||||
<span :style="{ paddingLeft: c.depth * 16 + 'px' }">{{ c.name }}</span>
|
||||
</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="排序">
|
||||
<InputNumber v-model="form.sortNo" :min="0" />
|
||||
</FormItem>
|
||||
<FormItem label="状态">
|
||||
<RadioGroup v-model="form.status">
|
||||
<Radio :label="1">启用</Radio>
|
||||
<Radio :label="0">停用</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="modalVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="save">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.size-tip {
|
||||
color: #999;
|
||||
line-height: 40px;
|
||||
}
|
||||
.avatar-preview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
.avatar-preview {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 1px solid #e5e6eb;
|
||||
}
|
||||
.avatar-clear {
|
||||
color: #999;
|
||||
&:hover {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
}
|
||||
.form-tip {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
+250
-29
@@ -8,13 +8,15 @@ import { adminPathOf, portalUrlOf } from "@/utils/sitePath";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { SiteVo, ThemeVo } from "@/types/cms";
|
||||
import SiteCard from "./components/SiteCard.vue";
|
||||
import SiteMainCard from "./components/SiteMainCard.vue";
|
||||
|
||||
/**
|
||||
* 站点管理列表页(docs/16 主线A 收敛后):列表只做表格——
|
||||
* 新建 → 站点设置中心创建模式(site/new);行「编辑」→ 站点设置中心(site/setting/:id)。
|
||||
* 分区卡片/皮肤/首页布局等全部配置能力收敛到 SiteSetting.vue(原编辑弹窗与三步向导退役)。
|
||||
* 站点工作台(docs/22 v3,原型 docs/21 屏1):按学校分组 = 主站宽卡 + 院系子站网格,
|
||||
* 站点父子层级用版式分区表达(不靠旧表格「所属主站」列文字);卡片 ⇄ 表格切换 + 名称/编码搜索。
|
||||
* 新建 → 站点设置中心创建模式(site/new);卡/行「站点设置」→ site/setting/:id(docs/16 主线A 收敛不变)。
|
||||
* 学校化管理(docs/09 §3.3):默认只显示当前学校站点;超管可经工作台「全部站点总览」(?scope=all)。
|
||||
* 行「进入管理」= 院系子站切换入口;跨校行先重建学校上下文;当前站点行带「当前」标记。
|
||||
* 「进入管理」= 院系子站切换入口;跨校行先重建学校上下文;当前站带「当前」徽章且按钮禁用。
|
||||
*/
|
||||
const store = useCmsStore();
|
||||
const route = useRoute();
|
||||
@@ -39,37 +41,91 @@ const themeMeta = computed(() => {
|
||||
return map;
|
||||
});
|
||||
|
||||
/** 视图模式(docs/22 Q2:保留表格,批量/密集场景) */
|
||||
const view = ref<"card" | "table">("card");
|
||||
/** 搜索关键字:按 站点名称 / 编码 即时过滤(站点多了找不到的痛点) */
|
||||
const keyword = ref("");
|
||||
|
||||
/** 学校分组:每组 = 主站宽卡 + 该主站的院系子站网格。
|
||||
* 单校视角只有 1 组;超管 ?scope=all 时按 schId 分组依次排列。 */
|
||||
interface SiteGroup {
|
||||
schKey: string;
|
||||
schName: string;
|
||||
main: SiteVo | null;
|
||||
subs: SiteVo[];
|
||||
/** 子站总数(过滤前):主站卡的「子站」统计要的是真实规模,不随搜索变化 */
|
||||
rawSubs: number;
|
||||
}
|
||||
|
||||
const groups = computed<SiteGroup[]>(() => {
|
||||
const kw = keyword.value.trim().toLowerCase();
|
||||
const match = (s: SiteVo) =>
|
||||
!kw || s.name.toLowerCase().includes(kw) || String(s.code || "").toLowerCase().includes(kw);
|
||||
|
||||
const map = new Map<string, SiteGroup>();
|
||||
rows.value.forEach(s => {
|
||||
const key = String(s.schId ?? s.schName ?? "0");
|
||||
let g = map.get(key);
|
||||
if (!g) {
|
||||
g = { schKey: key, schName: s.schName || "", main: null, subs: [], rawSubs: 0 };
|
||||
map.set(key, g);
|
||||
}
|
||||
if (!s.parentSiteId) {
|
||||
g.main = s;
|
||||
} else {
|
||||
g.rawSubs += 1;
|
||||
// 子站按关键字过滤;但组的主站卡始终保留作层级锚点(不因主站名不匹配而掉链)
|
||||
if (match(s)) g.subs.push(s);
|
||||
}
|
||||
});
|
||||
|
||||
// 搜索态:既无命中子站、主站自身也不匹配的组整组隐去
|
||||
return [...map.values()].filter(g => !kw || g.subs.length > 0 || (g.main && match(g.main)));
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ title: "站点编码", key: "code", width: 140 },
|
||||
{
|
||||
title: "站点名称",
|
||||
key: "name",
|
||||
minWidth: 200,
|
||||
minWidth: 220,
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) =>
|
||||
h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [
|
||||
// 层级徽章前置:主站/院系一眼可辨(取代原本只靠「所属主站」列文字)
|
||||
h(
|
||||
"span",
|
||||
{ class: p.row.parentSiteId ? "tier-bdg sub" : "tier-bdg main" },
|
||||
p.row.parentSiteId ? "院系" : "主站"
|
||||
),
|
||||
h("span", p.row.name),
|
||||
// 当前操作站点标记:壳面包屑校名后缀同源(store.currentSite)
|
||||
p.row.id === store.siteId ? h(Tag, { color: "success" }, () => "当前") : null,
|
||||
p.row.status !== 1 ? h(Tag, { color: "default" }, () => "停用") : null,
|
||||
]),
|
||||
},
|
||||
{ title: "站点编码", key: "code", width: 120 },
|
||||
{ title: "学校", key: "schName", width: 150, tooltip: true },
|
||||
{
|
||||
title: "所属主站",
|
||||
key: "parentSiteId",
|
||||
width: 150,
|
||||
width: 160,
|
||||
tooltip: true,
|
||||
render: (h: any, p: any) => h("span", p.row.parentSiteId ? p.row.parentName || `ID ${p.row.parentSiteId}` : "顶层站点"),
|
||||
render: (h: any, p: any) =>
|
||||
h("span", p.row.parentSiteId ? p.row.parentName || `ID ${p.row.parentSiteId}` : "顶层站点"),
|
||||
},
|
||||
{
|
||||
// 200px:让「缩略图 + 名称(包名)」单行放下,消除原 150px 下的 3 行竖排
|
||||
title: "皮肤包",
|
||||
key: "theme",
|
||||
width: 150,
|
||||
width: 200,
|
||||
render: (h: any, p: any) => {
|
||||
const t = themeMeta.value[p.row.theme || "default"];
|
||||
return h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [
|
||||
return h("span", { style: "display:inline-flex;align-items:center;gap:7px;white-space:nowrap" }, [
|
||||
t
|
||||
? h("img", { src: assetUrl(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"),
|
||||
]);
|
||||
@@ -82,19 +138,17 @@ const columns = [
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h(Tag, { color: p.row.status === 1 ? "success" : "default" }, () =>
|
||||
p.row.status === 1 ? "启用" : "停用"
|
||||
),
|
||||
h(Tag, { color: p.row.status === 1 ? "success" : "default" }, () => (p.row.status === 1 ? "启用" : "停用")),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 150,
|
||||
width: 200,
|
||||
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", () => openSetting(p.row)),
|
||||
Utils.operatingHandle(h, "进入管理", "icon-apps", () => enterManage(p.row), p.row.id === store.siteId || p.row.status !== 1),
|
||||
Utils.operatingHandle(h, "站点设置", "icon-edit", () => openSetting(p.row)),
|
||||
Utils.operatingHandle(h, "门户", "icon-browse", () => openPortal(p.row)),
|
||||
],
|
||||
},
|
||||
@@ -187,19 +241,186 @@ watch(() => store.currentSchool?.schId, () => run());
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="fix" v-if="scopeAll">
|
||||
<Alert type="info" show-icon style="width: 100%">
|
||||
全局站点视图:显示全部学校的站点(含各校主站与院系子站)。
|
||||
<!-- 超管全局视图(?scope=all):按学校分组,每组 = 主站宽卡 + 子站网格 -->
|
||||
<Alert v-if="scopeAll" type="info" show-icon style="width: 100%; margin-bottom: 12px">
|
||||
全局站点视图:按学校分组显示全部站点(含各校主站与院系子站)。
|
||||
<a href="javascript:void(0);" @click="goBackWorkbench">返回学校工作台</a>
|
||||
</Alert>
|
||||
</div>
|
||||
<div class="fix mb-12" v-if="canCreateSite">
|
||||
<Button class="fl" type="primary" size="large" @click="openCreate">
|
||||
<Icon type="ios-add" />
|
||||
{{ isSchoolAdmin ? "新建院系子站" : "新建站点" }}
|
||||
</Button>
|
||||
</div>
|
||||
<Table class="link-table table-bottom-box" :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无站点" />
|
||||
|
||||
<!-- 工具行:新建 + 卡片/表格切换 + 搜索 -->
|
||||
<div class="wb-toolbar mb-12">
|
||||
<Button v-if="canCreateSite" type="primary" @click="openCreate">
|
||||
<Icon type="ios-add" />
|
||||
{{ isSchoolAdmin ? "新建院系子站" : "新建站点" }}
|
||||
</Button>
|
||||
<div class="seg">
|
||||
<button type="button" :class="{ on: view === 'card' }" @click="view = 'card'">卡片</button>
|
||||
<button type="button" :class="{ on: view === 'table' }" @click="view = 'table'">表格</button>
|
||||
</div>
|
||||
<div class="seg-search">
|
||||
<Input v-model="keyword" placeholder="搜索站点名称 / 编码" clearable style="width: 220px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Spin v-if="loading" size="large" fix />
|
||||
|
||||
<template v-else>
|
||||
<!-- 卡片视图:主站宽卡 + 院系子站网格 -->
|
||||
<div v-if="view === 'card'">
|
||||
<template v-if="groups.length">
|
||||
<section v-for="g in groups" :key="g.schKey" class="site-group">
|
||||
<h3 v-if="scopeAll && (g.schName || g.main?.name)" class="group-cap">
|
||||
{{ g.schName || g.main?.name }}
|
||||
</h3>
|
||||
|
||||
<SiteMainCard
|
||||
v-if="g.main"
|
||||
:site="g.main"
|
||||
:is-current="g.main.id === store.siteId"
|
||||
:theme="themeMeta[g.main.theme || 'default']"
|
||||
:sub-count="g.rawSubs"
|
||||
@enter="enterManage"
|
||||
@setting="openSetting"
|
||||
@portal="openPortal"
|
||||
/>
|
||||
|
||||
<div class="zone-cap">
|
||||
<h3>院系子站</h3>
|
||||
<span class="cnt">{{ g.subs.length }} 个</span>
|
||||
</div>
|
||||
|
||||
<div v-if="g.subs.length" class="card-grid">
|
||||
<SiteCard
|
||||
v-for="s in g.subs"
|
||||
:key="s.id"
|
||||
:site="s"
|
||||
:is-current="s.id === store.siteId"
|
||||
:theme="themeMeta[s.theme || 'default']"
|
||||
@enter="enterManage"
|
||||
@setting="openSetting"
|
||||
@portal="openPortal"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p v-if="keyword">没有匹配「{{ keyword }}」的院系子站。</p>
|
||||
<p v-else>本校暂无院系子站。</p>
|
||||
<Button v-if="canCreateSite && !keyword" type="primary" @click="openCreate">
|
||||
<Icon type="ios-add" />
|
||||
新建院系子站
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<div v-else class="empty-state">
|
||||
<p v-if="keyword">没有匹配「{{ keyword }}」的站点。</p>
|
||||
<p v-else>暂无站点,请联系平台管理员在学校工作台添加。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格视图:保留批量速览(列已重排) -->
|
||||
<Table
|
||||
v-else
|
||||
class="link-table table-bottom-box"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无站点"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wb-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
/* 卡片 ⇄ 表格 视图切换(原型同款分段控件) */
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
color: #515a6e;
|
||||
font-family: inherit;
|
||||
&.on {
|
||||
background: #eff4ff;
|
||||
color: #4465d4;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
.seg-search {
|
||||
margin-left: auto;
|
||||
}
|
||||
.site-group + .site-group {
|
||||
margin-top: 34px;
|
||||
padding-top: 26px;
|
||||
border-top: 1px solid #eef0f3;
|
||||
}
|
||||
.group-cap {
|
||||
margin: 0 0 14px;
|
||||
font-size: 15px;
|
||||
color: #17233d;
|
||||
}
|
||||
.zone-cap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 12px;
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #17233d;
|
||||
}
|
||||
.cnt {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
}
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(276px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.empty-state {
|
||||
border: 1px dashed #dcdee2;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 44px 0;
|
||||
text-align: center;
|
||||
color: #808695;
|
||||
p {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
/* 表格视图的层级徽章(render 出的 span,scoped 属性会下发到该节点) */
|
||||
:deep(.tier-bdg) {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
&.main {
|
||||
background: #577afb;
|
||||
}
|
||||
&.sub {
|
||||
background: #ff9900;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+347
-303
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { siteApi, themeApi } from "@/api/module/cms";
|
||||
@@ -7,22 +7,20 @@ import config from "@/assets/config/host.config";
|
||||
import { portalUrlOf, adminPathOf, previewPortalUrl } from "@/utils/sitePath";
|
||||
import { useCmsStore } from "@/store";
|
||||
import { SiteVo, ThemeVo } from "@/types/cms";
|
||||
import { FormModelValue, formModelFromTheme, mergeThemeParamsIntoConfigJson, themeParamsFromConfigJson } from "@/utils/themeParamForm";
|
||||
import { resolveEditableTheme } from "@/utils/themeEdit";
|
||||
import { mergeConfigPatch } from "@/utils/siteConfigForm";
|
||||
import ThemePicker from "@/components/theme/ThemePicker.vue";
|
||||
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
|
||||
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
|
||||
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
|
||||
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
|
||||
|
||||
/**
|
||||
* 站点设置中心(docs/16 主线A 整页化):编辑/新增站点从模态框/三步向导 → 完整页面。
|
||||
* projman 模式:锚点导航 + 分区卡片(card-header/body/footer 三段式),每分区独立保存。
|
||||
* 单一 configJson 事实源(§3.5):各分区编辑器只读写自己的键空间(SiteConfigForm 分区作用域
|
||||
* + HomeLayoutEditor 仅动 home_layout 键),「仅保存本分区」= 校验本分区 → 当前 configJson 全量提交;
|
||||
* 其他分区未保存的修改仍在内存里,下次任一分区保存会一并带上(卡片 footer 已注明)。
|
||||
* 创建模式(site/new,D5 向导退役):仅①基本信息②皮肤与外观可编辑,③~⑦锁定置灰,
|
||||
* 创建成功原地 replace 到 site/setting/{id} 解锁全部分区。
|
||||
* 站点设置中心(docs/16 主线A 整页化 → docs/20 F1 Tab 化 + 渐进式向导):
|
||||
* 编辑模式 = 顶部 7 Tab(基本信息/皮肤与外观/首页布局/基础展示/页脚信息/SEO 与域名/自定义样式),
|
||||
* 每个 Tab 独立表单、独立 dirty 状态、独立保存——保存只合并本 Tab 的键空间进
|
||||
* lastSavedConfigJson(服务端事实源),其他 Tab 的未保存修改既不丢失也不被连带提交。
|
||||
* 新建模式 = 渐进式向导:第一步只填基本信息,创建成功自动进入第二步(选皮肤),
|
||||
* 完成后提示并展开全部 Tab;不再有任何「创建站点后开放」灰态锁定。
|
||||
*/
|
||||
const store = useCmsStore();
|
||||
const route = useRoute();
|
||||
@@ -33,7 +31,6 @@ const isCreate = computed(() => siteIdParam.value == null);
|
||||
|
||||
/** B1 建站权限拆分:校级(学校上下文内)可自助创建院系子站;超管行为不变 */
|
||||
const isSchoolAdmin = computed(() => !store.isSuperAdmin && !!store.currentSchool);
|
||||
const canCreateSite = computed(() => store.isSuperAdmin || isSchoolAdmin.value);
|
||||
/** 校级锁定在本校主站下建子站(原向导 lockParentId 语义) */
|
||||
const lockParentId = computed(() => (isCreate.value && isSchoolAdmin.value ? store.currentSchool?.mainSiteId : undefined));
|
||||
|
||||
@@ -43,23 +40,26 @@ const row = ref<SiteVo | null>(null);
|
||||
const sites = ref<SiteVo[]>([]);
|
||||
const themes = ref<ThemeVo[]>([]);
|
||||
|
||||
/** 分区清单(锚点导航;locked = 创建模式锁定) */
|
||||
const sections = [
|
||||
{ key: "basic", title: "基本信息", desc: "编码 / 名称 / 归属 / 状态" },
|
||||
{ key: "theme", title: "皮肤与外观", desc: "风格选择与微调" },
|
||||
{ key: "layout", title: "首页布局", desc: "区块序列编排", locked: true },
|
||||
{ key: "display", title: "基础展示", desc: "品牌标识与图片" },
|
||||
{ key: "footer", title: "页脚信息", desc: "联系方式与社媒" },
|
||||
{ key: "seo", title: "SEO 与域名", desc: "收录与独立域名" },
|
||||
{ key: "extras", title: "扩展参数", desc: "预留键位", locked: true },
|
||||
];
|
||||
const activeSection = ref("basic");
|
||||
let observer: IntersectionObserver | null = null;
|
||||
// ===== 向导(F1):1=填基本信息(创建模式);2=选皮肤(创建成功后);0=常规编辑 =====
|
||||
const wizardStep = ref(isCreate.value ? 1 : 0);
|
||||
|
||||
/** 单一 configJson 事实源(§3.5) */
|
||||
const configJson = ref("");
|
||||
// ===== Tab 结构(F1:7 Tab;自定义样式独立成 Tab)=====
|
||||
const TABS = [
|
||||
{ key: "basic", title: "基本信息" },
|
||||
{ key: "theme", title: "皮肤与外观" },
|
||||
{ key: "layout", title: "首页布局" },
|
||||
{ key: "display", title: "基础展示" },
|
||||
{ key: "footer", title: "页脚信息" },
|
||||
{ key: "seo", title: "SEO 与域名" },
|
||||
{ key: "css", title: "自定义样式" },
|
||||
] as const;
|
||||
type TabKey = (typeof TABS)[number]["key"];
|
||||
const activeTab = ref<string>("basic");
|
||||
|
||||
/** ① 基本信息分区 */
|
||||
/** 服务端事实源:最近一次保存成功(或载入)时的 configJson —— 各 Tab 保存以此为基础做增量合并 */
|
||||
const lastSavedConfigJson = ref("");
|
||||
|
||||
/** ① 基本信息表单 */
|
||||
const basicRef = ref();
|
||||
const basic = reactive({
|
||||
id: undefined as number | undefined,
|
||||
@@ -71,22 +71,36 @@ const basic = reactive({
|
||||
status: 1,
|
||||
});
|
||||
|
||||
/** ② 皮肤与外观分区 */
|
||||
/** ② 皮肤与外观(工作项 T:参数微调已迁皮肤包管理,站点只负责选哪套包) */
|
||||
const theme = ref("default");
|
||||
const themeUnregistered = ref(false);
|
||||
const themeParams = ref<FormModelValue>({});
|
||||
const customCss = ref("");
|
||||
const previewVisible = ref(false);
|
||||
const editTheme = computed(() => themes.value.find(t => t.name === theme.value));
|
||||
|
||||
/** ③~⑦ 分区表单 ref(分区独立校验) */
|
||||
const layoutEditorRef = ref();
|
||||
const displayFormRef = ref();
|
||||
const footerFormRef = ref();
|
||||
const seoFormRef = ref();
|
||||
const extrasFormRef = ref();
|
||||
/** ⑦ 自定义样式(F1:独立 Tab,config_json.custom_css,服务端黑名单强校验) */
|
||||
const customCss = ref("");
|
||||
|
||||
/** ③~⑥ 分区表单:各持独立模型(seed 后不再互相同步,保存各自合并) */
|
||||
const layoutEditorRef = ref();
|
||||
const layoutModel = ref("");
|
||||
const displayFormRef = ref();
|
||||
const displayModel = ref("");
|
||||
const footerFormRef = ref();
|
||||
const footerModel = ref("");
|
||||
const seoFormRef = ref();
|
||||
const seoModel = ref("");
|
||||
|
||||
/** 各 Tab 独立 dirty 状态(切 Tab 不丢;仅本 Tab 保存后清除) */
|
||||
const dirty = reactive<Record<string, boolean>>({
|
||||
basic: false, theme: false, layout: false, display: false, footer: false, seo: false, css: false,
|
||||
});
|
||||
const saving = reactive<Record<string, boolean>>({});
|
||||
/** initFromRow / 保存回灌种子时挂起 dirty 标记 */
|
||||
let seeding = false;
|
||||
|
||||
function tabLabel(tab: { key: string; title: string }): string {
|
||||
return dirty[tab.key] ? `${tab.title} ●` : tab.title;
|
||||
}
|
||||
|
||||
const schoolScopedSites = computed(() =>
|
||||
!store.currentSchool ? sites.value : sites.value.filter(s => s.schId === store.currentSchool!.schId)
|
||||
@@ -106,6 +120,10 @@ const portalPreviewUrl = computed(() =>
|
||||
? portalUrlOf(row.value)
|
||||
: ""
|
||||
);
|
||||
/** F2 实时预览 iframe 地址(编辑模式 = serviceHost + 正式门户首页) */
|
||||
const portalFrameUrl = computed(() =>
|
||||
!isCreate.value && row.value ? `${config.serviceHost}${portalUrlOf(row.value)}` : undefined
|
||||
);
|
||||
|
||||
const rules = {
|
||||
code: [
|
||||
@@ -126,11 +144,8 @@ onMounted(async () => {
|
||||
basic.parentSiteId = lockParentId.value;
|
||||
}
|
||||
loading.value = false;
|
||||
nextTick(setupAnchorObserver);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => observer?.disconnect());
|
||||
|
||||
async function loadSites() {
|
||||
sites.value = (await siteApi.list().send()) || [];
|
||||
}
|
||||
@@ -143,12 +158,14 @@ async function loadThemes() {
|
||||
}
|
||||
}
|
||||
|
||||
function initFromRow(id: number) {
|
||||
/** 载入站点 → 各 Tab 模型按 lastSavedConfigJson 种子(seeding 挂起 dirty 标记) */
|
||||
async function initFromRow(id: number) {
|
||||
const r = sites.value.find(s => s.id === id);
|
||||
if (!r) {
|
||||
loadError.value = "站点不存在或无权管理该站点";
|
||||
return;
|
||||
}
|
||||
seeding = true;
|
||||
row.value = r;
|
||||
// 主题值保留原值(未注册时告警,由服务端校验拒绝),不再静默改写为 default
|
||||
const resolved = resolveEditableTheme(r.theme, themes.value.map(t => t.name));
|
||||
@@ -163,10 +180,18 @@ function initFromRow(id: number) {
|
||||
status: r.status,
|
||||
});
|
||||
theme.value = resolved.theme;
|
||||
configJson.value = r.configJson || "";
|
||||
customCss.value = readCustomCss(configJson.value);
|
||||
const t = editTheme.value;
|
||||
themeParams.value = t ? themeParamsFromConfigJson(configJson.value, t) : {};
|
||||
lastSavedConfigJson.value = r.configJson || "";
|
||||
layoutModel.value = lastSavedConfigJson.value;
|
||||
displayModel.value = lastSavedConfigJson.value;
|
||||
footerModel.value = lastSavedConfigJson.value;
|
||||
seoModel.value = lastSavedConfigJson.value;
|
||||
customCss.value = readCustomCss(lastSavedConfigJson.value);
|
||||
Object.keys(dirty).forEach(k => (dirty[k] = false));
|
||||
// docs/20 返工 #2:seeding 是同步布尔位,而 Vue watcher 异步 flush——种值触发的 watcher 回调
|
||||
// 落在下一个 flush 周期(此时同步代码早已把 seeding 置回 false),必须等它们在挂起窗口内
|
||||
// 跑完再放行,否则冷加载即假脏
|
||||
await nextTick();
|
||||
seeding = false;
|
||||
}
|
||||
|
||||
function readCustomCss(configJson: string): string {
|
||||
@@ -178,26 +203,24 @@ function readCustomCss(configJson: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 把面板值写入/移出 configJson(空值移除键;与 SiteConfigForm 的 preserved 往返兼容) */
|
||||
function withCustomCss(configJson: string, css: string): string {
|
||||
let obj: Record<string, unknown>;
|
||||
/** HomeLayoutEditor 的整份 config 输出 → 仅取 home_layout 键的增量(无布局=删除键) */
|
||||
function layoutPatchOf(model: string): { home_layout?: unknown } {
|
||||
try {
|
||||
obj = configJson && configJson.trim() ? JSON.parse(configJson) : {};
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
|
||||
const parsed = model && model.trim() ? JSON.parse(model) : {};
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return { home_layout: (parsed as Record<string, unknown>)["home_layout"] };
|
||||
}
|
||||
} catch {
|
||||
obj = {};
|
||||
// 非法 JSON 视为无变更
|
||||
}
|
||||
if (css.trim()) obj.custom_css = css;
|
||||
else delete obj.custom_css;
|
||||
return JSON.stringify(obj);
|
||||
return {};
|
||||
}
|
||||
|
||||
function pickTheme(name: string) {
|
||||
if (theme.value === name) return;
|
||||
theme.value = name;
|
||||
themeUnregistered.value = false;
|
||||
const t = editTheme.value;
|
||||
themeParams.value = t ? formModelFromTheme(t, themeParams.value) : {};
|
||||
dirty.theme = true;
|
||||
}
|
||||
|
||||
function openPreview() {
|
||||
@@ -205,13 +228,16 @@ function openPreview() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一提交(§3.5 分区独立保存模型):payload = 页面当前事实源 + 基本信息快照;
|
||||
* patch.configJson 供分区保存前合并自己的键空间(如皮肤分区的 theme_params/custom_css)。
|
||||
* 创建成功原地 replace 到 site/setting/{id} 并解锁全部分区。
|
||||
* 统一提交(F1 Tab 独立保存):site 字段 = 当前基本信息快照 + 当前皮肤;
|
||||
* configJson = lastSavedConfigJson + 本 Tab 增量 patch(patch=null = 不动 config)。
|
||||
* 成功后回写事实源并清本 Tab dirty;其他 Tab 的内存修改不受影响。
|
||||
*/
|
||||
async function submitSection(key: string, patch: { configJson?: string }) {
|
||||
saving[key] = true;
|
||||
async function persistTab(tab: TabKey, patch: Record<string, unknown> | null) {
|
||||
saving[tab] = true;
|
||||
try {
|
||||
const configJson = patch == null
|
||||
? lastSavedConfigJson.value || undefined
|
||||
: mergeConfigPatch(lastSavedConfigJson.value, patch);
|
||||
const res = await siteApi
|
||||
.save({
|
||||
id: basic.id,
|
||||
@@ -222,30 +248,42 @@ async function submitSection(key: string, patch: { configJson?: string }) {
|
||||
schName: isSchoolAdmin.value ? undefined : basic.schName || undefined,
|
||||
name: basic.name,
|
||||
theme: theme.value || undefined,
|
||||
configJson: patch.configJson ?? configJson.value,
|
||||
configJson,
|
||||
status: basic.status,
|
||||
})
|
||||
.send();
|
||||
if (isCreate.value && res && res.id) {
|
||||
Message.success("站点创建成功!全部分区已开放");
|
||||
// 向导第一步完成:原地 replace 解锁全部 Tab,自动进入第二步(选皮肤)
|
||||
Message.success("站点创建成功!接下来为它挑选一套皮肤");
|
||||
await loadSites();
|
||||
const main = sites.value.find(s => s.id === (res.parentSiteId || 0));
|
||||
const schCode = main?.code || res.code;
|
||||
await router.replace(adminPathOf(schCode, res.parentSiteId ? res.code : null, `site/setting/${res.id}`));
|
||||
initFromRow(res.id);
|
||||
} else {
|
||||
Message.success("保存成功!");
|
||||
if (res) {
|
||||
row.value = res;
|
||||
// 服务端归一后的 configJson 刷新事实源(theme_params 合并等)
|
||||
if (typeof res.configJson === "string") {
|
||||
configJson.value = res.configJson;
|
||||
customCss.value = readCustomCss(configJson.value);
|
||||
}
|
||||
wizardStep.value = 2;
|
||||
activeTab.value = "theme";
|
||||
return;
|
||||
}
|
||||
Message.success("保存成功!");
|
||||
if (res) {
|
||||
row.value = res;
|
||||
if (typeof res.configJson === "string") {
|
||||
lastSavedConfigJson.value = res.configJson;
|
||||
}
|
||||
}
|
||||
dirty[tab] = false;
|
||||
// 保存回灌:本 Tab 模型与服务端事实源对齐(seeding 窗口须覆盖异步 flush 的 watcher,返工 #2 同 initFromRow)
|
||||
seeding = true;
|
||||
if (tab === "layout") {
|
||||
layoutModel.value = lastSavedConfigJson.value;
|
||||
layoutEditorRef.value?.refreshAfterSave?.();
|
||||
} else if (tab === "css") {
|
||||
customCss.value = readCustomCss(lastSavedConfigJson.value);
|
||||
}
|
||||
await nextTick();
|
||||
seeding = false;
|
||||
} finally {
|
||||
saving[key] = false;
|
||||
saving[tab] = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,46 +293,24 @@ async function saveBasic() {
|
||||
Message.warning("请填写红色星号内容!"); // bsch §4 统一提示
|
||||
return;
|
||||
}
|
||||
await submitSection("basic", {});
|
||||
await persistTab("basic", null);
|
||||
}
|
||||
|
||||
async function saveTheme() {
|
||||
const t = editTheme.value;
|
||||
const baseConfig = withCustomCss(configJson.value, customCss.value);
|
||||
const merged = t ? mergeThemeParamsIntoConfigJson(baseConfig, t, themeParams.value) : baseConfig;
|
||||
await submitSection("theme", { configJson: merged });
|
||||
await persistTab("theme", null);
|
||||
}
|
||||
|
||||
async function saveDisplay() {
|
||||
if (!(await displayFormRef.value?.validate())) {
|
||||
Message.warning("请填写红色星号内容!"); // bsch §4
|
||||
return;
|
||||
}
|
||||
await submitSection("display", {});
|
||||
/** 向导第二步收口:保存皮肤 → 提示可继续配置首页布局,展开全部 Tab */
|
||||
async function finishWizard() {
|
||||
await persistTab("theme", null);
|
||||
wizardStep.value = 0;
|
||||
Message.success("站点创建成功,可继续配置首页布局");
|
||||
}
|
||||
|
||||
async function saveFooter() {
|
||||
if (!(await footerFormRef.value?.validate())) {
|
||||
Message.warning("请填写红色星号内容!"); // bsch §4
|
||||
return;
|
||||
}
|
||||
await submitSection("footer", {});
|
||||
}
|
||||
|
||||
async function saveSeo() {
|
||||
if (!(await seoFormRef.value?.validate())) {
|
||||
Message.warning("请填写红色星号内容!"); // bsch §4
|
||||
return;
|
||||
}
|
||||
await submitSection("seo", {});
|
||||
}
|
||||
|
||||
async function saveExtras() {
|
||||
if (!(await extrasFormRef.value?.validate())) {
|
||||
Message.warning("请填写红色星号内容!"); // bsch §4
|
||||
return;
|
||||
}
|
||||
await submitSection("extras", {});
|
||||
/** 向导第二步跳过(皮肤先用当前选择/默认) */
|
||||
function skipWizard() {
|
||||
wizardStep.value = 0;
|
||||
Message.info("已跳过皮肤配置,全部设置已开放");
|
||||
}
|
||||
|
||||
async function saveLayout() {
|
||||
@@ -303,7 +319,31 @@ async function saveLayout() {
|
||||
Message.error(err);
|
||||
return;
|
||||
}
|
||||
await submitSection("layout", {});
|
||||
await persistTab("layout", layoutPatchOf(layoutModel.value));
|
||||
}
|
||||
|
||||
/** 分区表单 ref 表(docs/20 返工 #4:模板实参里的 ref 会被 Vue 自动解包成组件实例,
|
||||
* 旧代码按 `formRef.value?.validate()` 调用恒为 undefined → validate 恒 false;refs 必须在脚本侧取用) */
|
||||
const groupFormRefs: Record<"display" | "footer" | "seo", typeof displayFormRef> = {
|
||||
display: displayFormRef,
|
||||
footer: footerFormRef,
|
||||
seo: seoFormRef,
|
||||
};
|
||||
|
||||
async function saveGroupForm(tab: "display" | "footer" | "seo") {
|
||||
const form = groupFormRefs[tab].value;
|
||||
const firstError = await form?.validate();
|
||||
if (firstError) {
|
||||
// 精确到「字段名:原因」(内联红框 + 字段下方文案 + 滚动定位由 SiteConfigForm.validate 承担),
|
||||
// 不再用「请填写红色星号内容」这类与实际校验机制不匹配的通用提示
|
||||
Message.warning(firstError);
|
||||
return;
|
||||
}
|
||||
await persistTab(tab, form?.ownFields?.() || {});
|
||||
}
|
||||
|
||||
async function saveCss() {
|
||||
await persistTab("css", { custom_css: customCss.value });
|
||||
}
|
||||
|
||||
/** 门户预览(编辑模式):新窗口打开真实门户 */
|
||||
@@ -322,28 +362,25 @@ function backToList() {
|
||||
router.push(adminPathOf(schCode, row.value?.parentSiteId ? row.value.code : null, "site"));
|
||||
}
|
||||
|
||||
function setupAnchorObserver() {
|
||||
if (typeof IntersectionObserver === "undefined") return;
|
||||
observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(e => {
|
||||
if (e.isIntersecting) {
|
||||
activeSection.value = (e.target as HTMLElement).dataset.section || activeSection.value;
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-90px 0px -55% 0px", threshold: 0 },
|
||||
);
|
||||
sections.forEach(s => {
|
||||
const el = document.getElementById(`sec-${s.key}`);
|
||||
if (el) observer!.observe(el);
|
||||
});
|
||||
}
|
||||
|
||||
function scrollToSection(key: string) {
|
||||
activeSection.value = key;
|
||||
document.getElementById(`sec-${key}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
// ===== dirty 追踪:各 Tab 模型变更即标脏(seeding 期间挂起)=====
|
||||
watch(basic, () => {
|
||||
if (!seeding) dirty.basic = true;
|
||||
});
|
||||
watch(customCss, () => {
|
||||
if (!seeding) dirty.css = true;
|
||||
});
|
||||
watch(layoutModel, () => {
|
||||
if (!seeding) dirty.layout = true;
|
||||
});
|
||||
watch(displayModel, () => {
|
||||
if (!seeding) dirty.display = true;
|
||||
});
|
||||
watch(footerModel, () => {
|
||||
if (!seeding) dirty.footer = true;
|
||||
});
|
||||
watch(seoModel, () => {
|
||||
if (!seeding) dirty.seo = true;
|
||||
});
|
||||
|
||||
// 学校上下文切换即时重载(编辑中的站点归属变化防御)
|
||||
watch(() => store.currentSchool?.schId, () => loadSites());
|
||||
@@ -369,28 +406,65 @@ watch(() => store.currentSchool?.schId, () => loadSites());
|
||||
<Alert v-if="loadError" type="error" show-icon style="margin-bottom: 12px">{{ loadError }}</Alert>
|
||||
<Spin v-if="loading" size="large" fix />
|
||||
|
||||
<div class="ss-layout">
|
||||
<!-- 锚点导航 -->
|
||||
<aside class="ss-nav">
|
||||
<div
|
||||
v-for="s in sections"
|
||||
:key="s.key"
|
||||
class="ss-nav-item"
|
||||
:class="{ active: activeSection === s.key }"
|
||||
@click="scrollToSection(s.key)"
|
||||
>
|
||||
<span class="ss-nav-title">
|
||||
{{ s.title }}
|
||||
<Icon v-if="isCreate && s.locked" type="md-lock" class="ss-nav-lock" />
|
||||
</span>
|
||||
<span class="ss-nav-desc">{{ s.desc }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- 渐进式向导进度(F1):仅创建流程期间展示 -->
|
||||
<Steps v-if="wizardStep > 0" :current="wizardStep" class="ss-steps">
|
||||
<Step title="基本信息" content="编码 / 名称 / 归属"></Step>
|
||||
<Step title="皮肤与外观" content="挑一套门户皮肤"></Step>
|
||||
<Step title="完整设置" content="首页布局等全部配置开放"></Step>
|
||||
</Steps>
|
||||
|
||||
<!-- 分区卡片流 -->
|
||||
<div class="ss-cards">
|
||||
<!-- ① 基本信息 -->
|
||||
<div id="sec-basic" class="card setting-card" data-section="basic">
|
||||
<!-- ===== 向导第一步(创建模式):仅基本信息表单 ===== -->
|
||||
<div v-if="wizardStep === 1" class="card setting-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">基本信息</span>
|
||||
<span class="card-subtitle">站点编码 / 名称 / 归属与状态</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<Form ref="basicRef" :model="basic" :rules="rules" :label-width="110">
|
||||
<FormItem label="站点编码" prop="code">
|
||||
<Input v-model="basic.code" placeholder="URL 路径用,如 demo" style="max-width: 320px" />
|
||||
</FormItem>
|
||||
<FormItem label="站点名称" prop="name">
|
||||
<Input v-model="basic.name" :maxlength="128" placeholder="如 XX学校门户网站" style="max-width: 320px" />
|
||||
</FormItem>
|
||||
<FormItem label="上级站点">
|
||||
<Select v-model="basic.parentSiteId" :clearable="!lockParentId" :disabled="!!lockParentId" 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-hint">
|
||||
院系子站挂在主站下,门户 header 右上出现"专业院系"下拉;层级固定两层。
|
||||
{{ isSchoolAdmin ? "校级角色仅可在本校主站之间改挂。" : "" }}
|
||||
</p>
|
||||
</FormItem>
|
||||
<FormItem v-if="store.isSuperAdmin" label="学校ID" prop="schId" :rules="schIdRequired ? schIdRules : []">
|
||||
<InputNumber v-model="basic.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
|
||||
<div v-if="schIdRequired" class="form-hint">顶层站点必填(否则校级角色不可管理);院系子站可留空自动继承主站</div>
|
||||
</FormItem>
|
||||
<FormItem v-if="store.isSuperAdmin" label="学校名称">
|
||||
<Input v-model="basic.schName" :maxlength="128" style="max-width: 320px" />
|
||||
</FormItem>
|
||||
<FormItem label="门户地址预览">
|
||||
<span class="ss-portal-url">{{ portalPreviewUrl || "输入编码后自动生成" }}</span>
|
||||
<div class="form-hint">子站地址 = 上级地址 / 编码;创建后经站点管理「门户路径」列直达</div>
|
||||
</FormItem>
|
||||
<FormItem label="状态">
|
||||
<RadioGroup v-model="basic.status">
|
||||
<Radio :label="1">启用</Radio>
|
||||
<Radio :label="0">停用</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<Button type="primary" :loading="saving.basic" @click="saveBasic">创建站点,下一步</Button>
|
||||
<span class="card-hint">创建后自动进入第二步挑选皮肤,其余配置随即全部开放</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 常规编辑(含向导第二步起):顶部 Tab,7 个分区各持独立表单与保存 ===== -->
|
||||
<Tabs v-else v-model="activeTab" class="ss-tabs" :animated="false">
|
||||
<TabPane :label="tabLabel(TABS[0])" name="basic">
|
||||
<div class="card setting-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">基本信息</span>
|
||||
<span class="card-subtitle">站点编码 / 名称 / 归属与状态</span>
|
||||
@@ -420,9 +494,8 @@ watch(() => store.currentSchool?.schId, () => loadSites());
|
||||
<FormItem v-if="store.isSuperAdmin" label="学校名称">
|
||||
<Input v-model="basic.schName" :maxlength="128" style="max-width: 320px" />
|
||||
</FormItem>
|
||||
<FormItem label="门户地址预览">
|
||||
<span class="ss-portal-url">{{ portalPreviewUrl || "输入编码后自动生成" }}</span>
|
||||
<div class="form-hint">子站地址 = 上级地址 / 编码;创建后经站点管理「门户路径」列直达</div>
|
||||
<FormItem label="门户地址">
|
||||
<span class="ss-portal-url">{{ portalPreviewUrl || "-" }}</span>
|
||||
</FormItem>
|
||||
<FormItem label="状态">
|
||||
<RadioGroup v-model="basic.status">
|
||||
@@ -433,18 +506,17 @@ watch(() => store.currentSchool?.schId, () => loadSites());
|
||||
</Form>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<Button type="primary" :loading="saving.basic" @click="saveBasic">
|
||||
{{ isCreate ? "创建站点" : "仅保存基本信息" }}
|
||||
</Button>
|
||||
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
|
||||
<Button type="primary" :loading="saving.basic" @click="saveBasic">保存基本信息</Button>
|
||||
<span class="card-hint">仅保存本 Tab;其他 Tab 未保存的修改保留在各自表单里,不会连带提交</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<!-- ② 皮肤与外观 -->
|
||||
<div id="sec-theme" class="card setting-card" data-section="theme">
|
||||
<TabPane :label="tabLabel(TABS[1])" name="theme">
|
||||
<div class="card setting-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">皮肤与外观</span>
|
||||
<span class="card-subtitle">风格选择 / 参数微调 / 站点级 CSS 覆盖</span>
|
||||
<span class="card-subtitle">风格选择(皮肤参数的调整入口在「皮肤包管理」;自定义 CSS 已独立为专属 Tab)</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="field-block">
|
||||
@@ -457,126 +529,131 @@ watch(() => store.currentSchool?.schId, () => loadSites());
|
||||
</Alert>
|
||||
<ThemePicker :themes="themes" :model-value="theme" @update:model-value="pickTheme" />
|
||||
<Button size="small" class="preview-btn" :disabled="!editTheme" @click="openPreview">预览本皮肤</Button>
|
||||
<ThemeParamForm :theme="editTheme" v-model="themeParams" />
|
||||
<p class="form-hint">换肤/改参数保存后门户立即生效(缓存已自动失效)。</p>
|
||||
</div>
|
||||
<!-- 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>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<Button type="primary" :loading="saving.theme" @click="saveTheme">仅保存皮肤与外观</Button>
|
||||
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
|
||||
<template v-if="wizardStep === 2">
|
||||
<Button type="primary" :loading="saving.theme" @click="finishWizard">保存并完成</Button>
|
||||
<Button @click="skipWizard">跳过,稍后配置</Button>
|
||||
<span class="card-hint">第二步(选皮肤)完成后全部设置开放</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button type="primary" :loading="saving.theme" @click="saveTheme">保存皮肤与外观</Button>
|
||||
<span class="card-hint">换肤保存后门户立即生效(缓存已自动失效)</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<!-- ③ 首页布局 -->
|
||||
<div id="sec-layout" class="card setting-card" :class="{ locked: isCreate }" data-section="layout">
|
||||
<TabPane :label="tabLabel(TABS[2])" name="layout">
|
||||
<div class="card setting-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">首页布局</span>
|
||||
<span class="card-subtitle">首页 = 可编排的区块序列(要闻/栏目文章/画廊/链接组)</span>
|
||||
</div>
|
||||
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
|
||||
<div class="card-body">
|
||||
<HomeLayoutEditor v-model="configJson" :site-id="basic.id" ref="layoutEditorRef" @save="saveLayout" />
|
||||
<HomeLayoutEditor
|
||||
v-model="layoutModel"
|
||||
:site-id="basic.id"
|
||||
:portal-url="portalFrameUrl"
|
||||
ref="layoutEditorRef"
|
||||
@save="saveLayout"
|
||||
/>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<Button type="primary" :loading="saving.layout" :disabled="isCreate" @click="saveLayout">仅保存首页布局</Button>
|
||||
<Button type="primary" :loading="saving.layout" @click="saveLayout">保存首页布局</Button>
|
||||
<span class="card-hint">保存后门户立即生效(缓存自动失效);未编排布局时门户按「全部栏目自动渲染」默认形态展示</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<!-- ④ 基础展示 -->
|
||||
<div id="sec-display" class="card setting-card" :class="{ locked: isCreate }" data-section="display">
|
||||
<TabPane :label="tabLabel(TABS[3])" name="display">
|
||||
<div class="card setting-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">基础展示</span>
|
||||
<span class="card-subtitle">Logo / favicon / 标语 / 院系文案(图片支持直接上传)</span>
|
||||
</div>
|
||||
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
|
||||
<div class="card-body">
|
||||
<SiteConfigForm ref="displayFormRef" v-model="configJson" :groups="['display']" :site-id="basic.id" />
|
||||
<SiteConfigForm ref="displayFormRef" v-model="displayModel" :groups="['display']" :site-id="basic.id" />
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<Button type="primary" :loading="saving.display" :disabled="isCreate" @click="saveDisplay">仅保存基础展示</Button>
|
||||
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
|
||||
<Button type="primary" :loading="saving.display" @click="saveGroupForm('display')">保存基础展示</Button>
|
||||
<span class="card-hint">仅保存本 Tab 的字段</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<!-- ⑤ 页脚信息 -->
|
||||
<div id="sec-footer" class="card setting-card" :class="{ locked: isCreate }" data-section="footer">
|
||||
<TabPane :label="tabLabel(TABS[4])" name="footer">
|
||||
<div class="card setting-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">页脚信息</span>
|
||||
<span class="card-subtitle">联系方式 / 多校区地址 / 二维码 / 社媒 / 版权备案</span>
|
||||
</div>
|
||||
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
|
||||
<div class="card-body">
|
||||
<SiteConfigForm ref="footerFormRef" v-model="configJson" :groups="['footer']" :site-id="basic.id" />
|
||||
<SiteConfigForm ref="footerFormRef" v-model="footerModel" :groups="['footer']" :site-id="basic.id" />
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<Button type="primary" :loading="saving.footer" :disabled="isCreate" @click="saveFooter">仅保存页脚信息</Button>
|
||||
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
|
||||
<Button type="primary" :loading="saving.footer" @click="saveGroupForm('footer')">保存页脚信息</Button>
|
||||
<span class="card-hint">仅保存本 Tab 的字段</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<!-- ⑥ SEO 与域名 -->
|
||||
<div id="sec-seo" class="card setting-card" :class="{ locked: isCreate }" data-section="seo">
|
||||
<TabPane :label="tabLabel(TABS[5])" name="seo">
|
||||
<div class="card setting-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">SEO 与域名</span>
|
||||
<span class="card-subtitle">搜索引擎摘要 / 独立域名台账与对外基地址</span>
|
||||
</div>
|
||||
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
|
||||
<div class="card-body">
|
||||
<SiteConfigForm ref="seoFormRef" v-model="configJson" :groups="['seo', 'domain']" :site-id="basic.id" />
|
||||
<SiteConfigForm ref="seoFormRef" v-model="seoModel" :groups="['seo', 'domain']" :site-id="basic.id" />
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<Button type="primary" :loading="saving.seo" :disabled="isCreate" @click="saveSeo">仅保存 SEO 与域名</Button>
|
||||
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
|
||||
<Button type="primary" :loading="saving.seo" @click="saveGroupForm('seo')">保存 SEO 与域名</Button>
|
||||
<span class="card-hint">仅保存本 Tab 的字段</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<!-- ⑦ 扩展参数 -->
|
||||
<div id="sec-extras" class="card setting-card" :class="{ locked: isCreate }" data-section="extras">
|
||||
<TabPane :label="tabLabel(TABS[6])" name="css">
|
||||
<div class="card setting-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">扩展参数</span>
|
||||
<span class="card-subtitle">面向后续扩展的预留配置键</span>
|
||||
<span class="card-title">自定义样式</span>
|
||||
<span class="card-subtitle">站点级 CSS 覆盖(面向需要微调的高级用户)</span>
|
||||
</div>
|
||||
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
|
||||
<div class="card-body">
|
||||
<SiteConfigForm ref="extrasFormRef" v-model="configJson" show-extras />
|
||||
<Input
|
||||
v-model="customCss"
|
||||
type="textarea"
|
||||
:rows="14"
|
||||
: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>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<Button type="primary" :loading="saving.extras" :disabled="isCreate" @click="saveExtras">仅保存扩展参数</Button>
|
||||
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
|
||||
<Button type="primary" :loading="saving.css" @click="saveCss">保存自定义样式</Button>
|
||||
<span class="card-hint">仅保存本 Tab 的字段</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- 皮肤预览弹窗(iframe 加载 /api/theme/preview,no-store) -->
|
||||
<Modal v-model="previewVisible" title="皮肤预览" :mask-closable="true" width="980px" footer-hide>
|
||||
<ThemePreviewFrame
|
||||
v-if="previewVisible && editTheme && row"
|
||||
:site-path="row.path || row.code"
|
||||
:theme="theme"
|
||||
:theme-params="themeParams"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<!-- 皮肤预览弹窗(iframe 加载 /api/theme/preview,no-store) -->
|
||||
<Modal v-model="previewVisible" title="皮肤预览" :mask-closable="true" width="980px" footer-hide>
|
||||
<ThemePreviewFrame
|
||||
v-if="previewVisible && editTheme && row"
|
||||
:site-path="row.path || row.code"
|
||||
:theme="theme"
|
||||
:theme-params="editTheme?.paramValues || {}"
|
||||
/>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -597,70 +674,28 @@ watch(() => store.currentSchool?.schId, () => loadSites());
|
||||
}
|
||||
}
|
||||
}
|
||||
.ss-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
.ss-steps {
|
||||
margin-bottom: 18px;
|
||||
background: #fff;
|
||||
border: 1px solid #e9edf5;
|
||||
border-radius: 10px;
|
||||
padding: 14px 24px 4px;
|
||||
}
|
||||
.ss-nav {
|
||||
width: 180px;
|
||||
flex: none;
|
||||
position: sticky;
|
||||
top: 84px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
.ss-nav-item {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f0faff;
|
||||
}
|
||||
&.active {
|
||||
background: #e8f4ff;
|
||||
.ss-nav-title {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
}
|
||||
.ss-nav-title {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #515a6e;
|
||||
}
|
||||
.ss-nav-lock {
|
||||
color: #c5c8ce;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ss-nav-desc {
|
||||
display: block;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ss-tabs {
|
||||
background: #fff;
|
||||
border: 1px solid #e9edf5;
|
||||
border-radius: 10px;
|
||||
padding: 4px 16px 16px;
|
||||
:deep(.ivu-tabs-nav) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
.ss-cards {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.setting-card {
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f2f5;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 16px 18px;
|
||||
scroll-margin-top: 76px;
|
||||
&.locked {
|
||||
.card-body {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -669,13 +704,25 @@ watch(() => store.currentSchool?.schId, () => loadSites());
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
.card-title {
|
||||
position: relative;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
color: #1e2635;
|
||||
padding-left: 10px;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: #577afb;
|
||||
}
|
||||
}
|
||||
.card-subtitle {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
color: #8a93a6;
|
||||
}
|
||||
}
|
||||
.card-footer {
|
||||
@@ -686,14 +733,13 @@ watch(() => store.currentSchool?.schId, () => loadSites());
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f2f5;
|
||||
.card-hint {
|
||||
color: #999;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: #8a93a6;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
.ss-lock-tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
.form-hint {
|
||||
color: #999;
|
||||
@@ -719,21 +765,19 @@ watch(() => store.currentSchool?.schId, () => loadSites());
|
||||
.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;
|
||||
}
|
||||
.css-editor :deep(textarea) {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.css-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,14 +5,18 @@ import { Message } from "view-ui-plus";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import WorkbenchHeader from "@/components/WorkbenchHeader.vue";
|
||||
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
|
||||
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
|
||||
import { ThemeParamVo, ThemeVo, SiteVo } from "@/types/cms";
|
||||
import { themeApi, siteApi, assetUrl } from "@/api/module/cms";
|
||||
import { isValidParamValue, paramValuesOf, FormModelValue } from "@/utils/themeParamForm";
|
||||
import { useCmsStore } from "@/store";
|
||||
|
||||
/**
|
||||
* 皮肤包管理(W1 + 任务二/四,2026-09-08):平台级全屏页(/theme-manage,超管专属,
|
||||
* 工作台按钮进入、不再挂左侧菜单)。内置包内容只读但显示名/描述可改(.meta 覆盖层)、
|
||||
* 外部包可管(上传/派生/编辑/删除)。标识名(name,即主题路径/URL 段)仅创建时可定,创建后不可改。
|
||||
* 工作项 T:参数值改挂皮肤包(themes/.meta/{name}.json → paramValues),编辑弹窗内嵌参数微调,
|
||||
* 内置包与外部包同权(值只落覆盖层,不碰包内 theme.json);卡片显示「已微调 M 项」。
|
||||
*/
|
||||
|
||||
const router = useRouter();
|
||||
@@ -41,6 +45,9 @@ const metaTheme = ref<ThemeVo | null>(null);
|
||||
const metaTitle = ref("");
|
||||
const metaDescription = ref("");
|
||||
const metaParams = ref<ThemeParamVo[]>([]);
|
||||
/** 包级参数值模型(paramValuesOf 回显);paramTouched 决定保存时是否携带 paramValues */
|
||||
const metaParamModel = ref<FormModelValue>({});
|
||||
const paramTouched = ref(false);
|
||||
|
||||
// 非超管深链兜底(引导期守卫放行,user 装载后弹回工作台)
|
||||
watch(
|
||||
@@ -64,9 +71,8 @@ async function run() {
|
||||
|
||||
onMounted(run);
|
||||
|
||||
/** 预览内容源站点(W1:与向导同语义,取启用站点按门户 path 预览) */
|
||||
async function openPreview(theme: ThemeVo) {
|
||||
previewTheme.value = theme;
|
||||
/** 预览内容源站点(W1:与向导同语义,取启用站点按门户 path 预览);亦借给参数微调的 image 上传归属 */
|
||||
async function ensureContentSources(): Promise<SiteVo[]> {
|
||||
if (!contentSources.value.length) {
|
||||
try {
|
||||
contentSources.value = ((await siteApi.list().send()) || []).filter(s => s.status === 1);
|
||||
@@ -74,7 +80,16 @@ async function openPreview(theme: ThemeVo) {
|
||||
contentSources.value = [];
|
||||
}
|
||||
}
|
||||
const first = contentSources.value[0];
|
||||
return contentSources.value;
|
||||
}
|
||||
|
||||
/** image 型参数上传需归属站点(后端 siteId 必填);无可用站点时控件退化为仅手填路径 */
|
||||
const metaSiteId = computed(() => contentSources.value[0]?.id);
|
||||
|
||||
async function openPreview(theme: ThemeVo) {
|
||||
previewTheme.value = theme;
|
||||
const sources = await ensureContentSources();
|
||||
const first = sources[0];
|
||||
previewSitePath.value = first ? first.path || first.code : "";
|
||||
previewVisible.value = true;
|
||||
}
|
||||
@@ -145,36 +160,55 @@ async function doAdd() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 编辑元数据(任务四:内置包亦可改显示名/描述,走覆盖层) ===== */
|
||||
function openMeta(theme: ThemeVo) {
|
||||
/* ===== 编辑元数据(任务四:内置包亦可改显示名/描述,走覆盖层;工作项 T:包级参数值同区编辑) ===== */
|
||||
async function openMeta(theme: ThemeVo) {
|
||||
metaTheme.value = theme;
|
||||
metaTitle.value = theme.title;
|
||||
metaDescription.value = theme.description || "";
|
||||
metaParams.value = (theme.params || []).map(p => ({ ...p }));
|
||||
metaParamModel.value = paramValuesOf(theme);
|
||||
paramTouched.value = false;
|
||||
metaVisible.value = true;
|
||||
if ((theme.params || []).some(p => p.type === "image")) await ensureContentSources();
|
||||
}
|
||||
|
||||
function onParamModelChange(v: FormModelValue) {
|
||||
metaParamModel.value = v;
|
||||
paramTouched.value = true;
|
||||
}
|
||||
|
||||
/** 待提交的包级参数值:只带「合法且偏离包内默认」的 key(= 卡片「已微调」口径,与后端整体替换语义一致) */
|
||||
function currentParamValues(): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
(metaTheme.value?.params || []).forEach(p => {
|
||||
const v = metaParamModel.value[p.key];
|
||||
if (isValidParamValue(p, v) && v !== (p.defaultValue ?? "")) result[p.key] = v;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function doSaveMeta() {
|
||||
const builtin = metaTheme.value?.source === "builtin";
|
||||
await themeApi
|
||||
.updateMeta(metaTheme.value!.name, {
|
||||
title: metaTitle.value,
|
||||
description: metaDescription.value,
|
||||
// 内置包参数 schema 不可改(后端强校验),不传即不改动
|
||||
params: builtin ? undefined : metaParams.value,
|
||||
})
|
||||
.send();
|
||||
const payload = {
|
||||
title: metaTitle.value,
|
||||
description: metaDescription.value,
|
||||
// 内置包参数 schema 不可改(后端强校验),不传即不改动
|
||||
params: builtin ? undefined : metaParams.value,
|
||||
} as { title: string; description: string; params?: unknown[]; paramValues?: Record<string, string> };
|
||||
// 未触碰参数区则不带 paramValues 键(后端 null=不改),避免改名顺带清空微调
|
||||
if (paramTouched.value) payload.paramValues = currentParamValues();
|
||||
await themeApi.updateMeta(metaTheme.value!.name, payload).send();
|
||||
Message.success("已保存,引用该皮肤的站点门户与预览立即生效");
|
||||
metaVisible.value = false;
|
||||
run();
|
||||
}
|
||||
|
||||
/** 内置包恢复默认显示名/描述(清空 .meta 覆盖层) */
|
||||
/** 内置包恢复默认(清空 .meta 覆盖层:改名 + 描述 + 包级参数微调一并回到包内声明) */
|
||||
async function doResetBuiltinMeta() {
|
||||
MyConfirm({
|
||||
title: "恢复默认名称",
|
||||
msg: `确定恢复「${metaTheme.value?.name}」的默认显示名称与描述吗?`,
|
||||
info: "将清除改名覆盖层,恢复包内声明的原始名称",
|
||||
info: "将清除改名覆盖层,恢复包内声明的原始名称;包级参数微调一并清空",
|
||||
async onOk() {
|
||||
await themeApi.updateMeta(metaTheme.value!.name, { title: "", description: "" }).send();
|
||||
Message.success("已恢复默认名称");
|
||||
@@ -206,6 +240,20 @@ function askRemove(theme: ThemeVo) {
|
||||
}
|
||||
|
||||
const builtinCount = computed(() => themes.value.filter(t => t.source === "builtin").length);
|
||||
|
||||
/** 卡片「已微调 M 项」口径:包级 paramValues 里合法且偏离 theme.json 默认值的 key 数 */
|
||||
function tweakedCount(t: ThemeVo): number {
|
||||
const stored = t.paramValues || {};
|
||||
return (t.params || []).filter(p => {
|
||||
const v = stored[p.key];
|
||||
return isValidParamValue(p, v) && v !== (p.defaultValue ?? "");
|
||||
}).length;
|
||||
}
|
||||
|
||||
function tweakSummary(t: ThemeVo): string {
|
||||
const m = tweakedCount(t);
|
||||
return m ? `已微调 ${m} 项` : "未微调";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -234,13 +282,13 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
||||
<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>
|
||||
<p class="stats">参数 {{ (t.params || []).length }} 个 · {{ tweakSummary(t) }} · 被用 {{ t.inUseCount || 0 }} 站点</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<Button size="small" @click="openPreview(t)">预览</Button>
|
||||
<Tooltip
|
||||
:content="t.source === 'builtin'
|
||||
? '内置包内容只读,可改显示名称与描述'
|
||||
? '内置包内容只读,可改显示名称与描述、微调包级参数值'
|
||||
: (t.inUseCount || 0) > 0
|
||||
? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
|
||||
: '外部包可编辑参数 schema'"
|
||||
@@ -334,14 +382,20 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
||||
</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<ThemePreviewFrame fill :site-path="previewSitePath" :theme="previewTheme?.name" :theme-params="{}" />
|
||||
<ThemePreviewFrame
|
||||
fill
|
||||
:site-path="previewSitePath"
|
||||
:theme="previewTheme?.name"
|
||||
:theme-params="previewTheme?.paramValues || {}"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- 编辑弹窗(任务四:内置包仅显示名/描述;外部包另含参数 schema) -->
|
||||
<!-- 编辑弹窗(任务四:内置包仅显示名/描述;外部包另含参数 schema。工作项 T:包级参数值两态同权编辑) -->
|
||||
<Modal v-model="metaVisible" :title="`编辑皮肤:${metaTheme?.name || ''}`" :mask-closable="false" width="560px">
|
||||
<Alert v-if="metaTheme?.source === 'builtin'" type="info" show-icon style="margin-bottom: 12px">
|
||||
内置包内容(模板/资产)不可修改,仅显示名称与描述生效;标识名({{ metaTheme?.name }})不可改。
|
||||
下方的参数微调只写包级覆盖层,不改包内 theme.json。
|
||||
</Alert>
|
||||
<Form :label-width="110">
|
||||
<FormItem label="显示名称">
|
||||
@@ -357,6 +411,14 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
||||
</FormItem>
|
||||
</template>
|
||||
</Form>
|
||||
<div class="meta-params">
|
||||
<ThemeParamForm
|
||||
:theme="metaTheme || undefined"
|
||||
:model-value="metaParamModel"
|
||||
:site-id="metaSiteId"
|
||||
@update:model-value="onParamModelChange"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button v-if="metaTheme?.source === 'builtin'" style="float: left" @click="doResetBuiltinMeta">
|
||||
恢复默认名称
|
||||
@@ -462,6 +524,11 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
.meta-params {
|
||||
margin-top: 4px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #e9edf5;
|
||||
}
|
||||
.form-tip {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { assetUrl } from "@/api/module/cms";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
import { SiteVo, ThemeVo } from "@/types/cms";
|
||||
|
||||
/**
|
||||
* 院系子站卡(docs/22):站点工作台网格单元。
|
||||
* 信息按「身份 → 规模 → 皮肤 → 地址 → 动作」纵向分层,不再与表格争列宽。
|
||||
* 当前站 = 蓝描边 + 「当前」绿徽章;停用 = 降透明 + 灰图标 + 「进入管理」禁用。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
site: SiteVo;
|
||||
isCurrent: boolean;
|
||||
theme?: ThemeVo;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "enter", site: SiteVo): void;
|
||||
(e: "setting", site: SiteVo): void;
|
||||
(e: "portal", site: SiteVo): void;
|
||||
}>();
|
||||
|
||||
const disabled = computed(() => props.site.status !== 1);
|
||||
/** 当前站无需「进入管理」(已在站内),与停用同视为不可达 */
|
||||
const enterDisabled = computed(() => disabled.value || props.isCurrent);
|
||||
|
||||
/**
|
||||
* 规模统计:SiteVo 暂未提供聚合字段(docs/22 Q1),故当前渲染为空、该行自动隐藏;
|
||||
* 后端在 /api/site/list 补齐 categoryCount / articleCount / updateTime 后自动出现,模板不动。
|
||||
*/
|
||||
const stats = computed(() => {
|
||||
const s = props.site as any;
|
||||
const list: { num: string | number; lbl: string }[] = [];
|
||||
if (s.categoryCount != null) list.push({ num: s.categoryCount, lbl: "栏目" });
|
||||
if (s.articleCount != null) list.push({ num: s.articleCount, lbl: "文章" });
|
||||
if (s.updateTime) list.push({ num: String(s.updateTime).slice(5, 10), lbl: "更新" });
|
||||
return list;
|
||||
});
|
||||
|
||||
/** 皮肤缩略图(走 assetUrl 补资源前缀,与表格视图同源) */
|
||||
const themeThumb = computed(() => (props.theme?.screenshotUrl ? assetUrl(props.theme.screenshotUrl) : ""));
|
||||
const themeTitle = computed(() => props.theme?.title || props.site.theme || "default");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="site-card" :class="{ 'is-cur': isCurrent, disabled }">
|
||||
<div class="sc-head">
|
||||
<span class="sc-ico" :class="disabled ? 'off' : 'sub'">
|
||||
<Icon type="ios-cog" />
|
||||
</span>
|
||||
<div class="sc-id">
|
||||
<div class="sc-name">
|
||||
<h4 :title="site.name">{{ site.name }}</h4>
|
||||
<span class="sc-badge sub">院系子站</span>
|
||||
<span v-if="isCurrent" class="sc-badge cur">当前</span>
|
||||
<span v-else-if="disabled" class="sc-badge off">停用</span>
|
||||
</div>
|
||||
<p class="sc-code">{{ site.path || site.code }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="stats.length" class="sc-meta">
|
||||
<span v-for="st in stats" :key="st.lbl">{{ st.lbl }} <b>{{ st.num }}</b></span>
|
||||
</div>
|
||||
|
||||
<div class="sc-theme">
|
||||
<span class="thumb" :style="themeThumb ? { backgroundImage: `url(${themeThumb})` } : null"></span>
|
||||
<span class="tname" :title="themeTitle">
|
||||
{{ themeTitle }}
|
||||
<span v-if="site.theme" class="tspec">{{ site.theme }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="sc-domain">
|
||||
<Icon type="ios-globe-outline" />
|
||||
<code :title="portalUrlOf(site)">{{ portalUrlOf(site) }}</code>
|
||||
</p>
|
||||
|
||||
<div class="sc-actions">
|
||||
<Button type="primary" size="small" :disabled="enterDisabled" @click="emit('enter', site)">
|
||||
进入管理
|
||||
</Button>
|
||||
<Button size="small" @click="emit('setting', site)">站点设置</Button>
|
||||
<Button size="small" @click="emit('portal', site)">门户</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 与超管 SchoolWorkbench 的卡片语法同构:白底 8px 圆角 + 1px 边 + hover 抬升 2px */
|
||||
.site-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
transition: box-shadow 0.2s, transform 0.2s, border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(31, 42, 58, 0.12);
|
||||
transform: translateY(-2px);
|
||||
border-color: #c9d6fb;
|
||||
}
|
||||
/* 当前操作站点:4px 左色带 + 蓝描边 + 淡蓝光环。
|
||||
与 SiteMainCard 同一套语义 —— 全页同时只有一张卡带「选中」,不再出现第二处蓝色标识。 */
|
||||
&.is-cur {
|
||||
border-color: #9db4f7;
|
||||
box-shadow: 0 0 0 2px rgba(87, 122, 251, 0.12);
|
||||
}
|
||||
&.is-cur::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: #577afb;
|
||||
}
|
||||
&.disabled {
|
||||
opacity: 0.62;
|
||||
background: #fafbfc;
|
||||
&:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 1px 2px rgba(31, 42, 58, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
.sc-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
/* 身份色:主站 #577afb / 子站 #ff9900(与 SiteSwitcher 定稿身份色一致) */
|
||||
.sc-ico {
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
background: #577afb;
|
||||
&.sub {
|
||||
background: #ff9900;
|
||||
}
|
||||
&.off {
|
||||
background: #c0c4cc;
|
||||
}
|
||||
}
|
||||
.sc-id {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.sc-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #17233d;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.sc-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
&.sub {
|
||||
background: #ff9900;
|
||||
}
|
||||
&.cur {
|
||||
background: #2ba471;
|
||||
}
|
||||
&.off {
|
||||
background: #c0c4cc;
|
||||
}
|
||||
}
|
||||
.sc-code {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
}
|
||||
.sc-meta {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin: 11px 0 0;
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
b {
|
||||
color: #17233d;
|
||||
}
|
||||
}
|
||||
.sc-theme {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #eceff3;
|
||||
font-size: 12px;
|
||||
color: #656667;
|
||||
.thumb {
|
||||
width: 52px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e8eaec;
|
||||
flex-shrink: 0;
|
||||
background: #f0f2f5 center/cover no-repeat;
|
||||
}
|
||||
.tname {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tspec {
|
||||
color: #a0a6b0;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
.sc-domain {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
code {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
color: #656667;
|
||||
background: #f5f7fa;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.sc-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 14px 0 0;
|
||||
/* 三个动作等宽铺满(iview Button 是子组件根节点,scoped 属性会下发到根 DOM) */
|
||||
.ivu-btn {
|
||||
flex: 1;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { nextTick, reactive, ref, watch } from "vue";
|
||||
import {
|
||||
SITE_CONFIG_FIELD_DEFS,
|
||||
SITE_CONFIG_GROUPS,
|
||||
SiteConfigExtraRow,
|
||||
SiteConfigFieldDef,
|
||||
SiteConfigFormModel,
|
||||
SiteConfigGroup,
|
||||
extraRowError,
|
||||
firstExtraError,
|
||||
parseSiteConfig,
|
||||
serializeSiteConfig,
|
||||
validateFieldValue,
|
||||
} from "@/utils/siteConfigForm";
|
||||
import ImageAssetField from "@/components/ImageAssetField.vue";
|
||||
import FooterPreview from "@/components/FooterPreview.vue";
|
||||
|
||||
/**
|
||||
* 站点配置结构化表单(docs/09 §3.1;docs/16 §3.3 分区作用域扩展):
|
||||
* 站点配置结构化表单(docs/09 §3.1;docs/16 §3.3 分区作用域;docs/20 F6 扩展参数区退役):
|
||||
* v-model 绑定整页共享的 configJson 字符串(单一事实源)。
|
||||
* props.groups 声明本分区渲染的分组;props.showExtras 控制扩展参数编辑区(每页仅一处)。
|
||||
* 模型仍完整解析全部键(fields/extras/preserved 三通道),仅渲染限定分组——
|
||||
* 序列化时未渲染分区的值原样回写,保证多分区实例绑定同一 configJson 互不踩踏。
|
||||
* 保存前由父级调用 validate()(含扩展行兜底校验)。
|
||||
* props.groups 声明本分区渲染的分组。模型仍完整解析全部键(fields/preserved 双通道),
|
||||
* 仅渲染限定分组——序列化时未渲染分区的值原样回写,保证多分区实例绑定同一 configJson 互不踩踏。
|
||||
* 保存前由父级调用 validate()。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
/** 渲染的配置分组(缺省=全部分组,兼容旧整表单用法) */
|
||||
groups?: SiteConfigGroup[];
|
||||
/** 是否渲染扩展参数编辑区(docs/16:仅「扩展参数」分区开启) */
|
||||
showExtras?: boolean;
|
||||
/** 图片类字段上传归属站点(附件场景引用) */
|
||||
siteId?: number;
|
||||
}>();
|
||||
@@ -40,9 +34,10 @@ const groupSet = (() => new Set(props.groups || SITE_CONFIG_GROUPS.map(g => g.na
|
||||
const visibleGroups = SITE_CONFIG_GROUPS.filter(g => groupSet.has(g.name));
|
||||
|
||||
const formRef = ref();
|
||||
const formModel = reactive<{ fields: Record<string, string>; extras: SiteConfigExtraRow[] }>({
|
||||
/** 组件根元素(校验失败时定位滚动用) */
|
||||
const rootEl = ref<HTMLElement | null>(null);
|
||||
const formModel = reactive<{ fields: Record<string, string> }>({
|
||||
fields: {},
|
||||
extras: [],
|
||||
});
|
||||
let preserved: Record<string, unknown> = {};
|
||||
let lastEmitted: string | null = null;
|
||||
@@ -51,7 +46,6 @@ function parseIn(v: string) {
|
||||
const m: SiteConfigFormModel = parseSiteConfig(v);
|
||||
Object.keys(formModel.fields).forEach(k => delete formModel.fields[k]);
|
||||
Object.assign(formModel.fields, m.fields);
|
||||
formModel.extras.splice(0, formModel.extras.length, ...m.extras.map(r => ({ ...r })));
|
||||
preserved = m.preserved;
|
||||
}
|
||||
|
||||
@@ -65,38 +59,65 @@ watch(
|
||||
);
|
||||
|
||||
function emitUp() {
|
||||
lastEmitted = serializeSiteConfig({
|
||||
const next = serializeSiteConfig({
|
||||
fields: { ...formModel.fields },
|
||||
extras: formModel.extras.map(r => ({ ...r })),
|
||||
preserved,
|
||||
});
|
||||
emit("update:modelValue", lastEmitted);
|
||||
}
|
||||
|
||||
function addExtra() {
|
||||
formModel.extras.push({ key: "", value: "" });
|
||||
}
|
||||
|
||||
function removeExtra(i: number) {
|
||||
formModel.extras.splice(i, 1);
|
||||
emitUp();
|
||||
}
|
||||
|
||||
/** 保存前校验:内置字段 Form 规则 + 扩展行兜底(仅渲染扩展区的实例校验,避免越区拦截);通过返回 true */
|
||||
async function validate(): Promise<boolean> {
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) return false;
|
||||
if (props.showExtras) {
|
||||
const extraMsg = firstExtraError(formModel.extras);
|
||||
if (extraMsg) {
|
||||
Message.error(`扩展参数有误:${extraMsg}`);
|
||||
return false;
|
||||
}
|
||||
// docs/20 返工 #2 no-op 守卫:本次序列化结果与当前传入 modelValue 经同一解析/序列化管线归一后
|
||||
// 语义等价则不回写——挂载规范化、编辑后撤销往返不再触发父级假脏(保存路径读 ownFields() 取
|
||||
// 实时表单模型,此处跳发不会造成取值滞后)。
|
||||
if (next === serializeSiteConfig(parseSiteConfig(props.modelValue))) {
|
||||
return;
|
||||
}
|
||||
return true;
|
||||
lastEmitted = next;
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
defineExpose({ validate });
|
||||
/**
|
||||
* 保存前校验(docs/20 返工 #4,契约由 boolean 改为 string | null):
|
||||
* 返回 null = 通过;失败返回首个错误的原因文案(「字段名:原因」)。
|
||||
* 本分区无必填字段(空值恒通过),只拦「填了但格式非法」的值;iView 内联红框 + 字段下方
|
||||
* 错误文案由 Form.validate 标注,此处负责把首个出错字段滚动到可视区。
|
||||
*/
|
||||
async function validate(): Promise<string | null> {
|
||||
const form = formRef.value;
|
||||
if (!form) {
|
||||
// REWORK-DECISION: Form 未挂载视为无可校验内容(全字段可选,空值恒通过),不拦保存
|
||||
return null;
|
||||
}
|
||||
const ok = await form.validate();
|
||||
if (ok) {
|
||||
return null;
|
||||
}
|
||||
await nextTick();
|
||||
const firstErrorItem = rootEl.value?.querySelector<HTMLElement>(".ivu-form-item-error") || null;
|
||||
firstErrorItem?.scrollIntoView?.({ block: "center", behavior: "smooth" });
|
||||
const label = firstErrorItem?.querySelector(".ivu-form-item-label")?.textContent?.trim() || "";
|
||||
const tip = firstErrorItem?.querySelector(".ivu-form-item-error-tip")?.textContent?.trim() || "";
|
||||
if (label && tip) {
|
||||
return `${label}:${tip}`;
|
||||
}
|
||||
return tip || "存在填写不合法的字段,请检查表单中标红项";
|
||||
}
|
||||
|
||||
/**
|
||||
* F1(docs/20)Tab 独立保存:仅导出本实例渲染分组的字段值(空值剔除)。
|
||||
* 父级把返回值合并进服务端事实源 configJson 后提交——其他 Tab 的未保存修改不会被连带提交。
|
||||
*/
|
||||
function ownFields(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
visibleGroups.forEach(g => {
|
||||
defsOf(g.name).forEach(d => {
|
||||
const v = (formModel.fields[d.key] || "").trim();
|
||||
if (v) {
|
||||
out[d.key] = v;
|
||||
}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
defineExpose({ validate, ownFields });
|
||||
|
||||
function rulesFor(def: SiteConfigFieldDef) {
|
||||
return [
|
||||
@@ -115,7 +136,7 @@ function defsOf(group: string): SiteConfigFieldDef[] {
|
||||
}</script>
|
||||
|
||||
<template>
|
||||
<div class="site-config-form">
|
||||
<div ref="rootEl" class="site-config-form">
|
||||
<Form ref="formRef" :model="formModel" :label-width="130">
|
||||
<template v-for="g in visibleGroups" :key="g.name">
|
||||
<div class="group-title">{{ g.title }}</div>
|
||||
@@ -131,6 +152,7 @@ function defsOf(group: string): SiteConfigFieldDef[] {
|
||||
v-model="formModel.fields[def.key]"
|
||||
:site-id="siteId"
|
||||
:placeholder="def.placeholder"
|
||||
:aspect="def.aspect"
|
||||
@update:model-value="emitUp"
|
||||
/>
|
||||
<Input
|
||||
@@ -149,22 +171,22 @@ function defsOf(group: string): SiteConfigFieldDef[] {
|
||||
/>
|
||||
<p v-if="def.tip" class="cfg-tip">{{ def.tip }}</p>
|
||||
</FormItem>
|
||||
</template>
|
||||
|
||||
<template v-if="showExtras">
|
||||
<div class="group-title">扩展参数</div>
|
||||
<div v-for="(row, i) in formModel.extras" :key="i" class="extra-row">
|
||||
<Input v-model="row.key" placeholder="参数名" style="width: 200px" @on-change="emitUp" />
|
||||
<Input v-model="row.value" placeholder="参数值" style="flex: 1" @on-change="emitUp" />
|
||||
<Button type="text" class="extra-del" icon="md-trash" @click="removeExtra(i)" />
|
||||
<p v-if="extraRowError(formModel.extras, i)" class="cfg-error">
|
||||
{{ extraRowError(formModel.extras, i) }}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="dashed" long icon="md-add" class="extra-add" @click="addExtra">新增参数</Button>
|
||||
<p class="cfg-tip">
|
||||
面向后续扩展的预留配置项(键名仅字母/数字/下划线);系统托管参数(如 theme_params)不在此显示,保存时自动保留。
|
||||
</p>
|
||||
<!-- F5(docs/20):页脚分组底部固定结构示意图(字段↔视觉位置一一对应,实时回显) -->
|
||||
<FooterPreview
|
||||
v-if="g.name === 'footer'"
|
||||
:contact="formModel.fields.contact"
|
||||
:footer-addresses="formModel.fields.footer_addresses"
|
||||
:qr-wechat="formModel.fields.qr_wechat"
|
||||
:qr-weibo="formModel.fields.qr_weibo"
|
||||
:socials="[
|
||||
{ label: '微信', value: formModel.fields.social_wechat },
|
||||
{ label: '微博', value: formModel.fields.social_weibo },
|
||||
{ label: '抖音', value: formModel.fields.social_douyin },
|
||||
{ label: 'B站', value: formModel.fields.social_bilibili },
|
||||
]"
|
||||
:copyright="formModel.fields.copyright"
|
||||
:beian="formModel.fields.beian"
|
||||
/>
|
||||
</template>
|
||||
</Form>
|
||||
</div>
|
||||
@@ -190,27 +212,5 @@ function defsOf(group: string): SiteConfigFieldDef[] {
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cfg-error {
|
||||
color: #ed4014;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.extra-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
.extra-del {
|
||||
color: #999;
|
||||
&:hover {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
}
|
||||
.extra-add {
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { assetUrl } from "@/api/module/cms";
|
||||
import { portalUrlOf } from "@/utils/sitePath";
|
||||
import { SiteVo, ThemeVo } from "@/types/cms";
|
||||
|
||||
/**
|
||||
* 学校主站宽卡(docs/22):站点工作台的层级锚点。
|
||||
* 主站唯一、子站都挂在它下面 —— 横向宽卡独占一行(左侧 4px 主色带),
|
||||
* 与子站网格在版式上分区;层级不靠「所属主站」列的文字表达(那是旧表格的做法)。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
site: SiteVo;
|
||||
isCurrent: boolean;
|
||||
theme?: ThemeVo;
|
||||
/** 本校院系子站总数(由父级按全量行统计,搜索态下不随过滤变化) */
|
||||
subCount: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "enter", site: SiteVo): void;
|
||||
(e: "setting", site: SiteVo): void;
|
||||
(e: "portal", site: SiteVo): void;
|
||||
}>();
|
||||
|
||||
const disabled = computed(() => props.site.status !== 1);
|
||||
const enterDisabled = computed(() => disabled.value || props.isCurrent);
|
||||
|
||||
/** 统计:子站数本地可算(必显);栏目/文章同 SiteCard —— 后端补齐字段后自动出现 */
|
||||
const stats = computed(() => {
|
||||
const s = props.site as any;
|
||||
const list: { num: string | number; lbl: string }[] = [];
|
||||
if (s.categoryCount != null) list.push({ num: s.categoryCount, lbl: "栏目" });
|
||||
if (s.articleCount != null) list.push({ num: s.articleCount, lbl: "文章" });
|
||||
list.push({ num: props.subCount, lbl: "子站" });
|
||||
return list;
|
||||
});
|
||||
|
||||
const themeThumb = computed(() => (props.theme?.screenshotUrl ? assetUrl(props.theme.screenshotUrl) : ""));
|
||||
const themeTitle = computed(() => props.theme?.title || props.site.theme || "default");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="main-card" :class="{ 'is-cur': isCurrent, disabled }">
|
||||
<div class="mc-body">
|
||||
<div class="mc-left">
|
||||
<span class="sc-ico" :class="disabled ? 'off' : ''">
|
||||
<Icon type="ios-school" />
|
||||
</span>
|
||||
<div class="sc-id">
|
||||
<div class="sc-name">
|
||||
<h4 :title="site.name">{{ site.name }}</h4>
|
||||
<span class="sc-badge main">主站</span>
|
||||
<span v-if="isCurrent" class="sc-badge cur">当前</span>
|
||||
<span v-else-if="disabled" class="sc-badge off">停用</span>
|
||||
</div>
|
||||
<p class="sc-code">{{ site.code }} · {{ portalUrlOf(site) }}</p>
|
||||
<div class="sc-theme">
|
||||
<span class="thumb" :style="themeThumb ? { backgroundImage: `url(${themeThumb})` } : null"></span>
|
||||
<span class="tname" :title="themeTitle">
|
||||
{{ themeTitle }}
|
||||
<span v-if="site.theme" class="tspec">{{ site.theme }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mc-stats">
|
||||
<div class="st" v-for="st in stats" :key="st.lbl">
|
||||
<div class="num">{{ st.num }}</div>
|
||||
<div class="lbl">{{ st.lbl }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mc-actions">
|
||||
<Button type="primary" :disabled="enterDisabled" @click="emit('enter', site)">进入管理</Button>
|
||||
<Button @click="emit('setting', site)">站点设置</Button>
|
||||
<Button @click="emit('portal', site)">门户预览</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 主站宽卡:与子站卡共用同一套「当前站」语义 —— 4px 左色带 + 蓝描边 + 淡蓝光环,
|
||||
全页同时只有一张卡带选中样式(原色带常驻表达「根节点」,会被误读成选中态)。
|
||||
层级改由版式表达:宽卡独占一行 + 「主站」徽章 + 「N 子站」统计。
|
||||
色带走 ::before 绝对定位而非 border-left —— 用边框会在切换态时把内容挤偏 3px。 */
|
||||
.main-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 26px;
|
||||
&.is-cur {
|
||||
border-color: #9db4f7;
|
||||
box-shadow: 0 0 0 2px rgba(87, 122, 251, 0.12);
|
||||
}
|
||||
&.is-cur::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: #577afb;
|
||||
}
|
||||
&.disabled {
|
||||
opacity: 0.62;
|
||||
background: #fafbfc;
|
||||
}
|
||||
}
|
||||
.mc-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.mc-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.sc-ico {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 19px;
|
||||
color: #fff;
|
||||
background: #577afb;
|
||||
&.off {
|
||||
background: #c0c4cc;
|
||||
}
|
||||
}
|
||||
.sc-id {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.sc-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
color: #17233d;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.sc-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
&.main {
|
||||
background: #577afb;
|
||||
}
|
||||
&.cur {
|
||||
background: #2ba471;
|
||||
}
|
||||
&.off {
|
||||
background: #c0c4cc;
|
||||
}
|
||||
}
|
||||
.sc-code {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
}
|
||||
.sc-theme {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: #656667;
|
||||
.thumb {
|
||||
width: 52px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e8eaec;
|
||||
flex-shrink: 0;
|
||||
background: #f0f2f5 center/cover no-repeat;
|
||||
}
|
||||
.tname {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tspec {
|
||||
color: #a0a6b0;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
.mc-stats {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
.st {
|
||||
padding: 0 18px;
|
||||
border-left: 1px solid #eef0f3;
|
||||
text-align: center;
|
||||
&:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
.num {
|
||||
font-size: 19px;
|
||||
font-weight: bold;
|
||||
color: #17233d;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 11px;
|
||||
color: #a0a6b0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
.mc-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { categoryApi } from "@/api/module/cms";
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import BlockTypeThumb from "@/components/BlockTypeThumb.vue";
|
||||
import IconGrid from "@/components/IconGrid.vue";
|
||||
import { iconLabelOf } from "./icons";
|
||||
import {
|
||||
BadgeItem,
|
||||
BlockType,
|
||||
HOME_LAYOUT_VERSION,
|
||||
LayoutBlock,
|
||||
LINK_NAME_MAX,
|
||||
QUICK_LINK_MAX,
|
||||
STATS_MAX,
|
||||
StatItem,
|
||||
TOPICS_MAX,
|
||||
TopicItem,
|
||||
BADGES_MAX,
|
||||
flattenListCategories,
|
||||
genBlockId,
|
||||
genFromCategories,
|
||||
@@ -17,16 +28,19 @@ import {
|
||||
import { BLOCK_TYPES, blockDefOf, blockSummary } from "./blockTypes";
|
||||
|
||||
/**
|
||||
* 首页布局编辑器(docs/16 主线B §4.3):嵌入站点设置「首页布局」分区。
|
||||
* v-model 绑定整页共享的 configJson(仅读写 home_layout 键,其余键无损回写)。
|
||||
* 左侧区块序列(上移/下移/隐藏/删除)+ 右侧属性面板(注册表 schema 驱动);
|
||||
* 添加抽屉列组件库类型卡;「从当前栏目一键生成」复刻现状渲染为初始序列(首用救生索)。
|
||||
* 保存由父分区发起(emit("save")),校验经 defineExpose.validate()。
|
||||
* 首页布局编辑器(docs/16 主线B → docs/20 F2 三栏升级):
|
||||
* 左=区块序列(类型小图标 + 高度示意骨架)· 中=实时预览 iframe(加载正式门户,
|
||||
* 编排变更 debounce 500ms postMessage 注入近似模拟,含手动刷新兜底,D-3 方案 B)· 右=属性面板(schema 驱动)。
|
||||
* 添加抽屉类型卡与「展示形态」RadioCard 均配 SVG 缩略示意图(R-40)。
|
||||
* 首用自动编排(D-4):无 home_layout 键且站点有列表型栏目时进入即自动生成初始序列;
|
||||
* 显式空布局(blocks=[]) 是用户选择,不再自动生成。v-model 仍只读写 home_layout 键。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
/** 布局数据源栏目树归属站点(编辑模式必传) */
|
||||
siteId?: number;
|
||||
/** 实时预览 iframe 的正式门户地址(编辑模式传入;创建模式无站点则隐藏预览栏) */
|
||||
portalUrl?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", v: string): void;
|
||||
@@ -60,8 +74,16 @@ function emitUp() {
|
||||
: current
|
||||
? { version: HOME_LAYOUT_VERSION, blocks: [] }
|
||||
: null;
|
||||
lastEmitted = serializeHomeLayout(props.modelValue, layout);
|
||||
emit("update:modelValue", lastEmitted);
|
||||
const next = serializeHomeLayout(props.modelValue, layout);
|
||||
// docs/20 返工 #2 no-op 守卫:与当前 modelValue 严格相等则不回写(等价操作往返后父级不重标脏)。
|
||||
// 此处必须用严格相等而非语义比较:保存路径 layoutPatchOf 读的是父级 layoutModel,跳发一个
|
||||
// 「语义等价但字符串不同」的值会让父级停留在旧序列化形态,保存时落库旧状态。
|
||||
if (next === props.modelValue) {
|
||||
return;
|
||||
}
|
||||
lastEmitted = next;
|
||||
emit("update:modelValue", next);
|
||||
schedulePreview();
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -77,19 +99,108 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/** 已完成树加载的站点 id(幂等标记;null=未加载或上次失败可重试) */
|
||||
let loadedTreeSiteId: number | null = null;
|
||||
|
||||
async function loadTree() {
|
||||
if (!props.siteId) return;
|
||||
// REWORK-DECISION: 幂等保护(返工清单 #1-3)——同一 siteId 已加载过不重复请求,避免 onMounted + watch 双触发;
|
||||
// 请求失败则清标记允许后续重试
|
||||
if (loadedTreeSiteId === props.siteId) return;
|
||||
loadedTreeSiteId = props.siteId;
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
tree.value = (await categoryApi.tree(props.siteId).send()) || [];
|
||||
} catch {
|
||||
tree.value = [];
|
||||
loadedTreeSiteId = null;
|
||||
} finally {
|
||||
treeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTree);
|
||||
/**
|
||||
* 站点上下文就绪后的数据初始化(docs/20 返工 #1):
|
||||
* ① 只要 siteId 有值就无条件加载栏目树(「数据栏目/人物分组」数据源),与「是否首用自动编排」解耦;
|
||||
* ② 首用自动编排(D-4)复用已加载的树,不再独立承担加载职责。
|
||||
* siteId 由父级异步种入(晚于本组件挂载)时由 watch 补跑。
|
||||
*/
|
||||
async function initSiteData() {
|
||||
if (!props.siteId) return;
|
||||
await loadTree();
|
||||
if (parseHomeLayout(props.modelValue)) return;
|
||||
if (!blocks.value.length && flat.value.length) {
|
||||
blocks.value = genFromCategories(tree.value, props.modelValue).blocks;
|
||||
selectedId.value = null;
|
||||
emitUp();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(initSiteData);
|
||||
watch(
|
||||
() => props.siteId,
|
||||
id => {
|
||||
if (id) initSiteData();
|
||||
},
|
||||
);
|
||||
|
||||
// ===== 中栏:实时预览(iframe + 视口切换 + postMessage 近似模拟 + 手动刷新兜底)=====
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ value: 1200, label: "桌面 1200" },
|
||||
{ value: 768, label: "平板 768" },
|
||||
{ value: 390, label: "手机 390" },
|
||||
] as const;
|
||||
const viewport = ref<number>(1200);
|
||||
/** iframe 重载键(key 变更 = 重新挂载 = 重新加载正式门户) */
|
||||
const frameKey = ref(0);
|
||||
const frameWrap = ref<HTMLElement | null>(null);
|
||||
/** 预览容器宽 → 缩放比(视口宽超出容器时等比缩小,保证整页可见) */
|
||||
const frameScale = computed(() => {
|
||||
const w = frameWrap.value?.clientWidth || 0;
|
||||
return w && viewport.value > w ? w / viewport.value : 1;
|
||||
});
|
||||
const frameHeight = computed(() => Math.round(560 * (1 / frameScale.value)));
|
||||
/** 预览就绪/模拟状态提示(注入成功置"已同步",未保存新区块走手动刷新) */
|
||||
const previewState = ref("");
|
||||
|
||||
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** 编排变更 → debounce 500ms → postMessage 注入(R-41) */
|
||||
function schedulePreview() {
|
||||
if (!props.portalUrl) return;
|
||||
if (previewTimer) clearTimeout(previewTimer);
|
||||
previewTimer = setTimeout(sendPreview, 500);
|
||||
}
|
||||
|
||||
function sendPreview() {
|
||||
const frame = document.querySelector<HTMLIFrameElement>(".hle-preview-frame iframe");
|
||||
const win = frame?.contentWindow;
|
||||
if (!win) return;
|
||||
const order = blocks.value.filter(b => b.visible).map(b => b.id);
|
||||
try {
|
||||
win.postMessage({ type: "cms-layout-preview", order }, "*");
|
||||
previewState.value = "已同步未保存的顺序/显隐(新区块保存后可见)";
|
||||
} catch {
|
||||
previewState.value = "预览注入不可用,请点「刷新预览」";
|
||||
}
|
||||
}
|
||||
|
||||
function reloadPreview() {
|
||||
frameKey.value++;
|
||||
previewState.value = "";
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (previewTimer) clearTimeout(previewTimer);
|
||||
});
|
||||
|
||||
/** 保存成功后由父级调用:重载 iframe 呈现真实效果(验收 5「保存即可见」闭环) */
|
||||
function refreshAfterSave() {
|
||||
reloadPreview();
|
||||
}
|
||||
|
||||
// ===== 序列操作 =====
|
||||
|
||||
function addBlock(type: string) {
|
||||
const def = blockDefOf(type);
|
||||
@@ -136,6 +247,7 @@ function clearLayout() {
|
||||
selectedId.value = null;
|
||||
lastEmitted = serializeHomeLayout(props.modelValue, null);
|
||||
emit("update:modelValue", lastEmitted);
|
||||
reloadPreview();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -176,13 +288,73 @@ function onFieldChange() {
|
||||
emitUp();
|
||||
}
|
||||
|
||||
/** quick_links 入口行:编辑期内存允许留空行,提交时由 cleanBlock 剔除 */
|
||||
type LinkItems = { name: string; url: string; icon?: string }[];
|
||||
function itemsOf(block: LayoutBlock): LinkItems {
|
||||
return (block.items as LinkItems) || (block.items = [] as unknown as LayoutBlock["items"]);
|
||||
}
|
||||
|
||||
/** D-1 统计项行(编辑期允许空行,提交时 cleanBlock 剔除) */
|
||||
function statsOf(block: LayoutBlock): StatItem[] {
|
||||
return block.stats || (block.stats = []);
|
||||
}
|
||||
function addStat(block: LayoutBlock) {
|
||||
if (statsOf(block).length >= STATS_MAX) return;
|
||||
statsOf(block).push({ label: "", value: "", unit: "" });
|
||||
emitUp();
|
||||
}
|
||||
function removeStat(block: LayoutBlock, index: number) {
|
||||
statsOf(block).splice(index, 1);
|
||||
emitUp();
|
||||
}
|
||||
|
||||
/** D-3 专题卡行(编辑器模型 topicItems,cleanBlock 序列化为契约键 items) */
|
||||
function topicsOf(block: LayoutBlock): TopicItem[] {
|
||||
return block.topicItems || (block.topicItems = []);
|
||||
}
|
||||
function addTopic(block: LayoutBlock) {
|
||||
if (topicsOf(block).length >= TOPICS_MAX) return;
|
||||
topicsOf(block).push({ title: "", image: "" });
|
||||
emitUp();
|
||||
}
|
||||
function removeTopic(block: LayoutBlock, index: number) {
|
||||
topicsOf(block).splice(index, 1);
|
||||
emitUp();
|
||||
}
|
||||
|
||||
/** D-9 徽章行 */
|
||||
function badgesOf(block: LayoutBlock): BadgeItem[] {
|
||||
return block.badgeItems || (block.badgeItems = []);
|
||||
}
|
||||
function addBadge(block: LayoutBlock) {
|
||||
if (badgesOf(block).length >= BADGES_MAX) return;
|
||||
badgesOf(block).push({ label: "", image: "" });
|
||||
emitUp();
|
||||
}
|
||||
function removeBadge(block: LayoutBlock, index: number) {
|
||||
badgesOf(block).splice(index, 1);
|
||||
emitUp();
|
||||
}
|
||||
|
||||
function addLink(block: LayoutBlock) {
|
||||
const items = itemsOf(block);
|
||||
if (items.length >= QUICK_LINK_MAX) return;
|
||||
items.push({ name: "", url: "" });
|
||||
emitUp();
|
||||
}
|
||||
|
||||
function removeLink(block: LayoutBlock, index: number) {
|
||||
itemsOf(block).splice(index, 1);
|
||||
emitUp();
|
||||
}
|
||||
|
||||
/** 保存前校验(父分区调用):返回 null=通过,否则错误消息串 */
|
||||
function validate(): string | null {
|
||||
const errors = validateHomeLayout({ version: HOME_LAYOUT_VERSION, blocks: blocks.value });
|
||||
return errors.length ? errors.join(";") : null;
|
||||
}
|
||||
|
||||
defineExpose({ validate });
|
||||
defineExpose({ validate, refreshAfterSave });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -202,8 +374,9 @@ defineExpose({ validate });
|
||||
<p class="hle-empty-sub">点「从当前栏目一键生成」得到与现状等价的初始序列,再删改微调;或从空白开始添加区块。</p>
|
||||
</div>
|
||||
|
||||
<!-- 序列 + 属性面板 -->
|
||||
<!-- 三栏:序列 + 实时预览 + 属性面板 -->
|
||||
<div v-else class="hle-body">
|
||||
<!-- 左:区块序列(图标 + 高度示意骨架 + 摘要) -->
|
||||
<div class="hle-sequence">
|
||||
<div
|
||||
v-for="(b, i) in blocks"
|
||||
@@ -212,6 +385,10 @@ defineExpose({ validate });
|
||||
:class="{ active: b.id === selectedId, hidden: !b.visible }"
|
||||
@click="selectedId = b.id"
|
||||
>
|
||||
<!-- 返工 #3:序号徽标按数组位置编号(含隐藏区块,不重排),与 validateHomeLayout
|
||||
的「第 ${i+1} 个区块」报错口径一一对应 -->
|
||||
<span class="hle-row-no">{{ i + 1 }}</span>
|
||||
<span class="hle-row-vis" :class="`vis-${b.type}${b.type === 'article_list' ? '-' + (b.style || 'card') : ''}`"></span>
|
||||
<span class="hle-row-main">
|
||||
<Icon :type="blockDefOf(b.type)?.icon || 'md-square'" class="hle-row-icon" />
|
||||
<span class="hle-row-title">{{ b.title || blockDefOf(b.type)?.title || b.type }}</span>
|
||||
@@ -227,6 +404,30 @@ defineExpose({ validate });
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中:实时预览(正式门户 iframe,方案 B 近似模拟) -->
|
||||
<div class="hle-preview">
|
||||
<div class="hle-preview-bar">
|
||||
<RadioGroup v-model="viewport" size="small" @on-change="schedulePreview">
|
||||
<Radio v-for="v in VIEWPORTS" :key="v.value" :label="v.value">{{ v.label }}</Radio>
|
||||
</RadioGroup>
|
||||
<Button size="small" icon="md-refresh" @click="reloadPreview">刷新预览</Button>
|
||||
</div>
|
||||
<div ref="frameWrap" class="hle-preview-frame">
|
||||
<iframe
|
||||
:key="frameKey"
|
||||
:src="portalUrl"
|
||||
:style="{ width: viewport + 'px', height: frameHeight + 'px', transform: `scale(${frameScale})` }"
|
||||
title="门户首页实时预览"
|
||||
loading="lazy"
|
||||
@load="previewState = ''"
|
||||
></iframe>
|
||||
</div>
|
||||
<p class="hle-preview-tip">
|
||||
{{ previewState || "预览为未保存编排的近似模拟(顺序/显隐即时注入;新增或改属性区块保存后点「刷新预览」可见)" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 右:属性面板 -->
|
||||
<div class="hle-props">
|
||||
<template v-if="selected && selectedDef">
|
||||
<div class="hle-props-head">
|
||||
@@ -236,10 +437,23 @@ defineExpose({ validate });
|
||||
</div>
|
||||
<div v-for="f in visibleFields" :key="f.key" class="hle-field">
|
||||
<label class="hle-field-label">{{ f.label }}</label>
|
||||
<!-- F2/R-40:展示形态 = RadioCard 可视化选择(缩略图 + 文字),不盲选下拉 -->
|
||||
<div v-if="f.kind === 'select' && f.card" class="hle-style-cards">
|
||||
<label
|
||||
v-for="o in f.options"
|
||||
:key="o.value"
|
||||
class="hle-style-card"
|
||||
:class="{ active: (selected[f.key] || 'card') === o.value }"
|
||||
@click.prevent="selected![f.key] = o.value; onFieldChange()"
|
||||
>
|
||||
<BlockTypeThumb type="article_list" :variant="o.value" />
|
||||
<span class="hle-style-name">{{ o.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<Input
|
||||
v-if="f.kind === 'text'"
|
||||
v-else-if="f.kind === 'text'"
|
||||
v-model="selected[f.key]"
|
||||
:maxlength="100"
|
||||
:maxlength="f.maxlength || 100"
|
||||
:placeholder="f.placeholder"
|
||||
@on-change="onFieldChange"
|
||||
/>
|
||||
@@ -277,6 +491,99 @@ defineExpose({ validate });
|
||||
<p v-if="!flat.length" class="hle-cats-empty">暂无列表型栏目</p>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="f.kind === 'links'" class="hle-links">
|
||||
<div v-for="(l, li) in itemsOf(selected!)" :key="li" class="hle-link-row">
|
||||
<Input
|
||||
v-model="l.name"
|
||||
class="hle-link-name"
|
||||
:maxlength="LINK_NAME_MAX"
|
||||
placeholder="入口名称"
|
||||
@on-change="onFieldChange"
|
||||
/>
|
||||
<Input v-model="l.url" placeholder="https://… 或 / 站内路径" @on-change="onFieldChange" />
|
||||
<Poptip placement="bottom" width="300" transfer>
|
||||
<Button size="small" class="hle-link-icon-btn">
|
||||
{{ l.icon ? iconLabelOf(l.icon) : "图标" }}
|
||||
</Button>
|
||||
<template #content>
|
||||
<IconGrid v-model="l.icon" @update:model-value="onFieldChange" />
|
||||
</template>
|
||||
</Poptip>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon="md-trash"
|
||||
class="hle-link-del"
|
||||
@click="removeLink(selected!, li)"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!itemsOf(selected!).length" class="hle-links-empty">还没有入口,点下方按钮添加</p>
|
||||
<Button
|
||||
class="hle-link-add"
|
||||
long
|
||||
icon="md-add"
|
||||
:disabled="itemsOf(selected!).length >= QUICK_LINK_MAX"
|
||||
@click="addLink(selected!)"
|
||||
>
|
||||
添加入口
|
||||
</Button>
|
||||
</div>
|
||||
<!-- D-1 统计项编辑器 -->
|
||||
<div v-else-if="f.kind === 'stats'" class="hle-rows-editor">
|
||||
<div v-for="(st, si) in statsOf(selected!)" :key="si" class="hle-stat-row">
|
||||
<Input v-model="st.label" class="hle-stat-label" maxlength="20" placeholder="名称(如 在校生)" @on-change="onFieldChange" />
|
||||
<Input v-model="st.value" class="hle-stat-value" maxlength="10" placeholder="数值(如 3.2)" @on-change="onFieldChange" />
|
||||
<Input v-model="st.unit" class="hle-stat-unit" maxlength="10" placeholder="单位(如 万)" @on-change="onFieldChange" />
|
||||
<Button size="small" type="text" icon="md-trash" class="hle-link-del" @click="removeStat(selected!, si)" />
|
||||
</div>
|
||||
<p v-if="!statsOf(selected!).length" class="hle-links-empty">还没有统计项,点下方按钮添加</p>
|
||||
<Button class="hle-link-add" long icon="md-add" :disabled="statsOf(selected!).length >= STATS_MAX" @click="addStat(selected!)">
|
||||
添加统计项
|
||||
</Button>
|
||||
</div>
|
||||
<!-- D-3 专题卡编辑器 -->
|
||||
<div v-else-if="f.kind === 'topics'" class="hle-rows-editor">
|
||||
<div v-for="(t, ti) in topicsOf(selected!)" :key="ti" class="hle-topic-card">
|
||||
<div class="hle-topic-head">
|
||||
<span>专题 {{ ti + 1 }}</span>
|
||||
<Button size="small" type="text" icon="md-trash" class="hle-link-del" @click="removeTopic(selected!, ti)" />
|
||||
</div>
|
||||
<Input v-model="t.title" maxlength="50" placeholder="专题标题(如 2026 招生专题)" @on-change="onFieldChange" />
|
||||
<Input v-model="t.image" maxlength="500" placeholder="图片路径 /yschcms/upload/…(附件管理上传后复制)" @on-change="onFieldChange" />
|
||||
<Input v-model="t.description" maxlength="200" placeholder="一句话描述(可选)" @on-change="onFieldChange" />
|
||||
<div class="hle-topic-cta">
|
||||
<Input v-model="t.ctaText" maxlength="20" placeholder="按钮文字(如 进入专题)" @on-change="onFieldChange" />
|
||||
<Input v-model="t.ctaUrl" maxlength="500" placeholder="按钮地址 https://… 或 / 路径" @on-change="onFieldChange" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!topicsOf(selected!).length" class="hle-links-empty">还没有专题卡,点下方按钮添加</p>
|
||||
<Button class="hle-link-add" long icon="md-add" :disabled="topicsOf(selected!).length >= TOPICS_MAX" @click="addTopic(selected!)">
|
||||
添加专题卡
|
||||
</Button>
|
||||
</div>
|
||||
<!-- D-9 徽章编辑器 -->
|
||||
<div v-else-if="f.kind === 'badges'" class="hle-rows-editor">
|
||||
<div v-for="(bd, bi) in badgesOf(selected!)" :key="bi" class="hle-badge-row">
|
||||
<Input v-model="bd.label" class="hle-stat-label" maxlength="20" placeholder="名称(如 软科排名)" @on-change="onFieldChange" />
|
||||
<Input v-model="bd.image" maxlength="500" placeholder="徽章图路径 /yschcms/upload/…" @on-change="onFieldChange" />
|
||||
<Input v-model="bd.desc" class="hle-stat-unit" maxlength="50" placeholder="说明(如 前100强)" @on-change="onFieldChange" />
|
||||
<Button size="small" type="text" icon="md-trash" class="hle-link-del" @click="removeBadge(selected!, bi)" />
|
||||
</div>
|
||||
<p v-if="!badgesOf(selected!).length" class="hle-links-empty">还没有徽章,点下方按钮添加</p>
|
||||
<Button class="hle-link-add" long icon="md-add" :disabled="badgesOf(selected!).length >= BADGES_MAX" @click="addBadge(selected!)">
|
||||
添加徽章
|
||||
</Button>
|
||||
</div>
|
||||
<!-- D-7 人物分组栏目(单选下拉) -->
|
||||
<Select
|
||||
v-else-if="f.kind === 'category'"
|
||||
:model-value="selected.categoryId"
|
||||
clearable
|
||||
placeholder="不选 = 全部人物"
|
||||
@on-change="(v: any) => { selected!.categoryId = v || undefined; onFieldChange(); }"
|
||||
>
|
||||
<Option v-for="c in flat" :key="c.id" :value="c.id">{{ c.name }}</Option>
|
||||
</Select>
|
||||
<p v-if="f.tip" class="hle-field-tip">{{ f.tip }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -284,17 +591,19 @@ defineExpose({ validate });
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加区块抽屉 -->
|
||||
<Drawer v-model="addOpen" title="添加区块" width="420" class="hle-drawer">
|
||||
<!-- 添加区块抽屉(R-40:每类型配缩略示意图) -->
|
||||
<Drawer v-model="addOpen" title="添加区块" width="440" class="hle-drawer">
|
||||
<div
|
||||
v-for="def in BLOCK_TYPES"
|
||||
:key="def.type"
|
||||
class="hle-type-card"
|
||||
@click="addBlock(def.type)"
|
||||
>
|
||||
<Icon :type="def.icon" class="hle-type-icon" />
|
||||
<div class="hle-type-thumb">
|
||||
<BlockTypeThumb :type="def.type" />
|
||||
</div>
|
||||
<span class="hle-type-body">
|
||||
<span class="hle-type-title">{{ def.title }}</span>
|
||||
<span class="hle-type-title"><Icon :type="def.icon" /> {{ def.title }}</span>
|
||||
<span class="hle-type-desc">{{ def.description }}</span>
|
||||
</span>
|
||||
<Icon type="ios-arrow-forward" class="hle-type-go" />
|
||||
@@ -311,10 +620,6 @@ defineExpose({ validate });
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
.hle-gen,
|
||||
.hle-clear {
|
||||
margin-left: 0;
|
||||
}
|
||||
.hle-clear {
|
||||
color: #999;
|
||||
&:hover {
|
||||
@@ -343,20 +648,25 @@ defineExpose({ validate });
|
||||
}
|
||||
.hle-body {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.hle-sequence {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 262px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 640px;
|
||||
overflow: auto;
|
||||
// 首行序号徽标悬浮在行框上沿(-7px),留出顶部空间防裁剪
|
||||
padding-top: 8px;
|
||||
}
|
||||
.hle-row {
|
||||
position: relative;
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
padding: 8px 12px 8px 16px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
&.active {
|
||||
@@ -366,6 +676,50 @@ defineExpose({ validate });
|
||||
&.hidden {
|
||||
opacity: 0.55;
|
||||
}
|
||||
// 返工 #3:右上角序号徽标(悬浮于行框上沿,不与 ops 按钮带重叠)
|
||||
.hle-row-no {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: 6px;
|
||||
min-width: 16px;
|
||||
height: 15px;
|
||||
line-height: 13px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: #e8f3ff;
|
||||
border: 1px solid #9cc7f5;
|
||||
color: #2d8cf0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
&.hidden .hle-row-no {
|
||||
background: #f5f5f5;
|
||||
border-color: #dcdfe6;
|
||||
color: #a8abb2;
|
||||
}
|
||||
.hle-row-vis {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(180deg, #7fb2f0 0%, #4a7fd0 100%);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.hle-row-vis.vis-ticker,
|
||||
.hle-row-vis.vis-notice_bar {
|
||||
top: 45%;
|
||||
background: #2d8cf0;
|
||||
}
|
||||
.hle-row-vis.vis-links,
|
||||
.hle-row-vis.vis-quick_links {
|
||||
top: 20%;
|
||||
}
|
||||
.hle-row-vis.vis-gallery {
|
||||
top: 15%;
|
||||
}
|
||||
.hle-row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -397,13 +751,53 @@ defineExpose({ validate });
|
||||
}
|
||||
}
|
||||
}
|
||||
.hle-preview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 6px;
|
||||
background: #f6f8fb;
|
||||
padding: 8px;
|
||||
.hle-preview-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hle-preview-frame {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 480px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f2;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
iframe {
|
||||
border: 0;
|
||||
transform-origin: top left;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.hle-preview-tip {
|
||||
margin: 6px 2px 0;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
.hle-props {
|
||||
width: 320px;
|
||||
width: 300px;
|
||||
flex: none;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
background: #fafbfc;
|
||||
max-height: 640px;
|
||||
overflow: auto;
|
||||
.hle-props-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -442,6 +836,37 @@ defineExpose({ validate });
|
||||
margin-top: 3px;
|
||||
}
|
||||
}
|
||||
.hle-style-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
.hle-style-card {
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
&:hover {
|
||||
border-color: #5cadff;
|
||||
}
|
||||
&.active {
|
||||
border-color: #2d8cf0;
|
||||
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.12);
|
||||
.hle-style-name {
|
||||
color: #2d8cf0;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.hle-style-name {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
.hle-cats {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
@@ -459,6 +884,76 @@ defineExpose({ validate });
|
||||
margin: 8px 0;
|
||||
}
|
||||
}
|
||||
.hle-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
.hle-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.hle-link-name {
|
||||
flex: none;
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
.hle-link-del:hover {
|
||||
color: #ed4014;
|
||||
}
|
||||
.hle-links-empty {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.hle-link-add {
|
||||
border-style: dashed;
|
||||
}
|
||||
}
|
||||
.hle-rows-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
.hle-stat-row,
|
||||
.hle-badge-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.hle-stat-label {
|
||||
flex: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
.hle-stat-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.hle-stat-unit {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
.hle-topic-card {
|
||||
border: 1px solid #e2e8f2;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
.hle-topic-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #8a93a6;
|
||||
}
|
||||
.hle-topic-cta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.hle-link-icon-btn {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
.hle-type-card {
|
||||
display: flex;
|
||||
@@ -466,7 +961,7 @@ defineExpose({ validate });
|
||||
gap: 10px;
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
@@ -474,9 +969,12 @@ defineExpose({ validate });
|
||||
border-color: #2d8cf0;
|
||||
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.1);
|
||||
}
|
||||
.hle-type-icon {
|
||||
font-size: 22px;
|
||||
color: #2d8cf0;
|
||||
.hle-type-thumb {
|
||||
width: 116px;
|
||||
flex: none;
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hle-type-body {
|
||||
flex: 1;
|
||||
@@ -485,6 +983,10 @@ defineExpose({ validate });
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
.ivu-icon {
|
||||
color: #2d8cf0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
.hle-type-desc {
|
||||
display: block;
|
||||
|
||||
@@ -4,20 +4,24 @@
|
||||
* 编辑器属性面板/添加抽屉按本表 schema 驱动自动出现。
|
||||
* propFields 顺序即属性面板渲染顺序;showIf 控制条件显示(如 background 仅 news_grid)。
|
||||
*/
|
||||
import { LayoutBlock } from "./homeLayout";
|
||||
import { LIST_STYLES, styleLabel } from "./homeLayout";
|
||||
import { LayoutBlock, LINK_NAME_MAX, LIST_STYLES, NOTICE_TEXT_MAX, QUICK_LINK_MAX, styleLabel } from "./homeLayout";
|
||||
|
||||
export interface BlockPropField {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: "text" | "number" | "select" | "switch" | "categories";
|
||||
kind: "text" | "number" | "select" | "switch" | "categories" | "links"
|
||||
| "stats" | "topics" | "badges" | "category" | "video";
|
||||
options?: { value: string; label: string }[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** text 输入框字数上限(缺省 100) */
|
||||
maxlength?: number;
|
||||
placeholder?: string;
|
||||
tip?: string;
|
||||
/** 条件显示(返回 false 时隐藏且不校验) */
|
||||
showIf?: (block: LayoutBlock) => boolean;
|
||||
/** select 的可视化选项卡渲染(RadioCard + 缩略图,F2/R-40:形态选择不盲选) */
|
||||
card?: boolean;
|
||||
}
|
||||
|
||||
export interface BlockTypeDef {
|
||||
@@ -39,11 +43,12 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
|
||||
defaultProps: () => ({ style: "card", size: 8, categoryIds: [] }),
|
||||
propFields: [
|
||||
{ key: "title", label: "区块标题", kind: "text", placeholder: "留空显示第一个栏目名" },
|
||||
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Campus News(主题开双语后显示)" },
|
||||
{
|
||||
key: "categoryIds", label: "数据栏目", kind: "categories",
|
||||
tip: "可多选;渲染时聚合所选栏目的全部子孙栏目文章(文章通常挂叶子栏目)",
|
||||
},
|
||||
{ key: "style", label: "展示形态", kind: "select", options: LIST_STYLES },
|
||||
{ key: "style", label: "展示形态", kind: "select", options: LIST_STYLES, card: true, tip: "卡片缩略图为该形态的典型渲染示意,最终视觉以皮肤包实现为准" },
|
||||
{
|
||||
key: "size", label: "文章条数", kind: "number", min: 1, max: 20,
|
||||
showIf: b => b.style !== "image_wall",
|
||||
@@ -63,6 +68,7 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
|
||||
defaultProps: () => ({ source: "top", size: 6 }),
|
||||
propFields: [
|
||||
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「要闻速递」" },
|
||||
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Latest News(主题开双语后显示)" },
|
||||
{
|
||||
key: "source", label: "数据来源", kind: "select",
|
||||
options: [
|
||||
@@ -86,6 +92,14 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
|
||||
propFields: [
|
||||
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「线上展厅」" },
|
||||
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "默认 Online Exhibition Hall" },
|
||||
{
|
||||
key: "layout", label: "布局形态", kind: "select",
|
||||
options: [
|
||||
{ value: "grid", label: "规整网格" },
|
||||
{ value: "masonry", label: "瀑布流(campus_wall)" },
|
||||
],
|
||||
tip: "瀑布流 = 不等高错落排布,hover 放大;适合校园风光多图",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -96,9 +110,139 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
|
||||
defaultProps: () => ({}),
|
||||
propFields: [
|
||||
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「友情链接」" },
|
||||
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Friendly Links(主题开双语后显示)" },
|
||||
{ key: "group", label: "链接分组", kind: "text", placeholder: "留空显示全部分组" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "quick_links",
|
||||
title: "快捷通道",
|
||||
icon: "md-flash",
|
||||
description: "入口墙:自行填写 1~12 个跳转入口(名称 + 地址),不依赖栏目与友情链接数据",
|
||||
defaultProps: () => ({ items: [{ name: "", url: "" }] }),
|
||||
propFields: [
|
||||
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「快捷通道」" },
|
||||
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Quick Links(主题开双语后显示)" },
|
||||
{
|
||||
key: "items", label: "入口列表", kind: "links",
|
||||
tip: `每个入口 = 名称(${LINK_NAME_MAX} 字内)+ 地址 + 可选图标,最多 ${QUICK_LINK_MAX} 个;未选图标显示首字徽章`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "notice_bar",
|
||||
title: "公告条",
|
||||
icon: "md-volume-up",
|
||||
description: "通栏提示条:一条全站公告(放假/停网/招生等),可选跳转与醒目配色",
|
||||
defaultProps: () => ({ text: "", level: "info" }),
|
||||
propFields: [
|
||||
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「公告」,显示为左侧标签" },
|
||||
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选(主题开双语后显示)" },
|
||||
{
|
||||
key: "text", label: "公告正文", kind: "text", maxlength: NOTICE_TEXT_MAX,
|
||||
placeholder: "如:国庆放假安排通知如下……", tip: `通栏一行展示,建议一句话(${NOTICE_TEXT_MAX} 字内)`,
|
||||
},
|
||||
{ key: "url", label: "跳转地址", kind: "text", placeholder: "可选:http(s):// 或站内 / 路径" },
|
||||
{
|
||||
key: "level", label: "提示级别", kind: "select",
|
||||
options: [
|
||||
{ value: "info", label: "一般(主题色)" },
|
||||
{ value: "warn", label: "醒目(警示红)" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// ===== docs/20 D-1~D-9 新组件(骨架兜底渲染,所有主题可用) =====
|
||||
{
|
||||
type: "stats_band",
|
||||
title: "数字计数带",
|
||||
icon: "md-stats",
|
||||
description: "在校生/教职工/占地/院士等大数字横排,滚动可见时自动计数动画",
|
||||
defaultProps: () => ({ stats: [{ label: "", value: "", unit: "" }] }),
|
||||
propFields: [
|
||||
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「数说学校」" },
|
||||
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Facts & Figures" },
|
||||
{
|
||||
key: "stats", label: "统计项", kind: "stats",
|
||||
tip: "数值支持小数与「万/千」单位(如 3.2 + 万);滚动到可视区时数字从 0 滚动到目标值",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "featured_topics",
|
||||
title: "专题大卡",
|
||||
icon: "md-photos",
|
||||
description: "招生/校庆/双一流等专题:大图 + 标题 + 描述 + 按钮,2~3 张并排",
|
||||
defaultProps: () => ({ topicItems: [{ title: "", image: "" }] }),
|
||||
propFields: [
|
||||
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「专题推荐」" },
|
||||
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Featured Topics" },
|
||||
{
|
||||
key: "items", label: "专题卡", kind: "topics",
|
||||
tip: "图片先在「附件管理」上传后复制路径填入(/ 开头),建议尺寸 800×500",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "video_tour",
|
||||
title: "视频/VR 校园",
|
||||
icon: "md-play-circle",
|
||||
description: "校园宣传片/VR 导览:封面大图 + 播放按钮,点击跳转视频地址",
|
||||
defaultProps: () => ({ cover: "", url: "" }),
|
||||
propFields: [
|
||||
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「云端校园」" },
|
||||
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Virtual Tour" },
|
||||
{
|
||||
key: "cover", label: "封面图地址", kind: "text", maxlength: 500,
|
||||
placeholder: "/yschcms/upload/… 或 https://…(附件管理上传后复制路径)",
|
||||
},
|
||||
{
|
||||
key: "url", label: "视频/VR 地址", kind: "text", maxlength: 500,
|
||||
placeholder: "https://… 或站内 / 路径,点击封面跳转",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "events",
|
||||
title: "学术日历",
|
||||
icon: "md-calendar",
|
||||
description: "学术报告/活动:大日期块 + 活动名 + 地点;数据在「轮播管理 → 学术活动」维护",
|
||||
defaultProps: () => ({}),
|
||||
propFields: [
|
||||
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「学术活动」" },
|
||||
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Academic Events" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "people",
|
||||
title: "名师风采",
|
||||
icon: "md-contacts",
|
||||
description: "圆头像 + 姓名 + 头衔 + 一句话简介卡片;人物数据在「人物管理」维护",
|
||||
defaultProps: () => ({}),
|
||||
propFields: [
|
||||
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「名师风采」" },
|
||||
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Our Faculty" },
|
||||
{
|
||||
key: "categoryId", label: "人物分组栏目", kind: "category",
|
||||
tip: "可选;选择后仅展示挂在该栏目下的人物(人物编辑时指定所属栏目),不选 = 全部人物。人物的新增/编辑在「人物管理」页(/person)维护",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "badges",
|
||||
title: "排名/学科徽章带",
|
||||
icon: "md-ribbon",
|
||||
description: "软科排名 / 学科评估 A+ / ESI 前 1% 等徽章图片 + 文字横排",
|
||||
defaultProps: () => ({ badgeItems: [{ label: "", image: "" }] }),
|
||||
propFields: [
|
||||
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「实力声誉」" },
|
||||
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Rankings" },
|
||||
{
|
||||
key: "badgeItems", label: "徽章列表", kind: "badges",
|
||||
tip: "徽章图片建议透明底 PNG(附件管理上传后复制路径,/ 开头)",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function blockDefOf(type: string): BlockTypeDef | undefined {
|
||||
@@ -117,9 +261,33 @@ export function blockSummary(block: LayoutBlock, namesOf: (id: number) => string
|
||||
parts.push(block.source === "category" ? "指定栏目最新" : "全站置顶");
|
||||
parts.push(`${block.size ?? 6}条`);
|
||||
} else if (block.type === "gallery") {
|
||||
parts.push("线上展厅图集");
|
||||
parts.push(block.layout === "masonry" ? "线上展厅 · 瀑布流" : "线上展厅图集");
|
||||
} else if (block.type === "links") {
|
||||
parts.push(block.group ? `分组:${block.group}` : "全部分组");
|
||||
} else if (block.type === "quick_links") {
|
||||
const filled = (block.items || []).filter(i => (i.name || "").trim() || (i.url || "").trim());
|
||||
parts.push(`${filled.length} 个入口`);
|
||||
} else if (block.type === "notice_bar") {
|
||||
parts.push(block.level === "warn" ? "醒目提示" : "普通提示");
|
||||
const text = (block.text || "").trim();
|
||||
if (text) {
|
||||
parts.push(text.length > 18 ? `${text.slice(0, 18)}…` : text);
|
||||
}
|
||||
} else if (block.type === "stats_band") {
|
||||
const filled = (block.stats || []).filter(st => (st.label || "").trim() || (st.value || "").trim());
|
||||
parts.push(`${filled.length} 项数字`);
|
||||
} else if (block.type === "featured_topics") {
|
||||
const filled = (block.topicItems || []).filter(t => (t.title || "").trim());
|
||||
parts.push(`${filled.length} 张专题卡`);
|
||||
} else if (block.type === "video_tour") {
|
||||
parts.push("封面 + 播放跳转");
|
||||
} else if (block.type === "events") {
|
||||
parts.push("学术活动(轮播管理维护)");
|
||||
} else if (block.type === "people") {
|
||||
parts.push(block.categoryId ? "按分组栏目过滤" : "全部人物");
|
||||
} else if (block.type === "badges") {
|
||||
const filled = (block.badgeItems || []).filter(b => (b.label || "").trim());
|
||||
parts.push(`${filled.length} 个徽章`);
|
||||
}
|
||||
const ids = block.categoryIds || [];
|
||||
if (ids.length) {
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("parseHomeLayout:容错解析(无键/非法→null 回落)", () =
|
||||
expect(layout.blocks.length).toBe(3);
|
||||
expect(layout.blocks[0]).toMatchObject({ id: "b1", type: "article_list", title: "校园时讯", categoryIds: [12, 15], style: "news_grid", size: 16, background: true, visible: true });
|
||||
expect(layout.blocks[2].id).toMatch(/^b_/);
|
||||
expect(layout.blocks.some(b => b.type === "richtext")).toBe(false);
|
||||
expect(layout.blocks.some((b: LayoutBlock) => (b as { type: string }).type === "richtext")).toBe(false);
|
||||
});
|
||||
|
||||
it("visible:false 保留(保序下线),非字符串数字容错", () => {
|
||||
@@ -126,6 +126,84 @@ describe("validateHomeLayout:与后端 SiteService 同语义", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("quick_links / notice_bar(v2 手册 §2.8):装拆与校验须与后端 S3 同语义", () => {
|
||||
const link = (name: string, url: string) => ({ name, url });
|
||||
const quick = (items: LayoutBlock["items"]): LayoutBlock => ({ id: "q", type: "quick_links", visible: true, items });
|
||||
const notice = (extra: Partial<LayoutBlock>): LayoutBlock => ({ id: "n", type: "notice_bar", visible: true, ...extra });
|
||||
|
||||
it("T-B-06 parseHomeLayout 接受两个新类型;normalizeBlock 还原 items/text/url/level 并剔除脏行", () => {
|
||||
const json = JSON.stringify({
|
||||
home_layout: {
|
||||
version: 1,
|
||||
blocks: [
|
||||
{
|
||||
id: "q1", type: "quick_links", title: "服务入口", items: [
|
||||
link("办事大厅", "https://bsch.edu.cn/hall"), link("图书馆", "/library"),
|
||||
"脏项", { name: 1, url: 2 },
|
||||
],
|
||||
},
|
||||
{ id: "n1", type: "notice_bar", text: "国庆放假", url: "/notice/gq", level: "warn" },
|
||||
{ id: "n2", type: "notice_bar", text: "常规提示" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const layout = parseHomeLayout(json)!;
|
||||
expect(layout.blocks.length).toBe(3);
|
||||
expect(layout.blocks[0]).toMatchObject({
|
||||
id: "q1", type: "quick_links", title: "服务入口", visible: true,
|
||||
items: [link("办事大厅", "https://bsch.edu.cn/hall"), link("图书馆", "/library")],
|
||||
});
|
||||
expect(layout.blocks[1]).toMatchObject({ text: "国庆放假", url: "/notice/gq", level: "warn" });
|
||||
expect(layout.blocks[2].level).toBeUndefined();
|
||||
// 未知类型仍被跳过(两个新类型入白名单不等于放开任意类型)
|
||||
expect(parseHomeLayout(JSON.stringify({ home_layout: { blocks: [{ type: "quicklink" }] } }))!.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("T-B-07 cleanBlock:两个新类型只写业务键、缺省值不落键、空入口行剔除", () => {
|
||||
expect(cleanBlock(quick([link("办事大厅", "https://x.edu.cn"), link("", "")]))).toEqual({
|
||||
id: "q", type: "quick_links", items: [link("办事大厅", "https://x.edu.cn")],
|
||||
});
|
||||
expect(cleanBlock(quick([]))).toEqual({ id: "q", type: "quick_links" });
|
||||
expect(cleanBlock({ ...quick([link("图", "/lib")]), visible: false })).toEqual({
|
||||
id: "q", type: "quick_links", visible: false, items: [link("图", "/lib")],
|
||||
});
|
||||
expect(cleanBlock(notice({ text: " 放假通知 ", url: "/a", level: "warn" }))).toEqual({
|
||||
id: "n", type: "notice_bar", text: "放假通知", url: "/a", level: "warn",
|
||||
});
|
||||
// level=info 是缺省值 → 不写;url 空 → 不写
|
||||
expect(cleanBlock(notice({ text: "提示", level: "info", url: "" }))).toEqual({ id: "n", type: "notice_bar", text: "提示" });
|
||||
});
|
||||
|
||||
it("T-B-07b parse→serialize 往返稳定(新区块不被改写)", () => {
|
||||
const json = JSON.stringify({
|
||||
home_layout: {
|
||||
version: 1,
|
||||
blocks: [
|
||||
{ id: "q1", type: "quick_links", items: [link("办事大厅", "https://x.edu.cn")] },
|
||||
{ id: "n1", type: "notice_bar", text: "放假通知", url: "/notice", level: "warn" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(serializeHomeLayout(json, parseHomeLayout(json)))).toEqual(JSON.parse(json));
|
||||
});
|
||||
|
||||
it("T-B-08 validateHomeLayout 与后端同语义:必填 / 12 项 / 20 字 / 200 字 / url 前缀 / level 白名单", () => {
|
||||
expect(validateHomeLayout({ version: 1, blocks: [quick([link("办事大厅", "https://x.edu.cn")]), notice({ text: "通知" })] })).toEqual([]);
|
||||
const joined = (blocks: LayoutBlock[]) => validateHomeLayout({ version: 1, blocks }).join(" ");
|
||||
expect(joined([quick([])])).toContain("入口");
|
||||
expect(joined([quick(Array.from({ length: 13 }, (_, i) => link(`入口${i}`, `/e${i}`)))])).toContain("12");
|
||||
expect(joined([quick([link("长".repeat(21), "/x")])])).toContain("20");
|
||||
expect(joined([quick([link("", "/x")])])).toContain("名称");
|
||||
expect(joined([quick([link("办事大厅", "")])])).toContain("http(s)://");
|
||||
expect(joined([quick([link("办事大厅", "javascript:alert(1)")])])).toContain("http(s)://");
|
||||
expect(joined([quick([link("办事大厅", "lib.edu.cn")])])).toContain("http(s)://");
|
||||
expect(joined([notice({})])).toContain("公告正文");
|
||||
expect(joined([notice({ text: "告".repeat(201) })])).toContain("200");
|
||||
expect(joined([notice({ text: "x", url: "javascript:alert(1)" })])).toContain("http(s)://");
|
||||
expect(joined([notice({ text: "x", level: "danger" })])).toContain("info");
|
||||
});
|
||||
});
|
||||
|
||||
describe("genFromCategories:一键生成与现状渲染等价", () => {
|
||||
const tree: CategoryTreeNode[] = [
|
||||
cat(1, "校园时讯", 1, "news_grid", [cat(11, "院系动态", 1), cat(12, "单页栏目", 2), cat(13, "外链", 3)]),
|
||||
@@ -169,3 +247,74 @@ describe("flattenListCategories:扁平化仅列表型(选择器数据源)"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/* ===== docs/20 D 组件库:新类型解析/序列化/校验 ===== */
|
||||
describe("homeLayout - D 组件(docs/20)", () => {
|
||||
const D_LAYOUT = JSON.stringify({
|
||||
home_layout: {
|
||||
version: 1,
|
||||
blocks: [
|
||||
{ id: "s1", type: "stats_band", title: "数说学校", stats: [
|
||||
{ label: "在校生", value: "3.2", unit: "万", icon: "xiaoli" },
|
||||
{ label: "教职工", value: "2800" },
|
||||
] },
|
||||
{ id: "t1", type: "featured_topics", items: [
|
||||
{ title: "2026 招生专题", image: "/cms/upload/topic1.jpg", description: "欢迎报考", ctaText: "进入专题", ctaUrl: "https://zs.example.edu.cn" },
|
||||
] },
|
||||
{ id: "v1", type: "video_tour", cover: "/cms/upload/vr.jpg", url: "https://vr.example.edu.cn" },
|
||||
{ id: "e1", type: "events" },
|
||||
{ id: "p1", type: "people", categoryId: 12 },
|
||||
{ id: "b1", type: "badges", badgeItems: [{ label: "软科排名", desc: "前100强", image: "/cms/upload/badge.png" }] },
|
||||
{ id: "g1", type: "gallery", layout: "masonry" },
|
||||
{ id: "q1", type: "quick_links", items: [{ name: "办事大厅", url: "/service", icon: "banshi" }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
it("parse:D 新类型全量解析(stats/topicItems→items 契约/video/people.categoryId/gallery.layout/icon)", () => {
|
||||
const layout = parseHomeLayout(D_LAYOUT)!;
|
||||
expect(layout.blocks).toHaveLength(8);
|
||||
const s1 = layout.blocks.find(b => b.id === "s1")!;
|
||||
expect(s1.stats?.[0]).toMatchObject({ label: "在校生", value: "3.2", unit: "万", icon: "xiaoli" });
|
||||
const t1 = layout.blocks.find(b => b.id === "t1")!;
|
||||
expect(t1.topicItems?.[0].ctaUrl).toBe("https://zs.example.edu.cn");
|
||||
const v1 = layout.blocks.find(b => b.id === "v1")!;
|
||||
expect(v1.cover).toBe("/cms/upload/vr.jpg");
|
||||
const p1 = layout.blocks.find(b => b.id === "p1")!;
|
||||
expect(p1.categoryId).toBe(12);
|
||||
const g1 = layout.blocks.find(b => b.id === "g1")!;
|
||||
expect(g1.layout).toBe("masonry");
|
||||
const q1 = layout.blocks.find(b => b.id === "q1")!;
|
||||
expect(q1.items?.[0].icon).toBe("banshi");
|
||||
});
|
||||
|
||||
it("roundtrip:D 类型 serialize→parse 无损(topicItems 序列化为契约键 items;空行剔除)", () => {
|
||||
const once = parseHomeLayout(D_LAYOUT)!;
|
||||
const twice = parseHomeLayout(serializeHomeLayout("{}", once))!;
|
||||
const t1 = twice.blocks.find(b => b.id === "t1")!;
|
||||
expect(JSON.stringify(t1.topicItems)).toBe(JSON.stringify(once.blocks.find(b => b.id === "t1")!.topicItems));
|
||||
const b1 = twice.blocks.find(b => b.id === "b1")!;
|
||||
expect(b1.badgeItems?.[0].label).toBe("软科排名");
|
||||
// quick_links icon 随行序列化
|
||||
const q1 = twice.blocks.find(b => b.id === "q1")!;
|
||||
expect(q1.items?.[0].icon).toBe("banshi");
|
||||
// gallery masonry 序列化
|
||||
const g1 = twice.blocks.find(b => b.id === "g1")!;
|
||||
expect(g1.layout).toBe("masonry");
|
||||
});
|
||||
|
||||
it("validate:D 各类缺失/超限/非法值给出可读错误", () => {
|
||||
const mk = (blocks: unknown[]) =>
|
||||
({ version: 1, blocks: blocks as never[] });
|
||||
expect(validateHomeLayout(mk([{ id: "x", type: "stats_band", visible: true }]))[0]).toContain("统计项");
|
||||
expect(validateHomeLayout(mk([{ id: "x", type: "stats_band", visible: true, stats: [{ label: "在校生", value: "12345678901" }] }]))[0]).toContain("数值");
|
||||
expect(validateHomeLayout(mk([{ id: "x", type: "featured_topics", visible: true }]))[0]).toContain("专题卡");
|
||||
expect(validateHomeLayout(mk([{ id: "x", type: "featured_topics", visible: true, topicItems: [{ title: "专题", image: "javascript:alert(1)" }] }]))[0]).toContain("图片");
|
||||
expect(validateHomeLayout(mk([{ id: "x", type: "video_tour", visible: true }]))[0]).toContain("封面");
|
||||
expect(validateHomeLayout(mk([{ id: "x", type: "video_tour", visible: true, cover: "/cms/upload/a.jpg" }]))[0]).toContain("视频");
|
||||
expect(validateHomeLayout(mk([{ id: "x", type: "badges", visible: true }]))[0]).toContain("徽章");
|
||||
expect(validateHomeLayout(mk([{ id: "x", type: "gallery", visible: true, layout: "zigzag" }]))[0]).toContain("布局");
|
||||
// 合法布局零错误
|
||||
expect(validateHomeLayout(mk([{ id: "x", type: "gallery", visible: true, layout: "masonry" }]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,50 @@
|
||||
/**
|
||||
* 首页布局纯函数模块(docs/16 主线B §4.1/§4.5)。
|
||||
* 布局存 config_json.home_layout(零 DDL):{version:1, blocks:[…]};无该键 = 回落现状渲染(老站零迁移)。
|
||||
* 与后端契约对齐:type 白名单(article_list/ticker/gallery/links)、size 1~20、title/subtitle ≤100 字;
|
||||
* 与后端契约对齐:type 白名单(article_list/ticker/gallery/links/quick_links/notice_bar)、size 1~20、
|
||||
* title/subtitle ≤100 字、quick_links items ≤12 项(name ≤20 字、url 须 http(s):// 或 / 开头)、
|
||||
* notice_bar text 必填 ≤200 字、level ∈ {info,warn};
|
||||
* 服务端 SiteService.validateHomeLayout 强校验,本模块做编辑端同语义校验与往返装拆。
|
||||
* vitest 单测对象:homeLayout.spec.ts,不依赖组件挂载基建。
|
||||
*/
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
|
||||
/** v1 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */
|
||||
export type BlockType = "article_list" | "ticker" | "gallery" | "links";
|
||||
/** 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致;docs/20 D 新增后 12 种) */
|
||||
export type BlockType =
|
||||
| "article_list" | "ticker" | "gallery" | "links" | "quick_links" | "notice_bar"
|
||||
// docs/20 D-1~D-9
|
||||
| "stats_band" | "featured_topics" | "video_tour" | "events" | "people" | "badges";
|
||||
|
||||
/** quick_links 单个入口(v2 手册 §2.8;D-2 加可选 icon 内置图标标识符) */
|
||||
export interface QuickLinkItem {
|
||||
name: string;
|
||||
url: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/** stats_band 单个统计项(D-1:数字计数带) */
|
||||
export interface StatItem {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/** featured_topics 单张专题卡(D-3) */
|
||||
export interface TopicItem {
|
||||
title: string;
|
||||
description?: string;
|
||||
image: string;
|
||||
ctaText?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
/** badges 单个徽章(D-9) */
|
||||
export interface BadgeItem {
|
||||
label: string;
|
||||
desc?: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
/** article_list 展示形态(与后端渲染端 style 分支及主题 fragment 对应) */
|
||||
export const LIST_STYLES: { value: string; label: string }[] = [
|
||||
@@ -44,6 +80,28 @@ export interface LayoutBlock {
|
||||
source?: string;
|
||||
/** links 分组名(空=全部分组) */
|
||||
group?: string;
|
||||
/** quick_links 入口列表(≤12 项;name ≤20 字、url 为 http(s):// 或站内 / 路径) */
|
||||
items?: QuickLinkItem[];
|
||||
/** notice_bar 公告正文(必填,≤200 字) */
|
||||
text?: string;
|
||||
/** notice_bar 跳转地址(可选) */
|
||||
url?: string;
|
||||
/** notice_bar 提示级别:info=一般(缺省)| warn=醒目 */
|
||||
level?: string;
|
||||
// ===== docs/20 D 组件库新增字段 =====
|
||||
/** gallery 布局(D-8):grid 规整网格(缺省)| masonry 瀑布流 */
|
||||
layout?: string;
|
||||
/** quick_links 统一图标形状随主题参数,不落区块 */
|
||||
/** stats_band 统计项(D-1,1~8 项) */
|
||||
stats?: StatItem[];
|
||||
/** featured_topics 专题卡(D-3,编辑器模型;序列化键为 items,与后端契约对齐) */
|
||||
topicItems?: TopicItem[];
|
||||
/** video_tour(D-5) */
|
||||
cover?: string;
|
||||
/** people 数据栏目(D-7,可空 = 全部人物) */
|
||||
categoryId?: number;
|
||||
/** badges 徽章(D-9,1~8 项) */
|
||||
badgeItems?: BadgeItem[];
|
||||
}
|
||||
|
||||
export interface HomeLayout {
|
||||
@@ -53,12 +111,29 @@ export interface HomeLayout {
|
||||
|
||||
export const HOME_LAYOUT_VERSION = 1;
|
||||
|
||||
/** 服务端同款约束(SiteService §4.6) */
|
||||
export const BLOCK_TYPES_ALLOWED: BlockType[] = ["article_list", "ticker", "gallery", "links"];
|
||||
/** 服务端同款约束(SiteService §4.6 + v2 手册 §2.8 + docs/20 D) */
|
||||
export const BLOCK_TYPES_ALLOWED: BlockType[] = [
|
||||
"article_list", "ticker", "gallery", "links", "quick_links", "notice_bar",
|
||||
"stats_band", "featured_topics", "video_tour", "events", "people", "badges",
|
||||
];
|
||||
export const SIZE_MIN = 1;
|
||||
export const SIZE_MAX = 20;
|
||||
export const TITLE_MAX = 100;
|
||||
export const MAX_BLOCKS = 30;
|
||||
export const QUICK_LINK_MAX = 12;
|
||||
export const LINK_NAME_MAX = 20;
|
||||
export const NOTICE_TEXT_MAX = 200;
|
||||
export const NOTICE_LEVELS = ["info", "warn"];
|
||||
// ===== docs/20 D 组件库约束(与后端 SiteService 同契约) =====
|
||||
export const STATS_MAX = 8;
|
||||
export const TOPICS_MAX = 6;
|
||||
export const BADGES_MAX = 8;
|
||||
export const GALLERY_LAYOUTS = ["grid", "masonry"];
|
||||
/** 内置图标标识符(D-2:与门户骨架 icon fragment 同 key 集) */
|
||||
export const ICON_KEY_PATTERN = /^[a-z0-9-]{1,30}$/;
|
||||
export const ASSET_URL_PATTERN = /^(\/.+|https?:\/\/\S+)$/;
|
||||
/** 入口/跳转地址:http(s):// 绝对地址或站内 / 路径(与后端 HOME_LAYOUT_LINK_PATTERN 同语义) */
|
||||
const LINK_URL_PATTERN = /^(https?:\/\/\S+|\/.+)$/;
|
||||
|
||||
export function genBlockId(): string {
|
||||
return "b_" + Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4);
|
||||
@@ -109,6 +184,79 @@ function toIdList(v: unknown): number[] | undefined {
|
||||
return ids.length ? ids : undefined;
|
||||
}
|
||||
|
||||
/** 入口列表容错还原:非对象项(字符串/数字/null)与非字符串 name/url 的脏行整行丢弃 */
|
||||
function toLinkItems(v: unknown): QuickLinkItem[] | undefined {
|
||||
if (!Array.isArray(v)) {
|
||||
return undefined;
|
||||
}
|
||||
const items: QuickLinkItem[] = [];
|
||||
for (const raw of v as unknown[]) {
|
||||
const obj = asObject(raw);
|
||||
const name = typeof obj?.["name"] === "string" ? (obj["name"] as string).trim() : null;
|
||||
const url = typeof obj?.["url"] === "string" ? (obj["url"] as string).trim() : null;
|
||||
if (name === null || url === null) {
|
||||
continue;
|
||||
}
|
||||
const item: QuickLinkItem = { name, url };
|
||||
if (typeof obj?.["icon"] === "string" && ICON_KEY_PATTERN.test(obj["icon"] as string)) {
|
||||
item.icon = obj["icon"] as string;
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
return items.length ? items : undefined;
|
||||
}
|
||||
|
||||
/** stats_band 统计项容错还原(D-1) */
|
||||
function toStatItems(v: unknown): StatItem[] | undefined {
|
||||
if (!Array.isArray(v)) return undefined;
|
||||
const items: StatItem[] = [];
|
||||
for (const raw of v as unknown[]) {
|
||||
const obj = asObject(raw);
|
||||
const label = typeof obj?.["label"] === "string" ? (obj["label"] as string).trim() : "";
|
||||
const value = typeof obj?.["value"] === "string" ? (obj["value"] as string).trim() : "";
|
||||
if (!label || !value) continue;
|
||||
const item: StatItem = { label, value };
|
||||
if (typeof obj?.["unit"] === "string" && (obj["unit"] as string).trim()) item.unit = (obj["unit"] as string).trim();
|
||||
if (typeof obj?.["icon"] === "string" && ICON_KEY_PATTERN.test(obj["icon"] as string)) item.icon = obj["icon"] as string;
|
||||
items.push(item);
|
||||
}
|
||||
return items.length ? items : undefined;
|
||||
}
|
||||
|
||||
/** featured_topics 专题卡容错还原(D-3) */
|
||||
function toTopicItems(v: unknown): TopicItem[] | undefined {
|
||||
if (!Array.isArray(v)) return undefined;
|
||||
const items: TopicItem[] = [];
|
||||
for (const raw of v as unknown[]) {
|
||||
const obj = asObject(raw);
|
||||
const title = typeof obj?.["title"] === "string" ? (obj["title"] as string).trim() : "";
|
||||
const image = typeof obj?.["image"] === "string" ? (obj["image"] as string).trim() : "";
|
||||
if (!title || !image) continue;
|
||||
const item: TopicItem = { title, image };
|
||||
if (typeof obj?.["description"] === "string" && (obj["description"] as string).trim()) item.description = (obj["description"] as string).trim();
|
||||
if (typeof obj?.["ctaText"] === "string" && (obj["ctaText"] as string).trim()) item.ctaText = (obj["ctaText"] as string).trim();
|
||||
if (typeof obj?.["ctaUrl"] === "string" && (obj["ctaUrl"] as string).trim()) item.ctaUrl = (obj["ctaUrl"] as string).trim();
|
||||
items.push(item);
|
||||
}
|
||||
return items.length ? items : undefined;
|
||||
}
|
||||
|
||||
/** badges 徽章容错还原(D-9,序列化键 badgeItems 防与 featured_topics.items 语义混淆) */
|
||||
function toBadgeItems(v: unknown): BadgeItem[] | undefined {
|
||||
if (!Array.isArray(v)) return undefined;
|
||||
const items: BadgeItem[] = [];
|
||||
for (const raw of v as unknown[]) {
|
||||
const obj = asObject(raw);
|
||||
const label = typeof obj?.["label"] === "string" ? (obj["label"] as string).trim() : "";
|
||||
const image = typeof obj?.["image"] === "string" ? (obj["image"] as string).trim() : "";
|
||||
if (!label || !image) continue;
|
||||
const item: BadgeItem = { label, image };
|
||||
if (typeof obj?.["desc"] === "string" && (obj["desc"] as string).trim()) item.desc = (obj["desc"] as string).trim();
|
||||
items.push(item);
|
||||
}
|
||||
return items.length ? items : undefined;
|
||||
}
|
||||
|
||||
function normalizeBlock(obj: Record<string, unknown>, type: BlockType): LayoutBlock {
|
||||
const size = typeof obj["size"] === "number" ? Math.round(obj["size"]) : undefined;
|
||||
const block: LayoutBlock = {
|
||||
@@ -123,6 +271,33 @@ function normalizeBlock(obj: Record<string, unknown>, type: BlockType): LayoutBl
|
||||
if (typeof obj["background"] === "boolean") block.background = obj["background"];
|
||||
if (typeof obj["source"] === "string") block.source = obj["source"];
|
||||
if (typeof obj["group"] === "string") block.group = obj["group"];
|
||||
if (type === "quick_links") {
|
||||
const items = toLinkItems(obj["items"]);
|
||||
if (items) block.items = items;
|
||||
}
|
||||
if (type === "notice_bar") {
|
||||
if (typeof obj["text"] === "string") block.text = obj["text"];
|
||||
if (typeof obj["url"] === "string") block.url = obj["url"];
|
||||
if (typeof obj["level"] === "string") block.level = obj["level"];
|
||||
}
|
||||
// ===== docs/20 D =====
|
||||
if (typeof obj["layout"] === "string") block.layout = obj["layout"];
|
||||
if (typeof obj["cover"] === "string") block.cover = obj["cover"];
|
||||
if (typeof obj["categoryId"] === "number" && Number.isFinite(obj["categoryId"]) && (obj["categoryId"] as number) > 0) {
|
||||
block.categoryId = obj["categoryId"] as number;
|
||||
}
|
||||
if (type === "stats_band") {
|
||||
const stats = toStatItems(obj["stats"]);
|
||||
if (stats) block.stats = stats;
|
||||
}
|
||||
if (type === "featured_topics") {
|
||||
const items = toTopicItems(obj["items"]);
|
||||
if (items) block.topicItems = items;
|
||||
}
|
||||
if (type === "badges") {
|
||||
const items = toBadgeItems(obj["badgeItems"]);
|
||||
if (items) block.badgeItems = items;
|
||||
}
|
||||
const ids = toIdList(obj["categoryIds"]);
|
||||
if (ids) block.categoryIds = ids;
|
||||
return block;
|
||||
@@ -166,11 +341,64 @@ export function cleanBlock(block: LayoutBlock): Record<string, unknown> {
|
||||
if (subtitle) {
|
||||
out["subtitle"] = subtitle;
|
||||
}
|
||||
if (block.layout === "masonry") {
|
||||
out["layout"] = "masonry";
|
||||
}
|
||||
} else if (block.type === "links") {
|
||||
const group = (block.group || "").trim();
|
||||
if (group) {
|
||||
out["group"] = group;
|
||||
}
|
||||
} else if (block.type === "quick_links") {
|
||||
// 编辑期允许存在未填写的空行,提交前整行剔除(后端要求 name/url 齐备);icon 可选随行
|
||||
const items = (block.items || [])
|
||||
.map(i => {
|
||||
const row: Record<string, string> = { name: (i.name || "").trim(), url: (i.url || "").trim() };
|
||||
if (i.icon && ICON_KEY_PATTERN.test(i.icon)) {
|
||||
row["icon"] = i.icon;
|
||||
}
|
||||
return row;
|
||||
})
|
||||
.filter(i => i["name"] || i["url"]);
|
||||
if (items.length) {
|
||||
out["items"] = items;
|
||||
}
|
||||
} else if (block.type === "notice_bar") {
|
||||
const text = (block.text || "").trim();
|
||||
if (text) {
|
||||
out["text"] = text;
|
||||
}
|
||||
const url = (block.url || "").trim();
|
||||
if (url) {
|
||||
out["url"] = url;
|
||||
}
|
||||
if (block.level === "warn") {
|
||||
out["level"] = "warn";
|
||||
}
|
||||
} else if (block.type === "stats_band") {
|
||||
const stats = (block.stats || []).filter(st => st.label.trim() && st.value.trim());
|
||||
if (stats.length) {
|
||||
out["stats"] = stats;
|
||||
}
|
||||
} else if (block.type === "featured_topics") {
|
||||
const items = (block.topicItems || []).filter(t => t.title.trim() && t.image.trim());
|
||||
if (items.length) {
|
||||
out["items"] = items;
|
||||
}
|
||||
} else if (block.type === "video_tour") {
|
||||
const cover = (block.cover || "").trim();
|
||||
const url = (block.url || "").trim();
|
||||
if (cover) out["cover"] = cover;
|
||||
if (url) out["url"] = url;
|
||||
} else if (block.type === "people") {
|
||||
if (block.categoryId) {
|
||||
out["categoryId"] = block.categoryId;
|
||||
}
|
||||
} else if (block.type === "badges") {
|
||||
const items = (block.badgeItems || []).filter(b => b.label.trim() && b.image.trim());
|
||||
if (items.length) {
|
||||
out["badgeItems"] = items;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -228,6 +456,144 @@ export function validateHomeLayout(layout: HomeLayout | null): string[] {
|
||||
if (b.type === "ticker" && b.source === "category" && !(b.categoryIds && b.categoryIds.length)) {
|
||||
errors.push(`${no}:指定栏目模式请选择栏目`);
|
||||
}
|
||||
if (b.type === "quick_links") {
|
||||
const items = b.items || [];
|
||||
if (!items.length) {
|
||||
errors.push(`${no}:请至少填写 1 个入口`);
|
||||
} else if (items.length > QUICK_LINK_MAX) {
|
||||
errors.push(`${no}:入口数量不能超过 ${QUICK_LINK_MAX} 个`);
|
||||
}
|
||||
items.forEach(item => {
|
||||
const name = (item.name || "").trim();
|
||||
const linkUrl = (item.url || "").trim();
|
||||
if (!name) {
|
||||
errors.push(`${no}:入口名称不能为空`);
|
||||
} else if (name.length > LINK_NAME_MAX) {
|
||||
errors.push(`${no}:入口名称不能超过 ${LINK_NAME_MAX} 字`);
|
||||
}
|
||||
if (!LINK_URL_PATTERN.test(linkUrl)) {
|
||||
errors.push(`${no}:入口「${name || linkUrl}」地址须为 http(s):// 开头或站内路径(/ 开头)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (b.type === "notice_bar") {
|
||||
const text = (b.text || "").trim();
|
||||
if (!text) {
|
||||
errors.push(`${no}:请填写公告正文`);
|
||||
} else if (text.length > NOTICE_TEXT_MAX) {
|
||||
errors.push(`${no}:公告正文不能超过 ${NOTICE_TEXT_MAX} 字`);
|
||||
}
|
||||
const linkUrl = (b.url || "").trim();
|
||||
if (linkUrl && !LINK_URL_PATTERN.test(linkUrl)) {
|
||||
errors.push(`${no}:跳转地址须为 http(s):// 开头或站内路径(/ 开头)`);
|
||||
}
|
||||
if (b.level != null && !NOTICE_LEVELS.includes(b.level)) {
|
||||
errors.push(`${no}:提示级别只能是 info 或 warn`);
|
||||
}
|
||||
}
|
||||
// ===== docs/20 D 组件校验(与后端 SiteService 同语义) =====
|
||||
if (b.type === "gallery" && b.layout != null && !GALLERY_LAYOUTS.includes(b.layout)) {
|
||||
errors.push(`${no}:画廊布局只能是 grid 或 masonry`);
|
||||
}
|
||||
if (b.type === "stats_band") {
|
||||
const stats = b.stats || [];
|
||||
if (!stats.length) {
|
||||
errors.push(`${no}:请至少填写 1 个统计项`);
|
||||
} else if (stats.length > STATS_MAX) {
|
||||
errors.push(`${no}:统计项不能超过 ${STATS_MAX} 个`);
|
||||
}
|
||||
stats.forEach(st => {
|
||||
const label = (st.label || "").trim();
|
||||
const value = (st.value || "").trim();
|
||||
if (!label) {
|
||||
errors.push(`${no}:统计项名称不能为空`);
|
||||
} else if (label.length > 20) {
|
||||
errors.push(`${no}:统计项名称不能超过 20 字`);
|
||||
}
|
||||
if (!value) {
|
||||
errors.push(`${no}:统计项数值不能为空`);
|
||||
} else if (value.length > 10) {
|
||||
errors.push(`${no}:统计项数值不能超过 10 字`);
|
||||
}
|
||||
if ((st.unit || "").length > 10) {
|
||||
errors.push(`${no}:统计项单位不能超过 10 字`);
|
||||
}
|
||||
if (st.icon && !ICON_KEY_PATTERN.test(st.icon)) {
|
||||
errors.push(`${no}:统计项图标标识不合法`);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (b.type === "featured_topics") {
|
||||
const items = b.topicItems || [];
|
||||
if (!items.length) {
|
||||
errors.push(`${no}:请至少填写 1 张专题卡`);
|
||||
} else if (items.length > TOPICS_MAX) {
|
||||
errors.push(`${no}:专题卡不能超过 ${TOPICS_MAX} 张`);
|
||||
}
|
||||
items.forEach(t => {
|
||||
const title = (t.title || "").trim();
|
||||
const image = (t.image || "").trim();
|
||||
if (!title) {
|
||||
errors.push(`${no}:专题卡标题不能为空`);
|
||||
} else if (title.length > 50) {
|
||||
errors.push(`${no}:专题卡标题不能超过 50 字`);
|
||||
}
|
||||
if ((t.description || "").length > 200) {
|
||||
errors.push(`${no}:专题卡描述不能超过 200 字`);
|
||||
}
|
||||
if (!image) {
|
||||
errors.push(`${no}:专题卡必须上传图片`);
|
||||
} else if (!ASSET_URL_PATTERN.test(image)) {
|
||||
errors.push(`${no}:专题卡图片须为上传路径(/ 开头)或 http(s) 地址`);
|
||||
}
|
||||
if ((t.ctaText || "").length > 20) {
|
||||
errors.push(`${no}:按钮文字不能超过 20 字`);
|
||||
}
|
||||
const ctaUrl = (t.ctaUrl || "").trim();
|
||||
if (ctaUrl && !LINK_URL_PATTERN.test(ctaUrl)) {
|
||||
errors.push(`${no}:按钮地址须为 http(s):// 开头或站内路径(/ 开头)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (b.type === "video_tour") {
|
||||
const cover = (b.cover || "").trim();
|
||||
const url = (b.url || "").trim();
|
||||
if (!cover) {
|
||||
errors.push(`${no}:请上传视频封面图`);
|
||||
} else if (!ASSET_URL_PATTERN.test(cover)) {
|
||||
errors.push(`${no}:封面图须为上传路径(/ 开头)或 http(s) 地址`);
|
||||
}
|
||||
if (!url) {
|
||||
errors.push(`${no}:请填写视频/VR 页面地址`);
|
||||
} else if (!LINK_URL_PATTERN.test(url)) {
|
||||
errors.push(`${no}:视频地址须为 http(s):// 开头或站内路径(/ 开头)`);
|
||||
}
|
||||
}
|
||||
if (b.type === "badges") {
|
||||
const items = b.badgeItems || [];
|
||||
if (!items.length) {
|
||||
errors.push(`${no}:请至少填写 1 个徽章`);
|
||||
} else if (items.length > BADGES_MAX) {
|
||||
errors.push(`${no}:徽章不能超过 ${BADGES_MAX} 个`);
|
||||
}
|
||||
items.forEach(bd => {
|
||||
const label = (bd.label || "").trim();
|
||||
const image = (bd.image || "").trim();
|
||||
if (!label) {
|
||||
errors.push(`${no}:徽章名称不能为空`);
|
||||
} else if (label.length > 20) {
|
||||
errors.push(`${no}:徽章名称不能超过 20 字`);
|
||||
}
|
||||
if ((bd.desc || "").length > 50) {
|
||||
errors.push(`${no}:徽章说明不能超过 50 字`);
|
||||
}
|
||||
if (!image) {
|
||||
errors.push(`${no}:徽章必须上传图片`);
|
||||
} else if (!ASSET_URL_PATTERN.test(image)) {
|
||||
errors.push(`${no}:徽章图片须为上传路径(/ 开头)或 http(s) 地址`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* D-2(docs/20)内置通用 SVG 图标集(管理端选择器用)。
|
||||
* 与门户骨架 themes/common/fragments/home-blocks.html :: icon(name) 同 key 同 path——
|
||||
* 两端各持一份(Thymeleaf fragment / Vue 组件无法共享渲染层),改图标须两侧同步。
|
||||
* 24x24 线条风格:stroke=currentColor,fill=none。
|
||||
*/
|
||||
export interface IconDef {
|
||||
key: string;
|
||||
label: string;
|
||||
/** path d 列表(含 circle 用 path 近似或直接多 path) */
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
export const ICON_DEFS: IconDef[] = [
|
||||
{ key: "zhaosheng", label: "招生", paths: ["M3 9.5 12 4.5 21 9.5 12 14.5 3 9.5z", "M7 12v4.5c0 1 2.2 2 5 2s5-1 5-2V12", "M21 9.5V15"] },
|
||||
{ key: "jiuye", label: "就业", paths: ["M3.5 8h17a1.5 1.5 0 0 1 1.5 1.5v8.5a2 2 0 0 1-2 2H4a.5.5 0 0 1-.5-.5z", "M9 8V6.2C9 5.5 9.6 5 10.3 5h3.4c.7 0 1.3.5 1.3 1.2V8", "M3.5 12.5h17"] },
|
||||
{ key: "tushuguan", label: "图书馆", paths: ["M4 19.5 4.9 6.5 11 5v15", "M11 20h9V8l-7.9-1.5", "M4 19.5h7", "M14.5 11.5h3M14.5 15h3"] },
|
||||
{ key: "banshi", label: "办事大厅", paths: ["M4 5.5h16v10H9.5L5 19.2V15.5H4z"] },
|
||||
{ key: "xinxi", label: "信息公开", paths: ["M2.5 12C5 7.5 8.3 5.5 12 5.5S19 7.5 21.5 12C19 16.5 15.7 18.5 12 18.5S5 16.5 2.5 12z", "M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"] },
|
||||
{ key: "xiaoyou", label: "校友", paths: ["M6 8.5a3 3 0 1 0 6 0 3 3 0 0 0-6 0z", "M3.5 19c0-3 2.5-5 5.5-5s5.5 2 5.5 5", "M14.8 9.5a2.2 2.2 0 1 0 4.4 0 2.2 2.2 0 0 0-4.4 0z", "M16 14.2c2.6.3 4.5 2.1 4.5 4.8"] },
|
||||
{ key: "youjian", label: "邮箱", paths: ["M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z", "m3.5 7 8.5 6 8.5-6"] },
|
||||
{ key: "dianhua", label: "电话", paths: ["M5 4h3.5l1.7 4.3-2.3 1.8a12.5 12.5 0 0 0 5.9 5.9l1.8-2.3L20 15.4V19a1.5 1.5 0 0 1-1.6 1.5C10 20 4 14 3.5 5.6A1.5 1.5 0 0 1 5 4z"] },
|
||||
{ key: "ditu", label: "地图/定位", paths: ["M12 21.5c4.2-5 6.8-8 6.8-11.5a6.8 6.8 0 1 0-13.6 0c0 3.5 2.6 6.5 6.8 11.5z", "M9.6 9.7a2.4 2.4 0 1 0 4.8 0 2.4 2.4 0 0 0-4.8 0z"] },
|
||||
{ key: "xinwen", label: "新闻", paths: ["M4.5 5h15a1.5 1.5 0 0 1 1.5 1.5v11A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5v-11A1.5 1.5 0 0 1 4.5 5z", "M6.5 9h6M6.5 12.5h6M6.5 16h6", "M15.5 9h3v7.5h-3z"] },
|
||||
{ key: "rili", label: "日历", paths: ["M5.5 5h13a2 2 0 0 1 2 2v11.5a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z", "M3.5 10h17", "M8 3v4M16 3v4"] },
|
||||
{ key: "shipin", label: "视频", paths: ["M3.5 12a8.5 8.5 0 1 0 17 0 8.5 8.5 0 0 0-17 0z", "M10 8.5 16 12l-6 3.5z"] },
|
||||
{ key: "weixin", label: "微信", paths: ["M9.5 5.5C6 5.5 3 7.8 3 10.7c0 1.6.9 3 2.3 4L5 17l2.6-1.3c.6.2 1.3.3 1.9.3", "M21.5 14.8c0-2.4-2.3-4.3-5.1-4.3s-5.1 1.9-5.1 4.3 2.3 4.3 5.1 4.3c.6 0 1.2-.1 1.7-.3L20.5 20l-.5-1.7c.9-.8 1.5-2.1 1.5-3.5z"] },
|
||||
{ key: "weibo", label: "微博", paths: ["M10.5 19.5c-3.5 0-6.5-2-6.5-4.7 0-2.9 3.3-5.6 6.8-5.6 3 0 6.5 1.6 6.5 4.2 0 2.3-1.9 4.3-4.4 4.3-1.8 0-3.6-.8-3.6-2 0-.8.7-1.5 1.7-1.5.9 0 1.5.5 1.5 1.1 0 .4-.3.7-.7.8", "M15.5 4.5c2.9 0 5 1.8 5.2 4.3", "M15.8 7.2c1.5 0 2.7.9 2.8 2.3"] },
|
||||
{ key: "douyin", label: "抖音", paths: ["M5 17.5a3 3 0 1 0 6 0 3 3 0 0 0-6 0z", "M11 17.5V4.5l7 2v3.2l-4.6-1.3"] },
|
||||
{ key: "bilibili", label: "B站", paths: ["M6 6.5h12a3 3 0 0 1 3 3v6.5a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3v-6.5a3 3 0 0 1 3-3z", "M7 4l2.2 2.4M17 4l-2.2 2.4", "M9.2 10.5v4M14.8 10.5v4"] },
|
||||
{ key: "lianjie", label: "链接", paths: ["m9.5 14.5 5-5", "m8 12-3 3a4 4 0 0 0 5.7 5.7l3-3", "m16 12 3-3a4 4 0 0 0-5.7-5.7l-3 3"] },
|
||||
{ key: "dangan", label: "档案", paths: ["M6 3.5h8.5L18 7v13.5H6z", "M14 3.5V7h4", "M9 11h6M9 14.5h6M9 18h3.5"] },
|
||||
{ key: "xiaoli", label: "校历/校史", paths: ["M4 21V10l8-6.2L20 10v11", "M10 21v-6h4v6", "M4 21h16", "M9.5 10.5h5"] },
|
||||
{ key: "tiyu", label: "体育", paths: ["M8 4.5h8v5a4 4 0 0 1-8 0z", "M8 5.5H5v1.6A3.4 3.4 0 0 0 8.4 10M16 5.5h3v1.6A3.4 3.4 0 0 1 15.6 10", "M12 13.5v3M8.5 20h7M9.8 20c0-1.7.9-3.5 2.2-3.5s2.2 1.8 2.2 3.5"] },
|
||||
{ key: "yishu", label: "艺术", paths: ["M12 3a9 9 0 0 0 0 18c1.4 0 2-.8 2-1.7 0-1.5-1.6-1.9-1.6-3.1 0-1 .8-1.7 2-1.7h1.8A4.8 4.8 0 0 0 21 9.7C21 5.9 17 3 12 3z", "M6.9 10.5a.9.9 0 1 0 1.8 0 .9.9 0 0 0-1.8 0z", "M11.1 7.6a.9.9 0 1 0 1.8 0 .9.9 0 0 0-1.8 0z", "M15.3 10a.9.9 0 1 0 1.8 0 .9.9 0 0 0-1.8 0z"] },
|
||||
{ key: "shiyan", label: "实验", paths: ["M10 3.5h4M11 3.5v6l-4.6 8.2A2 2 0 0 0 8.2 20.5h7.6a2 2 0 0 0 1.8-2.8L13 9.5v-6", "M8.5 14.5h7"] },
|
||||
];
|
||||
|
||||
export function iconLabelOf(key: string | undefined | null): string {
|
||||
return ICON_DEFS.find(i => i.key === key)?.label || "";
|
||||
}
|
||||
Reference in New Issue
Block a user