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

@@ -0,0 +1,181 @@
/**
* 首页常驻通知条的「通知类型」与「标题图标素材」模型。
*
* 通知类型决定通知条底色(后台与前台共用同一套 token)。
* 图标素材是 emoji 字符:它就是普通文本,随 TitleRuns 的文本分段一起存储与渲染,
* 因此可以插到标题任意位置、跟随分段样式、参与关键字检索,后端不需要单独字段或白名单。
*
* 本模块只放纯数据与纯函数,不 import 任何组件。
*/
export type BannerNoticeType = "notice" | "activity" | "update" | "warning";
/** 未指定类型时的兜底:平台公告。 */
export const BANNER_NOTICE_DEFAULT_TYPE: BannerNoticeType = "notice";
export const BANNER_NOTICE_TYPES: {
key: BannerNoticeType;
label: string;
hint: string;
/** 该类型的语义底色 token;由 globals.css 按明暗主题分别取值。 */
surfaceVar: string;
}[] = [
{ key: "notice", label: "公告", hint: "平台公告、规则与流程变更", surfaceVar: "--notice-banner-surface-notice" },
{ key: "activity", label: "活动", hint: "运营活动、限时促销与福利", surfaceVar: "--notice-banner-surface-activity" },
{ key: "update", label: "更新", hint: "版本发布、功能上新与改版", surfaceVar: "--notice-banner-surface-update" },
{ key: "warning", label: "警告", hint: "维护窗口、风险与服务异常", surfaceVar: "--notice-banner-surface-warning" },
];
const BANNER_NOTICE_TYPE_KEYS = new Set<string>(BANNER_NOTICE_TYPES.map((item) => item.key));
/** 未知 / 空类型一律回落到默认类型,通知条不会因为脏数据失去底色。 */
export function normalizeBannerNoticeType(value?: string | null): BannerNoticeType {
const raw = (value || "").trim().toLowerCase();
return BANNER_NOTICE_TYPE_KEYS.has(raw) ? (raw as BannerNoticeType) : BANNER_NOTICE_DEFAULT_TYPE;
}
export function bannerNoticeTypeMeta(value?: string | null) {
const key = normalizeBannerNoticeType(value);
return BANNER_NOTICE_TYPES.find((item) => item.key === key) as (typeof BANNER_NOTICE_TYPES)[number];
}
export function bannerNoticeTypeLabel(value?: string | null) {
return bannerNoticeTypeMeta(value).label;
}
export function bannerNoticeTypeHint(value?: string | null) {
return bannerNoticeTypeMeta(value).hint;
}
/** 该类型底色的 CSS 引用(含 var());scope 内可被预览容器覆盖成明暗指定档。 */
export function bannerNoticeSurfaceCSS(value?: string | null) {
return `var(${bannerNoticeTypeMeta(value).surfaceVar})`;
}
/** 合法类型键,供后端白名单与前端一致性测试使用。 */
export const BANNER_NOTICE_TYPE_VALUES = BANNER_NOTICE_TYPES.map((item) => item.key);
/**
* 标题可插入的 emoji 图标素材,按用途分组。
* 每项的 char 会作为普通文本插进标题(跟随光标处的字号 / 颜色样式),label 供搜索与 aria 标注。
*/
export const BANNER_NOTICE_EMOJI_GROUPS: { key: string; label: string; emoji: { char: string; label: string }[] }[] = [
{
key: "notice",
label: "通知",
emoji: [
{ char: "📢", label: "喇叭" },
{ char: "📣", label: "扩音" },
{ char: "🔔", label: "铃铛" },
{ char: "📮", label: "邮筒" },
{ char: "📌", label: "图钉" },
{ char: "📄", label: "文件" },
{ char: "📝", label: "备注" },
{ char: "📰", label: "快讯" },
{ char: "🗓️", label: "日历" },
{ char: "⏰", label: "闹钟" },
{ char: "🕐", label: "时钟" },
{ char: "🎯", label: "目标" },
],
},
{
key: "celebrate",
label: "庆祝",
emoji: [
{ char: "🎉", label: "撒花" },
{ char: "🎊", label: "礼花" },
{ char: "🎈", label: "气球" },
{ char: "🎁", label: "礼物" },
{ char: "🎂", label: "蛋糕" },
{ char: "🥳", label: "狂欢" },
{ char: "🏆", label: "奖杯" },
{ char: "🥇", label: "金牌" },
{ char: "👑", label: "皇冠" },
{ char: "💎", label: "宝石" },
{ char: "✨", label: "闪光" },
{ char: "⭐", label: "星星" },
],
},
{
key: "hot",
label: "热门",
emoji: [
{ char: "🔥", label: "热门" },
{ char: "🚀", label: "火箭" },
{ char: "⚡", label: "闪电" },
{ char: "💥", label: "爆炸" },
{ char: "🌟", label: "亮星" },
{ char: "💫", label: "星晕" },
{ char: "🌈", label: "彩虹" },
{ char: "☀️", label: "太阳" },
{ char: "🌙", label: "月亮" },
{ char: "❤️", label: "红心" },
{ char: "💯", label: "满分" },
{ char: "👍", label: "点赞" },
],
},
{
key: "commerce",
label: "商业",
emoji: [
{ char: "💰", label: "钱袋" },
{ char: "💸", label: "飞钱" },
{ char: "💳", label: "银行卡" },
{ char: "🏦", label: "银行" },
{ char: "🛒", label: "购物车" },
{ char: "🛍️", label: "购物袋" },
{ char: "🏷️", label: "价签" },
{ char: "💼", label: "公文包" },
{ char: "📈", label: "上涨" },
{ char: "📉", label: "下跌" },
{ char: "🎫", label: "票券" },
{ char: "🎟️", label: "门票" },
],
},
{
key: "status",
label: "状态",
emoji: [
{ char: "✅", label: "完成" },
{ char: "❌", label: "取消" },
{ char: "⚠️", label: "警告" },
{ char: "⛔", label: "禁止" },
{ char: "🚫", label: "禁用" },
{ char: "❓", label: "疑问" },
{ char: "❗", label: "感叹" },
{ char: "⏳", label: "等待" },
{ char: "🔄", label: "刷新" },
{ char: "🔧", label: "扳手" },
{ char: "🔒", label: "上锁" },
{ char: "🐞", label: "缺陷" },
],
},
];
/** 扁平化的 emoji 清单,顺序与分组一致。 */
export const BANNER_NOTICE_EMOJIS = BANNER_NOTICE_EMOJI_GROUPS.flatMap((group) =>
group.emoji.map((item) => ({ ...item, group: group.key, groupLabel: group.label })),
);
/** 按关键字过滤(匹配中文名、分组名或字符本身),空关键字返回全量。 */
export function searchBannerNoticeEmojis(keyword: string) {
const query = keyword.trim().toLowerCase();
if (!query) return BANNER_NOTICE_EMOJIS;
return BANNER_NOTICE_EMOJIS.filter((item) => item.char.includes(query) || item.label.includes(query) || item.groupLabel.includes(query));
}
/**
* 读取指定类型在当前主题下的通知条实底色,避免把设计 token 的色值复制到 TS 里。
* scope 传后台预览容器可在该容器强制指定的明 / 暗档里取值。
*/
export function readBannerNoticeSurface(noticeType?: string | null, scope?: HTMLElement | null) {
if (typeof window === "undefined") return "";
const host = scope || window.document.body || window.document.documentElement;
if (!host) return "";
const probe = window.document.createElement("span");
probe.style.cssText = `position:absolute;left:-9999px;top:-9999px;background-color:${bannerNoticeSurfaceCSS(noticeType)}`;
host.appendChild(probe);
const resolved = window.getComputedStyle(probe).backgroundColor;
probe.remove();
return resolved;
}

