feat(banner): 首页常驻滚动通知与管理配置及类型样式支持
This commit is contained in:
213
web/src/pages/admin/components/banner-title-editor.tsx
Normal file
213
web/src/pages/admin/components/banner-title-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user