feat(banner): 首页常驻滚动通知与管理配置及类型样式支持
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" /> },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
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