View File

@@ -0,0 +1,217 @@
/**
* 通知标题富文本编辑与通知条渲染之间的桥接层。
*
* 编辑器用 Tiptap 提供选区能力,但存储与渲染都只认 `BannerTitleRun[]`:
* - 只保留一个 paragraph 与行内 `textStyle` 标记,标题永远是单段、单行语义。
* - 字号 / 字体只接受白名单档位(其余值在转换时丢弃),因此不会把任意 CSS 写进通知条。
*/
import { Extension, type Editor, type JSONContent } from "@tiptap/core";
import CharacterCount from "@tiptap/extension-character-count";
import { Color } from "@tiptap/extension-color";
import Placeholder from "@tiptap/extension-placeholder";
import { FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style";
import { StarterKit } from "@tiptap/starter-kit";
import {
BANNER_TITLE_FONT_FAMILIES,
BANNER_TITLE_FONT_SIZES,
BANNER_TITLE_FONT_WEIGHTS,
bannerTitleFontFamilyCSS,
bannerTitleFontFamilyKey,
normalizeBannerTitleHex,
normalizeBannerTitleRuns,
type BannerTitleFontFamilyKey,
type BannerTitleRun,
} from "./banner-title";
export const BANNER_TITLE_TEXT_STYLE = "textStyle";
/**
* 字重没有官方扩展,按 FontSize 的写法把 fontWeight 挂到 textStyle 标记上。
* 只声明属性,不下发命令:样式统一走 applyBannerTitleStyle,保证每个文本节点只留一个标记。
*/
export const BannerTitleFontWeight = Extension.create<{ types: string[] }>({
name: "bannerTitleFontWeight",
addOptions() {
return { types: [BANNER_TITLE_TEXT_STYLE] };
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
fontWeight: {
default: null,
parseHTML: (element) => element.style.fontWeight || null,
renderHTML: (attributes) => (attributes.fontWeight ? { style: `font-weight: ${attributes.fontWeight}` } : {}),
},
},
},
];
},
});
export function createBannerTitleExtensions(placeholder?: string) {
return [
StarterKit.configure({
bold: false,
blockquote: false,
bulletList: false,
code: false,
codeBlock: false,
dropcursor: false,
gapcursor: false,
hardBreak: false,
heading: false,
horizontalRule: false,
italic: false,
link: false,
listItem: false,
listKeymap: false,
orderedList: false,
strike: false,
trailingNode: false,
underline: false,
}),
TextStyle,
FontSize,
FontFamily,
Color,
BannerTitleFontWeight,
CharacterCount,
...(placeholder ? [Placeholder.configure({ placeholder })] : []),
];
}
export type BannerTitleStylePatch = {
fontSize?: number | null;
fontWeight?: number | null;
fontFamily?: BannerTitleFontFamilyKey | null;
color?: string | null;
};
export type BannerTitleSelectionStyle = {
fontSize?: number;
fontWeight?: number;
fontFamily?: BannerTitleFontFamilyKey;
color?: string;
};
export function bannerTitleRunsToDoc(runs: BannerTitleRun[]): JSONContent {
const content = [];
for (const run of normalizeBannerTitleRuns(runs)) {
const attrs = bannerTitleMarkAttrs(run);
const node: JSONContent = { type: "text", text: run.text };
if (Object.values(attrs).some((value) => value)) {
node.marks = [{ type: BANNER_TITLE_TEXT_STYLE, attrs }];
}
content.push(node);
}
return { type: "doc", content: [{ type: "paragraph", content }] };
}
export function bannerDocToRuns(doc?: JSONContent | null): BannerTitleRun[] {
const paragraph = (doc?.content || []).find((node) => node.type === "paragraph");
const runs: BannerTitleRun[] = [];
for (const node of paragraph?.content || []) {
if (node.type !== "text" || !node.text) continue;
const attrs = textStyleAttributes(node);
const run: BannerTitleRun = { text: node.text };
const size = Number(attrs.fontSize ? String(attrs.fontSize).replace(/[^\d]/g, "") : "");
if (BANNER_TITLE_FONT_SIZES.includes(size)) run.fontSize = size;
const weight = Number(String(attrs.fontWeight || "").replace(/[^\d]/g, ""));
if (BANNER_TITLE_FONT_WEIGHTS.some((item) => item.value === weight)) run.fontWeight = weight;
const family = bannerTitleFontFamilyKey(typeof attrs.fontFamily === "string" ? attrs.fontFamily : "");
if (family && family !== "sans") run.fontFamily = family;
const color = normalizeBannerTitleHex(typeof attrs.color === "string" ? attrs.color : "");
if (color) run.color = color;
runs.push(run);
}
return normalizeBannerTitleRuns(runs);
}
export function bannerTitleSelectionStyle(editor: Editor): BannerTitleSelectionStyle {
const attrs = editor.getAttributes(BANNER_TITLE_TEXT_STYLE) as Record<string, unknown>;
const selection: BannerTitleSelectionStyle = {};
const size = Number(attrs.fontSize ? String(attrs.fontSize).replace(/[^\d]/g, "") : "");
if (BANNER_TITLE_FONT_SIZES.includes(size)) selection.fontSize = size;
const weight = Number(String(attrs.fontWeight || "").replace(/[^\d]/g, ""));
if (BANNER_TITLE_FONT_WEIGHTS.some((item) => item.value === weight)) selection.fontWeight = weight;
const family = bannerTitleFontFamilyKey(typeof attrs.fontFamily === "string" ? attrs.fontFamily : "");
if (family && family !== "sans") selection.fontFamily = family;
const color = normalizeBannerTitleHex(typeof attrs.color === "string" ? attrs.color : "");
if (color) selection.color = color;
return selection;
}
/**
* 把样式片段应用到当前选区。
*
* 没有用 Tiptap 的 `setMark`:同一文本节点上它会叠加多个 textStyle 标记(属性不合并),
* 反复调整会留下旧值。这里按节点先移除旧标记再写入合并后的属性,保证每个节点只有一个标记。
*/
export function applyBannerTitleStyle(editor: Editor, patch: BannerTitleStylePatch) {
const textStyle = editor.state.schema.marks[BANNER_TITLE_TEXT_STYLE];
if (!textStyle) return;
const markPatch: Record<string, string | null> = {};
if (patch.fontSize !== undefined) markPatch.fontSize = patch.fontSize ? `${patch.fontSize}px` : null;
if (patch.fontWeight !== undefined) markPatch.fontWeight = patch.fontWeight ? String(patch.fontWeight) : null;
if (patch.fontFamily !== undefined) markPatch.fontFamily = patch.fontFamily ? bannerTitleFontFamilyCSS(patch.fontFamily) || null : null;
if (patch.color !== undefined) markPatch.color = patch.color ? normalizeBannerTitleHex(patch.color) || null : null;
const { from, to, empty } = editor.state.selection;
const transaction = editor.state.tr;
if (empty) {
transaction.addStoredMark(textStyle.create({ ...(editor.getAttributes(BANNER_TITLE_TEXT_STYLE) as Record<string, unknown>), ...markPatch }));
} else {
editor.state.doc.nodesBetween(from, to, (node, pos) => {
if (!node.isText) return;
const start = Math.max(pos, from);
const end = Math.min(pos + node.nodeSize, to);
if (start >= end) return;
const attrs = textStyleAttributes(node);
transaction.removeMark(start, end, textStyle);
transaction.addMark(start, end, textStyle.create({ ...attrs, ...markPatch }));
});
}
editor.view.dispatch(transaction);
}
export function clearBannerTitleStyle(editor: Editor) {
const textStyle = editor.state.schema.marks[BANNER_TITLE_TEXT_STYLE];
if (!textStyle) return;
const { from, to, empty } = editor.state.selection;
const transaction = editor.state.tr;
if (empty) {
transaction.removeStoredMark(textStyle);
} else {
transaction.removeMark(from, to, textStyle);
}
editor.view.dispatch(transaction);
}
function bannerTitleMarkAttrs(run: BannerTitleRun) {
return {
fontSize: run.fontSize ? `${run.fontSize}px` : null,
fontWeight: run.fontWeight ? String(run.fontWeight) : null,
fontFamily: bannerTitleFontFamilyCSS(run.fontFamily) || null,
color: run.color || null,
};
}
/**
* 同一文本节点可能带有多个 textStyle 标记,按出现顺序合并,后写入的取最终值。
* 标记来自两处:编辑器里的 ProseMirror 节点(`mark.type` 是 MarkType 对象)与 JSON 文档(`mark.type` 是字符串)。
*/
function textStyleAttributes(node: { marks?: readonly { type?: string | { name?: string }; attrs?: Record<string, unknown> }[] }) {
const attrs: Record<string, unknown> = {};
for (const mark of node.marks || []) {
const name = typeof mark.type === "string" ? mark.type : mark.type?.name;
if (name !== BANNER_TITLE_TEXT_STYLE) continue;
Object.assign(attrs, mark.attrs || {});
}
return attrs;
}
export { BANNER_TITLE_FONT_FAMILIES };

