feat(banner): 首页常驻滚动通知与管理配置及类型样式支持

This commit is contained in:
✨await
2026-09-17 23:17:09 +08:00
parent 772c551989
commit a3b6761ba9
34 changed files with 3345 additions and 10 deletions

View File

@@ -292,3 +292,77 @@ test("request logs display user credit billing independently from upstream cost"
expect(apiSource).toContain("billingAmountMicrocredits: number");
expect(apiSource).toContain("billingAvailable: boolean");
});
test("banner announcement editor keeps title styles through edit, save and status toggle", async () => {
const [panelSource, editorSource, sliderSource, apiSource, contentSource, emojiPickerSource, noticeSource] = await Promise.all([
Bun.file(new URL("../src/pages/admin/components/admin-banner-announcements-panel.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/pages/admin/components/banner-title-editor.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/components/layout/banner-announcements-slider.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/services/api/announcements.ts", import.meta.url)).text(),
Bun.file(new URL("../src/components/layout/banner-announcement-content.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/pages/admin/components/banner-notice-emoji-picker.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/lib/announcements/banner-notice.ts", import.meta.url)).text(),
]);
const panel = compactSource(panelSource);
const slider = compactSource(sliderSource);
// 状态开关与保存都必须带上样式分段与通知类型:后端更新走显式字段表,漏传会清空它们。
expect(panel).toContain("titleRuns: banner.titleRuns");
expect(panel).toContain("titleRuns: runs");
expect(panel).toContain("noticeType: selectedNoticeType");
expect(panel).toContain("noticeType: normalizeBannerNoticeType(banner.noticeType)");
// 编辑入口不能被新建信号重置:父级 createOpen 消费后立即复位,编辑路径不再写回该标志。
expect(panel).toContain("onCreateOpenChange(false)");
expect(panel).toContain('setDialog({ mode: "edit", banner })');
expect(panel).toContain("banner.titleRuns?.length");
// AntD 6 用 destroyOnHidden,destroyOnClose 已废弃。
expect(panel).toContain("destroyOnHidden");
expect(panel).not.toContain("destroyOnClose");
// Modal 渲染在 body portal 里,必须挂 admin-modal-root 才能拿到弹窗作用域的强边框 / 分层 token,
// 否则编辑器等自绘控件的边框回落到 :root 的 8% 透明度,肉眼不可见。
expect(panel).toContain('rootClassName="admin-modal-root"');
// 非表单控件不能放进 Form.Item(会被注入 value/onChange/ref)。
expect(panel).toContain("<BannerNoticePreview runs={titleRuns} hasLink={Boolean(linkValue?.trim())} noticeType={noticeType} />");
// 通知类型在后台要有独立入口;emoji 图标不设独立字段,经编辑器「图标」按钮插入。
expect(panel).toContain("<BannerNoticeTypeSelector");
expect(panel).not.toContain("BannerNoticeIconPicker");
expect(panel).not.toContain("normalizeBannerNoticeIcon");
expect(editorSource).toContain("applyBannerTitleStyle");
expect(editorSource).toContain("clearBannerTitleStyle");
expect(editorSource).toContain("lowContrastBannerTitleColors");
// 标题是单行语义,Enter 不产生新段落。
expect(editorSource).toContain('event.key === "Enter"');
// emoji 素材插到光标处、作为普通文本保存(无独立字段、无默认图标)。
expect(editorSource).toContain("<BannerNoticeEmojiPopover");
expect(editorSource).toContain("insertContent(emoji)");
expect(editorSource).not.toContain("BannerAnnouncementIcon");
// 预览必须走前台同一份展示单元,否则「预览即前台」不成立。
expect(editorSource).toContain("bannerAnnouncementBarStyle(noticeType)");
// 前台通知条必须按分段渲染,不能退回纯文本;底色来自当前通知的类型,标题前不再有固定图标。
expect(sliderSource).toContain("BannerAnnouncementTitle");
expect(sliderSource).toContain("currentBanner.titleRuns");
expect(slider).toContain("bannerAnnouncementBarStyle(currentBanner.noticeType)");
expect(slider).not.toContain("BannerAnnouncementIcon");
expect(slider).not.toContain("notice-banner-surface");
// 展示单元是唯一来源:底色、富文本标题、详情入口都在这里定义,前台与预览共用;
// 图标素材是标题文本的一部分,展示单元不再单独处理图标。
expect(contentSource).toContain("export function bannerAnnouncementBarStyle");
expect(contentSource).toContain("export function BannerAnnouncementLinkHint");
expect(contentSource).toContain("export function BannerAnnouncementTitle");
expect(contentSource).not.toContain("BannerAnnouncementIcon");
// emoji 面板:默认收起(Popover 点击触发),插入后不自动关闭,方便连续插入。
expect(emojiPickerSource).toContain('trigger="click"');
expect(emojiPickerSource).toContain('onPick(item.char)');
expect(noticeSource).toContain("BANNER_NOTICE_EMOJI_GROUPS");
expect(noticeSource).not.toContain("DEFAULT_ICON");
expect(apiSource).toContain("titleRuns?: BannerTitleRun[]");
expect(apiSource).toContain("noticeType?: BannerNoticeType");
expect(apiSource).not.toContain("icon?:");
expect(apiSource).toContain("export type { BannerTitleRun }");
});

