[CMS-VISUAL] D1-D10: 组件库全量——布局模型扩 6 新类型(stats_band/featured_topics/video_tour/events/people/badges)+ 编辑器新属性面板(统计项/专题卡/徽章数组编辑器、图标选择器)+ gallery 瀑布流 + banner 4 Tab(topic/event 位)+ 人物管理 CRUD 页 + 全类型英文副题(176 测试绿)

This commit is contained in:
chenxing
2026-09-28 13:48:51 +08:00
parent 1a913490ef
commit 49678a6568
12 changed files with 1196 additions and 15 deletions
+41 -1
View File
@@ -217,7 +217,7 @@ describe("HomeLayoutEditor - F2 三栏 + 预览 + 缩略图 + 自动编排(doc
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 }) => b.type === "article_list" && b.title === "校园时讯")).toBe(true);
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");
@@ -236,3 +236,43 @@ describe("HomeLayoutEditor - F2 三栏 + 预览 + 缩略图 + 自动编排(doc
expect(JSON.parse(model.value).home_layout).toBeUndefined();
});
});
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");
});
});
+19
View File
@@ -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 参数) */
@@ -208,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() {
@@ -271,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();
@@ -283,6 +301,7 @@ export default {
articleApi,
attachmentApi,
bannerApi,
personApi,
linkApi,
themeApi,
uploadUrl,
+78
View File
@@ -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>
+8
View File
@@ -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",
+28
View File
@@ -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;
}
/* ===== 友链 ===== */
@@ -270,3 +281,20 @@ export interface ThemeVo {
}
/* ===== 事项库(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;
}
+48
View File
@@ -27,6 +27,9 @@ const formPosition = ref("home");
const positionDict = [
{ 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 formPositionMeta = computed(() => positionDict.find(p => p.value === formPosition.value)!);
const modalTitle = computed(() => `${form.id ? "编辑" : "新增"}${formPositionMeta.value.short}`);
@@ -94,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 = {
@@ -122,6 +131,11 @@ function openCreate() {
startTime: "",
endTime: "",
status: 1,
description: "",
ctaText: "",
ctaUrl: "",
eventDate: "",
location: "",
});
formPosition.value = position.value;
modalVisible.value = true;
@@ -138,6 +152,11 @@ 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;
@@ -178,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("保存成功!");
@@ -270,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>
+309
View File
@@ -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>
+161 -2
View File
@@ -4,12 +4,20 @@ 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,
@@ -256,9 +264,51 @@ function onFieldChange() {
}
/** quick_links 入口行:编辑期内存允许留空行,提交时由 cleanBlock 剔除 */
type LinkItems = { name: string; url: string }[];
type LinkItems = { name: string; url: string; icon?: string }[];
function itemsOf(block: LayoutBlock): LinkItems {
return block.items || (block.items = []);
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) {
@@ -423,6 +473,14 @@ defineExpose({ validate, refreshAfterSave });
@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"
@@ -442,6 +500,62 @@ defineExpose({ validate, refreshAfterSave });
添加入口
</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>
@@ -743,6 +857,51 @@ defineExpose({ validate, refreshAfterSave });
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;
+123 -3
View File
@@ -9,7 +9,8 @@ import { LayoutBlock, LINK_NAME_MAX, LIST_STYLES, NOTICE_TEXT_MAX, QUICK_LINK_MA
export interface BlockPropField {
key: string;
label: string;
kind: "text" | "number" | "select" | "switch" | "categories" | "links";
kind: "text" | "number" | "select" | "switch" | "categories" | "links"
| "stats" | "topics" | "badges" | "category" | "video";
options?: { value: string; label: string }[];
min?: number;
max?: number;
@@ -42,6 +43,7 @@ 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: "可多选;渲染时聚合所选栏目的全部子孙栏目文章(文章通常挂叶子栏目)",
@@ -66,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: [
@@ -89,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 放大;适合校园风光多图",
},
],
},
{
@@ -99,6 +110,7 @@ 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: "留空显示全部分组" },
],
},
@@ -110,9 +122,10 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
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} 字内)+ 地址(http(s):// 或站内 / 路径),最多 ${QUICK_LINK_MAX} 个`,
tip: `每个入口 = 名称(${LINK_NAME_MAX} 字内)+ 地址 + 可选图标,最多 ${QUICK_LINK_MAX} 个;未选图标显示首字徽章`,
},
],
},
@@ -124,6 +137,7 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
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} 字内)`,
@@ -138,6 +152,97 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
},
],
},
// ===== 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 {
@@ -156,7 +261,7 @@ 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") {
@@ -168,6 +273,21 @@ export function blockSummary(block: LayoutBlock, namesOf: (id: number) => string
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) {
+72 -1
View File
@@ -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 保留(保序下线),非字符串数字容错", () => {
@@ -247,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([]);
});
});
+268 -8
View File
@@ -9,13 +9,41 @@
*/
import { CategoryTreeNode } from "@/types/cms";
/** 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */
export type BlockType = "article_list" | "ticker" | "gallery" | "links" | "quick_links" | "notice_bar";
/** 区块类型白名单(与后端 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) */
/** 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 对应) */
@@ -60,6 +88,20 @@ export interface LayoutBlock {
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 {
@@ -69,9 +111,10 @@ export interface HomeLayout {
export const HOME_LAYOUT_VERSION = 1;
/** 服务端同款约束(SiteService §4.6 + v2 手册 §2.8) */
/** 服务端同款约束(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;
@@ -81,6 +124,14 @@ 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+|\/.+)$/;
@@ -146,7 +197,62 @@ function toLinkItems(v: unknown): QuickLinkItem[] | undefined {
if (name === null || url === null) {
continue;
}
items.push({ name, url });
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;
}
@@ -174,6 +280,24 @@ function normalizeBlock(obj: Record<string, unknown>, type: BlockType): LayoutBl
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;
@@ -217,16 +341,25 @@ 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 齐备)
// 编辑期允许存在未填写的空行,提交前整行剔除(后端要求 name/url 齐备);icon 可选随行
const items = (block.items || [])
.map(i => ({ name: (i.name || "").trim(), url: (i.url || "").trim() }))
.filter(i => i.name || i.url);
.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;
}
@@ -242,6 +375,30 @@ export function cleanBlock(block: LayoutBlock): Record<string, unknown> {
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;
}
@@ -334,6 +491,109 @@ export function validateHomeLayout(layout: HomeLayout | null): string[] {
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;
}
+41
View File
@@ -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 || "";
}