Files
ttstd-infinite-canvas/web/src/pages/admin/components/admin-banner-announcements-panel.tsx
T

439 lines
18 KiB
TypeScript

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>
);
}