View File

@@ -0,0 +1,147 @@
import { describe, expect, test } from "bun:test";
import {
BANNER_NOTICE_DEFAULT_TYPE,
BANNER_NOTICE_EMOJI_GROUPS,
BANNER_NOTICE_EMOJIS,
BANNER_NOTICE_TYPE_VALUES,
BANNER_NOTICE_TYPES,
bannerNoticeSurfaceCSS,
bannerNoticeTypeLabel,
normalizeBannerNoticeType,
searchBannerNoticeEmojis,
} from "@/lib/announcements/banner-notice";
import { bannerTitleContrastRatio } from "@/lib/announcements/banner-title";
async function readSource(relativePath: string) {
return Bun.file(new URL(relativePath, import.meta.url)).text();
}
/** 从 Go 的 `var X = map[string]struct{}{...}` 字面量里取出键集合。 */
function goMapKeys(source: string, variable: string) {
const start = source.indexOf(`var ${variable} = map[string]struct{}{`);
if (start < 0) throw new Error(`未找到 ${variable}`);
const end = source.indexOf("\n}", start);
if (end < 0) throw new Error(`${variable} 字面量未闭合`);
const body = source.slice(start, end);
return Array.from(body.matchAll(/"([a-z0-9-]+)"/g)).map((match) => match[1]);
}
describe("通知类型", () => {
test("未知或空值回落到默认类型", () => {
expect(normalizeBannerNoticeType(undefined)).toBe(BANNER_NOTICE_DEFAULT_TYPE);
expect(normalizeBannerNoticeType("")).toBe(BANNER_NOTICE_DEFAULT_TYPE);
expect(normalizeBannerNoticeType("rainbow")).toBe(BANNER_NOTICE_DEFAULT_TYPE);
expect(normalizeBannerNoticeType(" ACTIVITY ")).toBe("activity");
});
test("每种类型都有中文名与独立的底色 token", () => {
const vars = BANNER_NOTICE_TYPES.map((item) => item.surfaceVar);
expect(new Set(vars).size).toBe(vars.length);
for (const type of BANNER_NOTICE_TYPES) {
expect(bannerNoticeTypeLabel(type.key)).toBe(type.label);
expect(bannerNoticeSurfaceCSS(type.key)).toBe(`var(${type.surfaceVar})`);
}
// 未知类型也必须拿到可用底色,不能渲染成透明条。
expect(bannerNoticeSurfaceCSS("rainbow")).toBe(bannerNoticeSurfaceCSS(BANNER_NOTICE_DEFAULT_TYPE));
});
});
describe("emoji 图标素材库", () => {
test("字符唯一、非空,每个素材都有中文名", () => {
const chars = BANNER_NOTICE_EMOJIS.map((item) => item.char);
expect(new Set(chars).size).toBe(chars.length);
expect(chars.length).toBeGreaterThanOrEqual(50);
for (const item of BANNER_NOTICE_EMOJIS) {
expect(item.char.trim().length).toBeGreaterThan(0);
expect(item.label.trim().length).toBeGreaterThan(0);
expect(item.groupLabel.trim().length).toBeGreaterThan(0);
}
});
test("用户点名的素材必须在库内", () => {
for (const char of ["💰", "🚀", "🔥", "📢"]) {
expect(BANNER_NOTICE_EMOJIS.some((item) => item.char === char)).toBe(true);
}
});
test("搜索同时匹配中文名、分组名与字符本身", () => {
expect(searchBannerNoticeEmojis("火箭").map((item) => item.char)).toContain("🚀");
expect(searchBannerNoticeEmojis("钱袋").map((item) => item.char)).toContain("💰");
// 分组名搜索命中整组。
const statusHits = searchBannerNoticeEmojis("状态");
expect(statusHits.length).toBeGreaterThanOrEqual(BANNER_NOTICE_EMOJI_GROUPS.find((g) => g.key === "status")!.emoji.length);
// 字符本身也能搜到(支持从别处粘贴检索)。
expect(searchBannerNoticeEmojis("🔥").map((item) => item.char)).toContain("🔥");
expect(searchBannerNoticeEmojis(" ").length).toBe(BANNER_NOTICE_EMOJIS.length);
expect(searchBannerNoticeEmojis("不存在的素材")).toEqual([]);
});
test("emoji 是普通文本:标题纯文本拼接函数必须原样保留它", async () => {
// 防止未来有人给 run.text 做字符过滤,把 emoji 丢掉。
const { bannerTitlePlainText } = await import("@/lib/announcements/banner-title");
const runs = [
{ text: "🔥", fontSize: 20 },
{ text: "限时", color: "#FFE58F" },
{ text: "大促" },
];
expect(bannerTitlePlainText(runs as never)).toBe("🔥限时大促");
});
});
describe("与后端白名单保持一致", () => {
test("通知类型的后端白名单逐项等于前端注册表", async () => {
const goSource = await readSource("../../backend/internal/app/announcement.go");
expect(new Set(goMapKeys(goSource, "bannerNoticeTypes"))).toEqual(new Set(BANNER_NOTICE_TYPE_VALUES));
});
test("后端默认值常量与前端一致", async () => {
const goSource = await readSource("../../backend/internal/app/announcement.go");
expect(goSource).toContain(`bannerNoticeDefaultType = "${BANNER_NOTICE_DEFAULT_TYPE}"`);
});
test("后端不保存独立图标字段:emoji 必须内嵌在标题分段里", async () => {
const [goModel, goApp, goRepo] = await Promise.all([
readSource("../../backend/internal/model/models_project.go"),
readSource("../../backend/internal/app/announcement.go"),
readSource("../../backend/internal/repository/announcement.go"),
]);
// 防止 icon 字段被加回来却没人维护白名单:图标的存储形态就是 TitleRuns 的文本。
expect(goModel).not.toMatch(/\bIcon\s+string/);
expect(goApp).not.toContain("normalizeBannerIcon");
expect(goRepo).not.toContain('"icon"');
});
});
describe("通知条主题色", () => {
test("四种类型在明暗两档都满足白字对比度要求", async () => {
const css = await readSource("../src/styles/globals.css");
const value = (token: string) => {
const match = css.match(new RegExp(`--${token}:\\s*(#[0-9a-fA-F]{6})`));
if (!match) throw new Error(`globals.css 缺少 --${token}`);
return match[1];
};
for (const type of BANNER_NOTICE_TYPES) {
// 浅色档是正文级底色,必须达到 WCAG AA 的 4.5:1;
// 深色档统一压到 7:1 以上,避免暗色主题里底色过亮。
const light = value(`palette-banner-${type.key}-500`);
const dark = value(`palette-banner-${type.key}-700`);
expect(bannerTitleContrastRatio("#FFFFFF", light)).toBeGreaterThanOrEqual(4.5);
expect(bannerTitleContrastRatio("#FFFFFF", dark)).toBeGreaterThanOrEqual(7);
}
});
test("前台与预览都引用按类型取值的语义 token", async () => {
const [globals, adminUi] = await Promise.all([readSource("../src/styles/globals.css"), readSource("../src/styles/admin-ui.css")]);
for (const type of BANNER_NOTICE_TYPES) {
// surfaceVar 自带 `--` 前缀,拼接时不要再加一次。
const name = type.surfaceVar.replace(/^--/, "");
// 语义层明暗各定义一次::root 一次、.dark 一次。
const semantic = new RegExp(`--${name}: var\\(--palette-banner-${type.key}-\\d00\\)`, "g");
expect(globals.match(semantic)?.length).toBe(2);
// 后台预览强制两套底色,不受页面当前主题影响。
expect(adminUi).toContain(`--${name}: var(--palette-banner-${type.key}-500)`);
expect(adminUi).toContain(`--${name}: var(--palette-banner-${type.key}-700)`);
}
});
});