View File

@@ -0,0 +1,167 @@
/**
* 首页常驻通知条的标题样式模型。
*
* 标题按「样式分段」存储:每段是一串连续文本加可选的字号 / 字重 / 字体 / 颜色覆盖。
* 后端只接受这里的白名单取值(字号 10-20、字重 400/500/600/700、字体 sans|serif|mono、颜色 #RRGGBB),
* 所以通知条渲染时不会出现任意 CSS 值。
*/
export type BannerTitleFontFamilyKey = "sans" | "serif" | "mono";
export type BannerTitleRun = {
text: string;
fontSize?: number;
fontWeight?: number;
fontFamily?: BannerTitleFontFamilyKey;
color?: string;
};
export const BANNER_TITLE_MAX_CHARS = 120;
export const BANNER_TITLE_MIN_FONT_SIZE = 10;
export const BANNER_TITLE_MAX_FONT_SIZE = 20;
/** 通知条标题默认字号,与前台 `text-xs` 一致。 */
export const BANNER_TITLE_DEFAULT_FONT_SIZE = 12;
/** 通知条标题默认字重,与前台 `font-semibold` 一致。 */
export const BANNER_TITLE_DEFAULT_FONT_WEIGHT = 600;
export const BANNER_TITLE_FONT_SIZES = [10, 11, 12, 13, 14, 15, 16, 18, 20];
export const BANNER_TITLE_FONT_WEIGHTS = [
{ value: 400, label: "常规" },
{ value: 500, label: "中等" },
{ value: 600, label: "半粗" },
{ value: 700, label: "粗体" },
];
export const BANNER_TITLE_FONT_FAMILIES: { key: BannerTitleFontFamilyKey; label: string; css: string }[] = [
{ key: "sans", label: "默认", css: "" },
{ key: "serif", label: "衬线", css: 'Georgia, "Songti SC", "SimSun", serif' },
{ key: "mono", label: "等宽", css: "var(--font-mono)" },
];
export function bannerTitleFontFamilyCSS(key?: BannerTitleFontFamilyKey) {
if (!key) return "";
return BANNER_TITLE_FONT_FAMILIES.find((item) => item.key === key)?.css || "";
}
/** 由 CSS 值反查字体档位;未知字体返回空,调用方按未设置处理。 */
export function bannerTitleFontFamilyKey(css?: string) {
const value = (css || "").trim();
if (!value) return "" as const;
return BANNER_TITLE_FONT_FAMILIES.find((item) => item.css === value)?.key || ("" as const);
}
/** 清洗单个分段:非法取值一律丢弃,不调用方兜底。 */
export function normalizeBannerTitleRun(run: BannerTitleRun): BannerTitleRun | null {
const text = typeof run.text === "string" ? run.text : "";
if (!text) return null;
const normalized: BannerTitleRun = { text };
const size = Number.isInteger(run.fontSize) ? (run.fontSize as number) : undefined;
if (size && size >= BANNER_TITLE_MIN_FONT_SIZE && size <= BANNER_TITLE_MAX_FONT_SIZE) normalized.fontSize = size;
const weight = BANNER_TITLE_FONT_WEIGHTS.find((item) => item.value === run.fontWeight)?.value;
if (weight) normalized.fontWeight = weight;
const family = BANNER_TITLE_FONT_FAMILIES.find((item) => item.key === run.fontFamily)?.key;
if (family && family !== "sans") normalized.fontFamily = family;
const color = normalizeBannerTitleHex(run.color);
if (color) normalized.color = color;
return normalized;
}
/** 清洗整段标题并合并相邻同样式分段,与后端 normalizeBannerTitleRuns 保持同一套规则。 */
export function normalizeBannerTitleRuns(runs?: BannerTitleRun[] | null): BannerTitleRun[] {
const normalized: BannerTitleRun[] = [];
for (const run of runs || []) {
const next = normalizeBannerTitleRun(run);
if (!next) continue;
const previous = normalized[normalized.length - 1];
if (previous && sameBannerTitleRunStyle(previous, next)) {
previous.text += next.text;
continue;
}
normalized.push(next);
}
return normalized;
}
export function sameBannerTitleRunStyle(left: BannerTitleRun, right: BannerTitleRun) {
return left.fontSize === right.fontSize && left.fontWeight === right.fontWeight && left.fontFamily === right.fontFamily && left.color === right.color;
}
export function bannerTitlePlainText(runs?: BannerTitleRun[] | null) {
return (runs || []).map((run) => run.text).join("");
}
/** 按码点计数,与后端 utf8.RuneCountInString 一致(emoji 等代理对按 1 个字符算)。 */
export function bannerTitleCharCount(runs?: BannerTitleRun[] | null) {
return Array.from(bannerTitlePlainText(runs)).length;
}
export function normalizeBannerTitleHex(value?: string) {
const raw = (value || "").trim().toUpperCase();
if (!raw) return "";
const hex = raw.startsWith("#") ? raw.slice(1) : raw;
if (/^[0-9A-F]{3}$/.test(hex)) {
return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`;
}
return /^[0-9A-F]{6}$/.test(hex) ? `#${hex}` : "";
}
/** 分段对应的内联样式;未设置的维度交给外层类名(通知条默认白色文字、12px)。 */
export function bannerTitleRunStyle(run: BannerTitleRun) {
const style: Record<string, string | number> = {};
if (run.fontSize) {
style.fontSize = `${run.fontSize}px`;
}
if (run.fontWeight) style.fontWeight = run.fontWeight;
const family = bannerTitleFontFamilyCSS(run.fontFamily);
if (family) style.fontFamily = family;
if (run.color) style.color = run.color;
return style;
}
/** 解析 #RGB / #RRGGBB / rgb() 为 0-255 三元组;无法解析时返回 null。 */
export function parseBannerTitleColor(value?: string): [number, number, number] | null {
const raw = (value || "").trim();
if (!raw) return null;
const hex = normalizeBannerTitleHex(raw);
if (hex) {
return [Number.parseInt(hex.slice(1, 3), 16), Number.parseInt(hex.slice(3, 5), 16), Number.parseInt(hex.slice(5, 7), 16)];
}
const rgb = raw.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i);
if (!rgb) return null;
return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
}
/** WCAG 对比度;任一颜色无法解析时返回 null。 */
export function bannerTitleContrastRatio(foreground?: string, background?: string) {
const front = parseBannerTitleColor(foreground);
const back = parseBannerTitleColor(background);
if (!front || !back) return null;
const frontLuminance = relativeLuminance(front);
const backLuminance = relativeLuminance(back);
const lighter = Math.max(frontLuminance, backLuminance);
const darker = Math.min(frontLuminance, backLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
function relativeLuminance([red, green, blue]: [number, number, number]) {
const channels = [red, green, blue].map((channel) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
/** 返回该主题下对比度不足 4.5:1 的文字颜色(去重),用于编辑时的可读性提示。 */
export function lowContrastBannerTitleColors(runs: BannerTitleRun[] | null | undefined, background: string) {
if (!background) return [];
const failing = new Set<string>();
for (const run of runs || []) {
if (!run.color) continue;
const ratio = bannerTitleContrastRatio(run.color, background);
if (ratio !== null && ratio < 4.5) failing.add(run.color);
}
return Array.from(failing);
}