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,438 @@
import { App, Button, DatePicker, Form, Input, Modal, Popconfirm, Select, Switch } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Trash2, Edit3, Search } from "lucide-react";
import dayjs, { type Dayjs } from "dayjs";
import { useCallback, useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
createAdminBanner,
deleteAdminBanner,
listAdminBanners,
updateAdminBanner,
type BannerAnnouncement,
} from "@/services/api/announcements";
import {
BANNER_NOTICE_DEFAULT_TYPE,
bannerNoticeTypeLabel,
normalizeBannerNoticeType,
type BannerNoticeType,
} from "@/lib/announcements/banner-notice";
import { bannerAnnouncementBarStyle } from "@/components/layout/banner-announcement-content";
import { BANNER_TITLE_MAX_CHARS, bannerTitleCharCount, bannerTitlePlainText, normalizeBannerTitleRuns, type BannerTitleRun } from "@/lib/announcements/banner-title";
import { AdminDataTable } from "./admin-ui";
import { BannerNoticeTypeSelector } from "./banner-notice-type-selector";
import { BannerNoticePreview, BannerTitleEditor } from "./banner-title-editor";
type FormValues = {
noticeType: BannerNoticeType;
link?: string;
status: boolean;
dateRange?: [Dayjs | null, Dayjs | null] | null;
};
type BannerDialog = { mode: "create" } | { mode: "edit"; banner: BannerAnnouncement };
export default function AdminBannerAnnouncementsPanel({
createOpen,
onCreateOpenChange,
}: {
createOpen: boolean;
onCreateOpenChange: (open: boolean) => void;
}) {
const { message, modal } = App.useApp();
const queryClient = useQueryClient();
const [form] = Form.useForm<FormValues>();
const linkValue = Form.useWatch("link", form);
const [banners, setBanners] = useState<BannerAnnouncement[]>([]);
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState<string>("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [dialog, setDialog] = useState<BannerDialog | null>(null);
const [titleRuns, setTitleRuns] = useState<BannerTitleRun[]>([]);
const [noticeType, setNoticeType] = useState<BannerNoticeType>(BANNER_NOTICE_DEFAULT_TYPE);
const [titleTouched, setTitleTouched] = useState(false);
const [dirty, setDirty] = useState(false);
const [submitting, setSubmitting] = useState(false);
const reload = async (targetPage = page, targetPageSize = pageSize) => {
setLoading(true);
try {
const data = await listAdminBanners({
keyword: keyword.trim() || undefined,
status: status === "all" ? undefined : status,
page: targetPage,
pageSize: targetPageSize,
});
setBanners(data.banners || []);
setTotal(data.total || 0);
} catch (error) {
message.error(error instanceof Error ? error.message : "读取常驻通知列表失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
void reload(1, pageSize);
}, [keyword, status]);
const openCreate = useCallback(() => {
setTitleRuns([]);
setNoticeType(BANNER_NOTICE_DEFAULT_TYPE);
setTitleTouched(false);
setDirty(false);
form.resetFields();
form.setFieldsValue({
noticeType: BANNER_NOTICE_DEFAULT_TYPE,
status: true,
});
setDialog({ mode: "create" });
}, [form]);
// 父级的 createOpen 只是「请求新建」信号:消费后立即复位,避免它再次触发时把编辑中的内容重置掉。
useEffect(() => {
if (!createOpen) return;
onCreateOpenChange(false);
openCreate();
}, [createOpen, onCreateOpenChange, openCreate]);
const openEdit = (banner: BannerAnnouncement) => {
const initialRuns = banner.titleRuns?.length ? banner.titleRuns : banner.title ? [{ text: banner.title }] : [];
const type = normalizeBannerNoticeType(banner.noticeType);
setTitleRuns(initialRuns);
setNoticeType(type);
setTitleTouched(false);
setDirty(false);
form.setFieldsValue({
noticeType: type,
link: banner.link || undefined,
status: banner.status === "active",
dateRange: banner.startsAt || banner.endsAt ? [banner.startsAt ? dayjs(banner.startsAt) : null, banner.endsAt ? dayjs(banner.endsAt) : null] : null,
});
setDialog({ mode: "edit", banner });
};
const requestClose = () => {
if (submitting) return;
if (!dirty) {
setDialog(null);
return;
}
modal.confirm({
title: "放弃未保存的修改?",
content: "关闭后本次编辑的内容不会保留。",
okText: "放弃修改",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => setDialog(null),
});
};
const handleDelete = async (id: string) => {
try {
await deleteAdminBanner(id);
message.success("常驻通知已删除");
void queryClient.invalidateQueries({ queryKey: ["active-banner-announcements"] });
void reload(page, pageSize);
} catch (error) {
message.error(error instanceof Error ? error.message : "删除失败");
}
};
const handleToggleStatus = async (banner: BannerAnnouncement, nextStatus: boolean) => {
try {
await updateAdminBanner(banner.id, {
title: banner.title,
// 必须带上样式分段与通知类型:后端更新走显式字段表,漏传会把它们清空。
titleRuns: banner.titleRuns,
noticeType: normalizeBannerNoticeType(banner.noticeType),
link: banner.link,
status: nextStatus ? "active" : "disabled",
startsAt: banner.startsAt,
endsAt: banner.endsAt,
});
message.success(nextStatus ? "常驻通知已启用" : "常驻通知已禁用");
void queryClient.invalidateQueries({ queryKey: ["active-banner-announcements"] });
void reload(page, pageSize);
} catch (error) {
message.error(error instanceof Error ? error.message : "更新状态失败");
}
};
const handleSubmit = async (values: FormValues) => {
setTitleTouched(true);
const runs = normalizeBannerTitleRuns(titleRuns);
const plain = bannerTitlePlainText(runs).trim();
if (!plain || !runs.length) {
message.error("请填写通知标题");
return;
}
if (bannerTitleCharCount(runs) > BANNER_TITLE_MAX_CHARS) {
message.error(`通知标题不能超过 ${BANNER_TITLE_MAX_CHARS} 个字符`);
return;
}
setSubmitting(true);
try {
const startsAt = values.dateRange?.[0] ? values.dateRange[0].toISOString() : undefined;
const endsAt = values.dateRange?.[1] ? values.dateRange[1].toISOString() : undefined;
const selectedNoticeType = values.noticeType || noticeType;
const payload = {
title: plain,
titleRuns: runs,
noticeType: selectedNoticeType,
link: values.link?.trim() || undefined,
status: (values.status ? "active" : "disabled") as "active" | "disabled",
startsAt,
endsAt,
};
if (dialog?.mode === "edit") {
await updateAdminBanner(dialog.banner.id, payload);
message.success("常驻通知已更新");
} else {
await createAdminBanner(payload);
message.success("常驻通知已新建");
}
setDialog(null);
form.resetFields();
void queryClient.invalidateQueries({ queryKey: ["active-banner-announcements"] });
void reload(1, pageSize);
} catch (error) {
message.error(error instanceof Error ? error.message : "保存常驻通知失败");
} finally {
setSubmitting(false);
}
};
const columns: ColumnsType<BannerAnnouncement> = [
{
title: "通知标题",
dataIndex: "title",
key: "title",
render: (text: string, record) => (
<div className="flex flex-col gap-0.5">
<span className="font-medium text-foreground">{text}</span>
{record.link ? (
<span className="font-mono text-[var(--fs-label)] text-foreground/45" title={record.link}>
{record.link.length > 44 ? `${record.link.slice(0, 44)}…` : record.link}
</span>
) : null}
</div>
),
},
{
title: "类型",
key: "noticeType",
width: 92,
// 用真实底色渲染类型,和前台/预览看到的是同一个 token。
render: (_, record) => (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[var(--fs-micro)] font-semibold text-white"
style={bannerAnnouncementBarStyle(record.noticeType)}
>
{bannerNoticeTypeLabel(record.noticeType)}
</span>
),
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 110,
render: (value: string, record) => (
<Switch
size="small"
checked={value === "active"}
onChange={(checked) => void handleToggleStatus(record, checked)}
/>
),
},
{
title: "有效期",
key: "validity",
width: 280,
render: (_, record) => {
if (!record.startsAt && !record.endsAt) {
return <span className="text-foreground/45">长期有效</span>;
}
const startStr = record.startsAt ? dayjs(record.startsAt).format("YYYY-MM-DD HH:mm") : "不限";
const endStr = record.endsAt ? dayjs(record.endsAt).format("YYYY-MM-DD HH:mm") : "不限";
return (
<span className="text-xs text-foreground/75 font-mono">
{startStr} 至 {endStr}
</span>
);
},
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 160,
render: (date: string) => <span className="text-xs text-foreground/60 font-mono">{dayjs(date).format("YYYY-MM-DD HH:mm")}</span>,
},
{
title: "操作",
key: "action",
width: 120,
align: "right",
render: (_, record) => (
<div className="flex justify-end gap-1">
<Button type="text" size="small" icon={<Edit3 className="size-3.5" />} onClick={() => openEdit(record)}>
编辑
</Button>
<Popconfirm title="确认删除此常驻通知?" onConfirm={() => void handleDelete(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-3.5" />}>
删除
</Button>
</Popconfirm>
</div>
),
},
];
const hasFilters = Boolean(keyword.trim() || status !== "all");
const titleMissing = titleTouched && (!titleRuns.length || !bannerTitlePlainText(titleRuns).trim());
const titleTooLong = bannerTitleCharCount(titleRuns) > BANNER_TITLE_MAX_CHARS;
return (
<div className="flex min-h-0 flex-1 flex-col pb-4">
<AdminDataTable
toolbar={
<Input
allowClear
aria-label="搜索常驻通知标题"
className="app-list-search"
prefix={<Search className="size-4 text-foreground/40" />}
value={keyword}
placeholder="搜索常驻通知标题"
onChange={(event) => {
setKeyword(event.target.value);
setPage(1);
}}
/>
}
toolbarActive={hasFilters}
toolbarFilters={
<Select
aria-label="按状态筛选常驻通知"
className="w-32"
value={status}
onChange={(value) => {
setStatus(value);
setPage(1);
}}
options={[
{ label: "全部状态", value: "all" },
{ label: "已启用", value: "active" },
{ label: "已禁用", value: "disabled" },
]}
/>
}
onReset={() => {
setKeyword("");
setStatus("all");
setPage(1);
}}
table={{
rowKey: "id",
size: "small",
loading,
columns,
dataSource: banners,
pagination: {
current: page,
pageSize,
total,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
void reload(p, ps);
},
},
}}
/>
<Modal
title={dialog?.mode === "edit" ? "编辑常驻通知" : "新建常驻通知"}
open={Boolean(dialog)}
onCancel={requestClose}
onOk={() => form.submit()}
confirmLoading={submitting}
centered
destroyOnHidden
width="min(880px, calc(100vw - 32px))"
rootClassName="admin-modal-root"
>
<Form
form={form}
layout="vertical"
// 表单比弹窗高时在表单内部滚动,底部按钮始终可见;下拉层由 AntD 挂到 body,不会被裁剪。
style={{ maxHeight: "68vh", overflowY: "auto", paddingRight: 4 }}
onFinish={(values) => void handleSubmit(values)}
onValuesChange={() => setDirty(true)}
>
<Form.Item name="noticeType" label="通知类型" extra="决定通知条底色;浅色 / 深色主题各有一档取值">
<BannerNoticeTypeSelector
value={noticeType}
onChange={(next) => {
setNoticeType(next);
form.setFieldValue("noticeType", next);
setDirty(true);
}}
/>
</Form.Item>
<Form.Item
label="通知标题"
required
validateStatus={titleMissing || titleTooLong ? "error" : undefined}
help={
titleMissing
? "请输入常驻通知标题"
: titleTooLong
? `已超出 ${BANNER_TITLE_MAX_CHARS} 个字符上限`
: "支持按选区设置字号、字重、字体与颜色,用「图标」插入 emoji 素材;标题在通知条内固定单行展示"
}
>
<BannerTitleEditor
key={dialog?.mode === "edit" ? dialog.banner.id : "create"}
value={titleRuns}
onChange={(runs) => {
setTitleRuns(runs);
setDirty(true);
}}
/>
</Form.Item>
{/* 预览放在 Form 内但不用 Form.Item:避免 AntD 向非表单控件注入 value/onChange/ref。 */}
<div className="mb-6">
<div className="mb-2 text-sm text-foreground/85">前台效果预览</div>
<BannerNoticePreview runs={titleRuns} hasLink={Boolean(linkValue?.trim())} noticeType={noticeType} />
</div>
<Form.Item
name="link"
label="跳转链接(可选)"
extra="支持 https:// 外链或以 / 开头的站内路径(如 /projects);留空表示不可点击"
rules={[{ max: 500, message: "链接最多 500 字符" }]}
>
<Input maxLength={500} placeholder="https://… 或 /projects" />
</Form.Item>
<Form.Item name="status" label="发布状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
<Form.Item name="dateRange" label="有效期(可选)" extra="留空表示长期有效;设置时间段后仅在有效期内于前台展示">
<DatePicker.RangePicker showTime format="YYYY-MM-DD HH:mm" className="w-full" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -20,6 +20,7 @@ import {
KeyRound,
Layers3,
Mail,
Megaphone,
MessageSquareText,
Moon,
Palette,
@@ -77,6 +78,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
label: "运营",
items: [
{ path: "/admin/announcements", label: "系统公告", description: "发布、关闭与历史公告", icon: <BellRing className="size-4" /> },
{ path: "/admin/banner-announcements", label: "常驻通知", description: "首页顶部常驻滚动通知", icon: <Megaphone className="size-4" /> },
{ path: "/admin/payments", label: "支付充值", description: "支付渠道、订单与对账", icon: <CreditCard className="size-4" /> },
{ path: "/admin/credit-operations", label: "积分运营", description: "人工调账与异常计费", icon: <Coins className="size-4" /> },
{ path: "/admin/redemption-codes", label: "兑换码", description: "生成与查看兑换码批次", icon: <TicketCheck className="size-4" /> },

View File

@@ -0,0 +1,90 @@
import { Input, Popover, Button } from "antd";
import { SmilePlus, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { BANNER_NOTICE_EMOJI_GROUPS, searchBannerNoticeEmojis } from "@/lib/announcements/banner-notice";
/**
* emoji 图标素材面板:按分组铺开,支持按中文名搜索。
* 选中即作为普通文本插进标题光标处,可插任意多个、任意位置,并跟随光标处的字号 / 颜色样式。
* 面板不自动关闭,方便连续插入;点击面板外任意位置收起。
*/
export function BannerNoticeEmojiPanel({ onPick }: { onPick: (emoji: string) => void }) {
const [keyword, setKeyword] = useState("");
const matched = useMemo(() => new Set(searchBannerNoticeEmojis(keyword).map((item) => item.char)), [keyword]);
const groups = useMemo(
() =>
BANNER_NOTICE_EMOJI_GROUPS.map((group) => ({
...group,
emoji: group.emoji.filter((item) => matched.has(item.char)),
})).filter((group) => group.emoji.length > 0),
[matched],
);
return (
<div className="admin-banner-icon-picker">
<div className="admin-banner-icon-picker-search">
<Input
size="small"
allowClear
aria-label="搜索图标素材"
placeholder="搜索素材(如 礼物、火箭)"
prefix={<Search className="size-3.5 text-foreground/40" aria-hidden="true" />}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
</div>
<div className="admin-banner-icon-picker-body">
{groups.length ? (
groups.map((group) => (
<div key={group.key} className="admin-banner-icon-picker-group">
<span className="admin-banner-icon-picker-group-label">{group.label}</span>
<div className="admin-banner-icon-picker-grid" role="listbox" aria-label={`${group.label}素材`}>
{group.emoji.map((item) => (
<button
key={item.char}
type="button"
role="option"
aria-selected="false"
aria-label={item.label}
title={item.label}
className="admin-banner-icon-picker-option admin-banner-icon-picker-option-emoji"
onClick={() => onPick(item.char)}
>
{item.char}
</button>
))}
</div>
</div>
))
) : (
<p className="admin-banner-icon-picker-empty">没有匹配「{keyword.trim()}」的素材</p>
)}
</div>
</div>
);
}
/**
* 标题编辑工具栏的「插入图标」入口:默认不占任何视觉位,点了才弹出素材面板。
* onPick 由调用方提供(编辑器要把内容插到光标处)。
*/
export function BannerNoticeEmojiPopover({ onPick }: { onPick: (emoji: string) => void }) {
return (
<Popover
trigger="click"
placement="bottomLeft"
arrow={false}
content={<BannerNoticeEmojiPanel onPick={onPick} />}
// AntD 6 Popover 的语义样式键是 content / container / root / arrow,没有 body;
// container 对应 .ant-popover-inner(内边距所在元素)。
styles={{ container: { padding: 0 } }}
>
<Button size="small" type="text" title="在光标处插入 emoji 图标素材" icon={<SmilePlus className="size-3.5" />} aria-label="插入图标素材">
图标
</Button>
</Popover>
);
}

View File

@@ -0,0 +1,40 @@
import { Check } from "lucide-react";
import { BANNER_NOTICE_TYPES, normalizeBannerNoticeType, type BannerNoticeType } from "@/lib/announcements/banner-notice";
/**
* 通知类型选择:每种类型决定通知条底色,卡片上直接铺真实底色条,
* 免得用「蓝色 / 橙色」这种文字去描述一个已经看得见的颜色。
*/
export function BannerNoticeTypeSelector({ value, onChange }: { value: string; onChange: (type: BannerNoticeType) => void }) {
const active = normalizeBannerNoticeType(value);
return (
<div className="admin-banner-type-grid" role="radiogroup" aria-label="通知类型">
{BANNER_NOTICE_TYPES.map((type) => {
const selected = active === type.key;
return (
<button
key={type.key}
type="button"
role="radio"
aria-checked={selected}
data-banner-type={type.key}
className={`admin-banner-type-card${selected ? " is-active" : ""}`}
onClick={() => onChange(type.key)}
>
<span className="admin-banner-type-card-name">
{type.label}
{selected ? <Check className="admin-banner-type-card-check size-3.5" aria-hidden="true" /> : null}
</span>
<span className="admin-banner-type-card-hint">{type.hint}</span>
{/* 真实底色条:颜色来自该类型 token,随当前主题解析 */}
<span className="admin-banner-type-card-preview" aria-hidden="true">
<span className="truncate">{type.label}通知效果预览</span>
</span>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,213 @@
import type { Editor } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import { Button, ColorPicker, Select, Tooltip } from "antd";
import { Eraser, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
BannerAnnouncementLinkHint,
BannerAnnouncementTitle,
bannerAnnouncementBarStyle,
} from "@/components/layout/banner-announcement-content";
import { readBannerNoticeSurface, bannerNoticeTypeLabel } from "@/lib/announcements/banner-notice";
import { BannerNoticeEmojiPopover } from "./banner-notice-emoji-picker";
import {
BANNER_TITLE_DEFAULT_FONT_SIZE,
BANNER_TITLE_DEFAULT_FONT_WEIGHT,
BANNER_TITLE_FONT_FAMILIES,
BANNER_TITLE_FONT_SIZES,
BANNER_TITLE_FONT_WEIGHTS,
BANNER_TITLE_MAX_CHARS,
bannerTitleCharCount,
lowContrastBannerTitleColors,
type BannerTitleRun,
} from "@/lib/announcements/banner-title";
import {
applyBannerTitleStyle,
bannerDocToRuns,
bannerTitleRunsToDoc,
bannerTitleSelectionStyle,
clearBannerTitleStyle,
createBannerTitleExtensions,
type BannerTitleSelectionStyle,
type BannerTitleStylePatch,
} from "@/lib/announcements/banner-title-rich-text";
const DEFAULT_OPTION = "default";
/** 一次性读出编辑器当前的分段与光标处样式,供受控渲染和回调共用。 */
function readEditorTitle(instance: Editor) {
const runs = bannerDocToRuns(instance.getJSON());
return { runs, selection: bannerTitleSelectionStyle(instance) };
}
/**
* 通知标题编辑区:文字按选区设置字号 / 字重 / 字体 / 颜色。
* 组件只把 title 分段当初始值,切换编辑对象时由调用方通过 key 重建实例。
*/
export function BannerTitleEditor({ value, onChange }: { value: BannerTitleRun[]; onChange: (runs: BannerTitleRun[]) => void }) {
const [selectionStyle, setSelectionStyle] = useState<BannerTitleSelectionStyle>({});
const [charCount, setCharCount] = useState(() => bannerTitleCharCount(value));
const editor = useEditor({
immediatelyRender: false,
extensions: createBannerTitleExtensions("输入首页顶部展示的通知标题"),
content: bannerTitleRunsToDoc(value),
editorProps: {
attributes: { class: "outline-none", "aria-label": "通知标题编辑区" },
handleKeyDown: (_view, event) => {
// 通知条标题是单行语义,Enter 不产生新段落。
return event.key === "Enter";
},
},
onUpdate: ({ editor: instance }) => {
const { runs, selection } = readEditorTitle(instance);
setSelectionStyle(selection);
setCharCount(bannerTitleCharCount(runs));
onChange(runs);
},
});
const applyStyle = (patch: BannerTitleStylePatch) => {
if (!editor) return;
editor.view.focus();
applyBannerTitleStyle(editor, patch);
const { runs, selection } = readEditorTitle(editor);
setSelectionStyle(selection);
setCharCount(bannerTitleCharCount(runs));
onChange(runs);
};
const tooLong = charCount > BANNER_TITLE_MAX_CHARS;
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Select
size="small"
className="w-[104px]"
aria-label="标题字号"
value={selectionStyle.fontSize ?? DEFAULT_OPTION}
onChange={(next) => applyStyle({ fontSize: next === DEFAULT_OPTION ? null : Number(next) })}
options={[
{ value: DEFAULT_OPTION, label: `默认(${BANNER_TITLE_DEFAULT_FONT_SIZE}px)` },
...BANNER_TITLE_FONT_SIZES.map((size) => ({ value: size, label: `${size}px` })),
]}
/>
<Select
size="small"
className="w-[92px]"
aria-label="标题字重"
value={selectionStyle.fontWeight ?? DEFAULT_OPTION}
onChange={(next) => applyStyle({ fontWeight: next === DEFAULT_OPTION ? null : Number(next) })}
options={[
{ value: DEFAULT_OPTION, label: "默认字重" },
...BANNER_TITLE_FONT_WEIGHTS.map((item) => ({ value: item.value, label: `${item.label} ${item.value}` })),
]}
/>
<Select
size="small"
className="w-[92px]"
aria-label="标题字体"
value={selectionStyle.fontFamily ?? DEFAULT_OPTION}
onChange={(next) => applyStyle({ fontFamily: next === DEFAULT_OPTION ? null : (next as BannerTitleRun["fontFamily"]) })}
options={[{ value: DEFAULT_OPTION, label: "默认字体" }, ...BANNER_TITLE_FONT_FAMILIES.map((item) => ({ value: item.key, label: item.label }))]}
/>
<ColorPicker
size="small"
aria-label="标题颜色"
value={selectionStyle.color || "#FFFFFF"}
onChange={(color) => applyStyle({ color: color.toHexString().toUpperCase() })}
presets={[{ label: "通知条常用", colors: ["#FFFFFF", "#FFE58F", "#FFB37B", "#FF9AA2", "#B7EB8F", "#8FD3FF", "#D3ADF7", "#1F1F1F"] }]}
/>
{/* 不能用 Tooltip 包住 EmojiPopover:Tooltip 会向 Popover 要 ref,嵌套弹层行为不可靠。 */}
<BannerNoticeEmojiPopover
onPick={(emoji) => {
if (!editor) return;
// emoji 是普通文本:插进光标处,跟随当前选区的字号 / 颜色样式,可连续插入。
editor.chain().focus().insertContent(emoji).run();
}}
/>
<Tooltip title="清除所选文字的样式">
<Button
size="small"
type="text"
icon={<Eraser className="size-3.5" />}
aria-label="清除样式"
onClick={() => {
if (!editor) return;
clearBannerTitleStyle(editor);
const { runs, selection } = readEditorTitle(editor);
setSelectionStyle(selection);
setCharCount(bannerTitleCharCount(runs));
onChange(runs);
}}
/>
</Tooltip>
<span className={`ml-auto font-mono text-[var(--fs-label)] ${tooLong ? "text-[var(--admin-status-error)]" : "text-foreground/45"}`}>
{charCount}/{BANNER_TITLE_MAX_CHARS}
</span>
</div>
<div className="admin-banner-title-editor" onClick={() => editor?.view.focus()}>
<EditorContent editor={editor} />
</div>
<p className="text-[var(--fs-label)] text-foreground/45">
选中文字后调整样式;未选中时设置后续输入的文字样式。「图标」可在光标处插入 emoji 素材,位置与个数不限。默认字号 {BANNER_TITLE_DEFAULT_FONT_SIZE}px、字重 {BANNER_TITLE_DEFAULT_FONT_WEIGHT},与通知条一致。
</p>
</div>
);
}
/**
* 前台效果预览:明暗两态各一条,按当前通知类型取各自主题下的真实底色。
* 标题分段全部走前台同一份展示单元,因此预览即前台结果。
*/
export function BannerNoticePreview({ runs, hasLink, noticeType }: { runs: BannerTitleRun[]; hasLink: boolean; noticeType: string }) {
const lightRef = useRef<HTMLDivElement | null>(null);
const darkRef = useRef<HTMLDivElement | null>(null);
const [backgrounds, setBackgrounds] = useState({ light: "", dark: "" });
useEffect(() => {
// 底色由类型决定,切换类型后需要重新解析两个预览容器里的实际取值。
setBackgrounds({
light: readBannerNoticeSurface(noticeType, lightRef.current),
dark: readBannerNoticeSurface(noticeType, darkRef.current),
});
}, [noticeType]);
const themes = [
{ key: "light", label: "浅色主题", background: backgrounds.light, ref: lightRef, className: "admin-banner-title-preview-light" },
{ key: "dark", label: "深色主题", background: backgrounds.dark, ref: darkRef, className: "admin-banner-title-preview-dark" },
] as const;
return (
<div className="space-y-2">
{themes.map((theme) => {
const lowContrast = lowContrastBannerTitleColors(runs, theme.background);
return (
<div key={theme.key} className="space-y-1">
<div
ref={theme.ref}
className={`relative flex min-h-10 w-full items-center justify-center px-4 py-2 text-white ${theme.className}`}
style={bannerAnnouncementBarStyle(noticeType)}
>
<span className="flex w-full min-w-0 items-center justify-center gap-2.5 px-8">
<span className="min-w-0 truncate font-semibold">
<BannerAnnouncementTitle runs={runs} fallbackText="通知标题预览" />
</span>
{hasLink ? <BannerAnnouncementLinkHint /> : null}
</span>
<X className="absolute right-4 size-3.5 shrink-0 text-white" aria-hidden="true" />
</div>
<p className="text-[var(--fs-label)] text-foreground/45">
{theme.label} · {bannerNoticeTypeLabel(noticeType)}
{lowContrast.length ? ` · ${lowContrast.join("、")} 在该底色上对比度不足 4.5:1,建议调亮或调暗` : " · 对比度达标"}
</p>
</div>
);
})}
</div>
);
}