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

199 lines
10 KiB
TypeScript

import { App, Button, Form, Input, Modal, Select } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Plus, Search } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { PaginationBar } from "@/components/layout/workspace-page";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import {
closeAdminAnnouncement,
createAdminAnnouncement,
listAdminAnnouncements,
updateAdminAnnouncement,
type AnnouncementLevel,
type AnnouncementStatus,
type SystemAnnouncement,
} from "@/services/api/announcements";
import { AdminDataTable, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "./admin-ui";
type AnnouncementFormValues = {
title: string;
content: string;
level: AnnouncementLevel;
};
const levelOptions: Array<{ value: AnnouncementLevel; label: string }> = [
{ value: "info", label: "平台通知" },
{ value: "success", label: "状态恢复" },
{ value: "warning", label: "服务提醒" },
{ value: "critical", label: "重要通知" },
];
const levelMeta: Record<AnnouncementLevel, { label: string; tone: "info" | "success" | "warning" | "error" }> = {
info: { label: "平台通知", tone: "info" },
success: { label: "状态恢复", tone: "success" },
warning: { label: "服务提醒", tone: "warning" },
critical: { label: "重要通知", tone: "error" },
};
export default function AdminAnnouncementsPanel() {
const { message } = App.useApp();
const [form] = Form.useForm<AnnouncementFormValues>();
const [announcements, setAnnouncements] = useState<SystemAnnouncement[]>([]);
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebouncedValue(keyword);
const [status, setStatus] = useState<"all" | AnnouncementStatus>("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingAnnouncement, setEditingAnnouncement] = useState<SystemAnnouncement | null>(null);
const [publishing, setPublishing] = useState(false);
const [closingId, setClosingId] = useState("");
const reload = useCallback(async () => {
setLoading(true);
try {
const data = await listAdminAnnouncements({ keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, limit: pageSize });
setAnnouncements(data.announcements || []);
setTotal(data.total || 0);
} catch (error) {
message.error(error instanceof Error ? error.message : "读取公告列表失败");
} finally {
setLoading(false);
}
}, [debouncedKeyword, message, page, pageSize, status]);
useEffect(() => {
void reload();
}, [reload]);
const openPublishModal = () => {
setEditingAnnouncement(null);
form.setFieldsValue({ title: "", content: "", level: "info" });
setModalOpen(true);
};
const openEditModal = (announcement: SystemAnnouncement) => {
setEditingAnnouncement(announcement);
form.setFieldsValue({ title: announcement.title, content: announcement.content, level: announcement.level });
setModalOpen(true);
};
const publish = async () => {
const values = await form.validateFields();
setPublishing(true);
try {
const input = { title: values.title.trim(), content: values.content.trim(), level: values.level };
if (editingAnnouncement) await updateAdminAnnouncement(editingAnnouncement.id, input);
else await createAdminAnnouncement(input);
setModalOpen(false);
setEditingAnnouncement(null);
setPage(1);
await reload();
message.success(editingAnnouncement ? "公告已更新并重新发布" : "公告已发布");
} catch (error) {
message.error(error instanceof Error ? error.message : editingAnnouncement ? "更新公告失败" : "发布公告失败");
} finally {
setPublishing(false);
}
};
const closeAnnouncement = async (announcement: SystemAnnouncement) => {
setClosingId(announcement.id);
try {
await closeAdminAnnouncement(announcement.id);
await reload();
message.success("公告已关闭");
} catch (error) {
message.error(error instanceof Error ? error.message : "关闭公告失败");
} finally {
setClosingId("");
}
};
const columns: ColumnsType<SystemAnnouncement> = [
{
title: "公告内容",
dataIndex: "title",
minWidth: 360,
render: (_, announcement) => (
<div className="min-w-0 py-0.5">
<div className="truncate text-sm font-medium text-foreground" title={announcement.title}>{announcement.title}</div>
<div className="mt-1 line-clamp-2 whitespace-pre-wrap text-xs leading-5 text-foreground/50">{announcement.content}</div>
</div>
),
},
{
title: "级别",
dataIndex: "level",
width: 120,
render: (level: AnnouncementLevel) => {
const meta = levelMeta[level] || levelMeta.info;
return <AdminStatusBadge label={meta.label} tone={meta.tone} />;
},
},
{
title: "状态",
dataIndex: "status",
width: 100,
render: (value: AnnouncementStatus) => <AdminStatusBadge label={value === "active" ? "发布中" : "已关闭"} tone={value === "active" ? "success" : "neutral"} />,
},
{
title: "发布时间",
dataIndex: "publishedAt",
width: 170,
render: formatDateTime,
},
{
title: "关闭时间",
dataIndex: "closedAt",
width: 170,
render: (value?: string) => value ? formatDateTime(value) : "--",
},
{
title: "操作",
key: "actions",
width: 160,
render: (_, announcement) => <AdminRowActions primary={{ label: "编辑", onClick: () => openEditModal(announcement) }} actions={announcement.status === "active" ? [{ key: "close", label: "关闭", danger: true, disabled: closingId === announcement.id, onClick: () => void closeAnnouncement(announcement), confirm: { title: "关闭这条公告?", description: "关闭后用户公告中心将不再展示,历史记录会保留。", okText: "关闭公告" } }] : []} />,
},
];
return (
<>
<AdminDataTable
toolbar={<Input allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索公告标题或正文" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />}
toolbarActiveFilters={<>{keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => { setKeyword(""); setPage(1); }} /> : null}{status !== "all" ? <AdminFilterChip label={`状态:${status === "active" ? "发布中" : "已关闭"}`} onRemove={() => { setStatus("all"); setPage(1); }} /> : null}</>}
toolbarActive={Boolean(keyword || status !== "all")}
toolbarFilters={<Select className="w-32" value={status} onChange={(value) => { setStatus(value); setPage(1); }} options={[{ label: "全部状态", value: "all" }, { label: "发布中", value: "active" }, { label: "已关闭", value: "closed" }]} />}
onReset={() => { setKeyword(""); setStatus("all"); setPage(1); }}
trailing={<Button type="primary" size="small" icon={<Plus className="size-4" />} onClick={openPublishModal}>发布公告</Button>}
table={{ rowKey: "id", size: "small", loading, pagination: false, columns, dataSource: announcements, scroll: { x: 1020 } }}
empty={<AdminTableEmpty filtered={Boolean(keyword || status !== "all")} title="暂无公告" />}
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />}
/>
<Modal title={editingAnnouncement ? "编辑并重新发布公告" : "发布系统公告"} open={modalOpen} width={760} centered okText={editingAnnouncement ? "保存并重新发布" : "立即发布"} cancelText="取消" confirmLoading={publishing} onOk={() => void publish()} onCancel={() => { setModalOpen(false); setEditingAnnouncement(null); }} destroyOnHidden>
<Form form={form} layout="vertical" className="pt-3" requiredMark={false}>
<Form.Item name="title" label="公告标题" rules={[{ required: true, whitespace: true, message: "请填写公告标题" }, { max: 120, message: "标题不能超过 120 个字符" }]}>
<Input maxLength={120} showCount placeholder="例如:视频模型已恢复正常使用" />
</Form.Item>
<Form.Item name="level" label="公告级别" rules={[{ required: true, message: "请选择公告级别" }]}>
<Select options={levelOptions} />
</Form.Item>
<Form.Item name="content" label="公告正文" rules={[{ required: true, whitespace: true, message: "请填写公告正文" }, { max: 4000, message: "正文不能超过 4000 个字符" }]}>
<Input.TextArea maxLength={4000} showCount autoSize={{ minRows: 6, maxRows: 12 }} placeholder="填写服务状态、影响范围和用户需要采取的操作" />
</Form.Item>
</Form>
</Modal>
</>
);
}
function formatDateTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(date).replaceAll("/", "-");
}