View File

@@ -0,0 +1,72 @@
import { expect, test } from "bun:test";
import { bannerDocToRuns, bannerTitleRunsToDoc } from "@/lib/announcements/banner-title-rich-text";
test("样式分段与编辑文档互转保持幂等", () => {
const runs = [
{ text: "限时活动", fontSize: 16, fontWeight: 700, color: "#FF0000" },
{ text: "已上线", fontFamily: "mono" as const },
{ text: ",欢迎体验" },
];
const doc = bannerTitleRunsToDoc(runs);
expect(doc.content?.[0]?.type).toBe("paragraph");
expect(bannerDocToRuns(doc)).toEqual(runs);
});
test("编辑器文档里的白名单外样式在转回分段时被丢弃", () => {
const doc = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "标题",
marks: [{ type: "textStyle", attrs: { fontSize: "40px", fontWeight: "900", color: "red", fontFamily: "Comic Sans" } }],
},
],
},
],
};
expect(bannerDocToRuns(doc)).toEqual([{ text: "标题" }]);
});
test("同一节点上的多个 textStyle 标记按出现顺序合并取值", () => {
const doc = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "标题",
marks: [
{ type: "textStyle", attrs: { fontSize: "12px" } },
{ type: "textStyle", attrs: { fontSize: "18px", color: "#00ff00" } },
],
},
],
},
],
};
expect(bannerDocToRuns(doc)).toEqual([{ text: "标题", fontSize: 18, color: "#00FF00" }]);
});
test("多段文档只取首个段落,粘贴内容不会变成多行标题", () => {
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "第一行" }] },
{ type: "paragraph", content: [{ type: "text", text: "第二行" }] },
],
};
expect(bannerDocToRuns(doc)).toEqual([{ text: "第一行" }]);
});
test("空文档与空分段互转不报错", () => {
expect(bannerDocToRuns(undefined)).toEqual([]);
expect(bannerDocToRuns(null)).toEqual([]);
expect(bannerTitleRunsToDoc([])).toEqual({ type: "doc", content: [{ type: "paragraph", content: [] }] });
});

