diff --git a/src/__tests__/homeLayoutEditor.spec.ts b/src/__tests__/homeLayoutEditor.spec.ts index 1639efa..9cf37e6 100644 --- a/src/__tests__/homeLayoutEditor.spec.ts +++ b/src/__tests__/homeLayoutEditor.spec.ts @@ -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"); + }); +}); diff --git a/src/api/module/cms.ts b/src/api/module/cms.ts index 569e478..85ecf3c 100644 --- a/src/api/module/cms.ts +++ b/src/api/module/cms.ts @@ -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(`${PERSON}/list`, { siteId }); + } + + save(data: Partial) { + return postJSON(`${PERSON}/save`, data); + } + + delete(data: DeleteRequest) { + return postJSON(`${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, diff --git a/src/components/IconGrid.vue b/src/components/IconGrid.vue new file mode 100644 index 0000000..49c1fac --- /dev/null +++ b/src/components/IconGrid.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/router/routes.ts b/src/router/routes.ts index 969ab1c..8e848bd 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -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", diff --git a/src/types/cms.ts b/src/types/cms.ts index 0cde756..3b0afc3 100644 --- a/src/types/cms.ts +++ b/src/types/cms.ts @@ -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; +} diff --git a/src/views/cms/BannerManage.vue b/src/views/cms/BannerManage.vue index 67526c1..b7b4a04 100644 --- a/src/views/cms/BannerManage.vue +++ b/src/views/cms/BannerManage.vue @@ -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()); > + + + + diff --git a/src/views/cms/PersonManage.vue b/src/views/cms/PersonManage.vue new file mode 100644 index 0000000..0cb2e3f --- /dev/null +++ b/src/views/cms/PersonManage.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/src/views/cms/homeLayout/HomeLayoutEditor.vue b/src/views/cms/homeLayout/HomeLayoutEditor.vue index f569927..75a9168 100644 --- a/src/views/cms/homeLayout/HomeLayoutEditor.vue +++ b/src/views/cms/homeLayout/HomeLayoutEditor.vue @@ -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" /> + + + + + + +
+
+
+ 专题 {{ ti + 1 }} +
+ + + +
+ + +
+
+ + +
+ +
+
+ + + +
+ + +
+ +

{{ f.tip }}

@@ -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; diff --git a/src/views/cms/homeLayout/blockTypes.ts b/src/views/cms/homeLayout/blockTypes.ts index 9c6164b..6c4aa63 100644 --- a/src/views/cms/homeLayout/blockTypes.ts +++ b/src/views/cms/homeLayout/blockTypes.ts @@ -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) { diff --git a/src/views/cms/homeLayout/homeLayout.spec.ts b/src/views/cms/homeLayout/homeLayout.spec.ts index 77a0005..41e4e3b 100644 --- a/src/views/cms/homeLayout/homeLayout.spec.ts +++ b/src/views/cms/homeLayout/homeLayout.spec.ts @@ -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([]); + }); +}); diff --git a/src/views/cms/homeLayout/homeLayout.ts b/src/views/cms/homeLayout/homeLayout.ts index 8cc42f4..5b7b46a 100644 --- a/src/views/cms/homeLayout/homeLayout.ts +++ b/src/views/cms/homeLayout/homeLayout.ts @@ -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, 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 { 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 = { 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 { 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; } diff --git a/src/views/cms/homeLayout/icons.ts b/src/views/cms/homeLayout/icons.ts new file mode 100644 index 0000000..4849f5a --- /dev/null +++ b/src/views/cms/homeLayout/icons.ts @@ -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 || ""; +}