View File

@@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test";
import {
BANNER_TITLE_FONT_FAMILIES,
bannerTitleCharCount,
bannerTitleContrastRatio,
bannerTitlePlainText,
bannerTitleRunStyle,
lowContrastBannerTitleColors,
normalizeBannerTitleHex,
normalizeBannerTitleRuns,
parseBannerTitleColor,
} from "@/lib/announcements/banner-title";
describe("通知标题样式分段清洗", () => {
test("合并相邻同样式分段,跳过空文本", () => {
const runs = normalizeBannerTitleRuns([
{ text: "限时", fontSize: 16, color: "#FF0000" },
{ text: "活动", fontSize: 16, color: "#ff0000" },
{ text: "", fontSize: 16 },
{ text: "上线", fontSize: 20 },
]);
expect(runs).toEqual([
{ text: "限时活动", fontSize: 16, color: "#FF0000" },
{ text: "上线", fontSize: 20 },
]);
});
test("丢弃白名单外的字号、字重、字体与颜色", () => {
expect(normalizeBannerTitleRuns([{ text: "标题", fontSize: 9 }])).toEqual([{ text: "标题" }]);
expect(normalizeBannerTitleRuns([{ text: "标题", fontSize: 21 }])).toEqual([{ text: "标题" }]);
expect(normalizeBannerTitleRuns([{ text: "标题", fontWeight: 6000 }])).toEqual([{ text: "标题" }]);
expect(normalizeBannerTitleRuns([{ text: "标题", fontFamily: "comic" as never }])).toEqual([{ text: "标题" }]);
expect(normalizeBannerTitleRuns([{ text: "标题", color: "red" }])).toEqual([{ text: "标题" }]);
expect(normalizeBannerTitleRuns([{ text: "标题", color: "#fff;background:url(x)" }])).toEqual([{ text: "标题" }]);
});
test("sans 视为默认字体,不写入分段", () => {
expect(normalizeBannerTitleRuns([{ text: "标题", fontFamily: "sans" }])).toEqual([{ text: "标题" }]);
expect(normalizeBannerTitleRuns([{ text: "标题", fontFamily: "mono" }])).toEqual([{ text: "标题", fontFamily: "mono" }]);
});
test("纯文本与字符数按码点统计", () => {
const runs = [{ text: "限时" }, { text: "活动🎉" }];
expect(bannerTitlePlainText(runs)).toBe("限时活动🎉");
expect(bannerTitleCharCount(runs)).toBe(5);
});
});
describe("通知标题样式输出", () => {
test("只输出已设置的维度", () => {
expect(bannerTitleRunStyle({ text: "标题" })).toEqual({});
expect(bannerTitleRunStyle({ text: "标题", fontSize: 20, fontWeight: 700, color: "#FF0000" })).toEqual({
fontSize: "20px",
fontWeight: 700,
color: "#FF0000",
});
});
test("字体档位输出对应字体栈", () => {
const serif = BANNER_TITLE_FONT_FAMILIES.find((item) => item.key === "serif");
expect(bannerTitleRunStyle({ text: "标题", fontFamily: "serif" }).fontFamily).toBe(serif?.css);
});
test("十六进制颜色归一化", () => {
expect(normalizeBannerTitleHex("#abc")).toBe("#AABBCC");
expect(normalizeBannerTitleHex("5f6ae4")).toBe("#5F6AE4");
expect(normalizeBannerTitleHex("#12345")).toBe("");
expect(parseBannerTitleColor("rgb(95, 106, 228)")).toEqual([95, 106, 228]);
expect(parseBannerTitleColor("not-a-color")).toBeNull();
});
});
describe("通知标题对比度检查", () => {
test("白字在通知条两种底色上的对比度", () => {
expect(bannerTitleContrastRatio("#FFFFFF", "#5C67E2")).toBeCloseTo(4.67, 2);
expect(bannerTitleContrastRatio("#FFFFFF", "#3F4590")).toBeCloseTo(8.52, 2);
expect(bannerTitleContrastRatio("not-a-color", "#3F4590")).toBeNull();
expect(bannerTitleContrastRatio("#FFFFFF", "")).toBeNull();
});
test("通知条底色 token 的白字对比度必须达到 AA(改 token 时同步更新此处)", () => {
// globals.css 的 --palette-banner-notice-500 / -700(「公告」类型);默认标题为白色 12px 正文,需 >= 4.5:1。
// 四种类型的完整覆盖见 test/banner-notice.test.ts。
expect(bannerTitleContrastRatio("#FFFFFF", "#5C67E2")).toBeGreaterThanOrEqual(4.5);
expect(bannerTitleContrastRatio("#FFFFFF", "#3F4590")).toBeGreaterThanOrEqual(4.5);
});
test("挑出对比度不足的颜色并去重,默认无色不参与检查", () => {
const runs = [
{ text: "低", color: "#808BF5" },
{ text: "也低", color: "#808BF5" },
{ text: "足够", color: "#FFFFFF" },
{ text: "没设颜色" },
];
expect(lowContrastBannerTitleColors(runs, "#5C67E2")).toEqual(["#808BF5"]);
expect(lowContrastBannerTitleColors(runs, "#3F4590")).toEqual(["#808BF5"]);
expect(lowContrastBannerTitleColors([{ text: "足够", color: "#FFFFFF" }], "#5C67E2")).toEqual([]);
expect(lowContrastBannerTitleColors(runs, "")).toEqual([]);
});
});