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

350 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { App, Button, Form, Input, Modal, Select, Segmented, Switch, Tag } from "antd";
import type { FormInstance } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Eye, Pencil, Pin, Plus, Search, Upload, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from "react";
import { PaginationBar } from "@/components/layout/workspace-page";
import { AnnouncementContent } from "@/components/ui/announcement-content";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import {
announcementImageUrl,
closeAdminAnnouncement,
createAdminAnnouncement,
discardAdminAnnouncementImage,
listAdminAnnouncements,
uploadAdminAnnouncementImage,
updateAdminAnnouncement,
type AnnouncementLevel,
type AnnouncementStatus,
type SystemAnnouncement,
} from "@/services/api/announcements";
import { resourceFileUrl } from "@/services/api/resources";
import { AdminDataTable, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "./admin-ui";
type AnnouncementFormValues = {
title: string;
content: string;
imageResourceId?: string;
level: AnnouncementLevel;
pinned: boolean;
};
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 [imagePreviewUrl, setImagePreviewUrl] = useState("");
const [draftImageResourceId, setDraftImageResourceId] = useState("");
const [imageUploading, setImageUploading] = useState(false);
const [editorMode, setEditorMode] = useState<"edit" | "preview">("edit");
const imageInputRef = useRef<HTMLInputElement | null>(null);
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: "", imageResourceId: "", level: "info", pinned: false });
setImagePreviewUrl("");
setDraftImageResourceId("");
setEditorMode("edit");
setModalOpen(true);
};
const openEditModal = (announcement: SystemAnnouncement) => {
setEditingAnnouncement(announcement);
form.setFieldsValue({ title: announcement.title, content: announcement.content, imageResourceId: announcement.imageResourceId || "", level: announcement.level, pinned: announcement.pinned });
setImagePreviewUrl(announcementImageUrl(announcement));
setDraftImageResourceId("");
setEditorMode("edit");
setModalOpen(true);
};
const discardDraftImage = async () => {
if (!draftImageResourceId) return;
await discardAdminAnnouncementImage(draftImageResourceId);
setDraftImageResourceId("");
};
const uploadAnnouncementImage = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
message.warning("公告配图必须是图片文件");
return;
}
if (file.size > 10 * 1024 * 1024) {
message.warning("公告配图不能超过 10MB");
return;
}
setImageUploading(true);
try {
if (draftImageResourceId) await discardDraftImage();
const { resource } = await uploadAdminAnnouncementImage(file);
form.setFieldValue("imageResourceId", resource.id);
setDraftImageResourceId(resource.id);
setImagePreviewUrl(resourceFileUrl(resource.id));
message.success("公告配图已上传");
} catch (error) {
message.error(error instanceof Error ? error.message : "公告配图上传失败");
} finally {
setImageUploading(false);
}
};
const clearAnnouncementImage = async () => {
setImageUploading(true);
try {
await discardDraftImage();
form.setFieldValue("imageResourceId", "");
setImagePreviewUrl("");
} catch (error) {
message.error(error instanceof Error ? error.message : "公告配图清理失败");
} finally {
setImageUploading(false);
}
};
const closeEditor = async () => {
setImageUploading(true);
try {
await discardDraftImage();
setModalOpen(false);
setEditingAnnouncement(null);
setImagePreviewUrl("");
form.resetFields();
} catch (error) {
message.error(error instanceof Error ? error.message : "公告配图草稿清理失败,请重试");
} finally {
setImageUploading(false);
}
};
const publish = async () => {
const values = await form.validateFields();
setPublishing(true);
try {
const input = { title: values.title.trim(), content: values.content.trim(), imageResourceId: values.imageResourceId?.trim() || "", level: values.level, pinned: Boolean(values.pinned) };
if (editingAnnouncement) await updateAdminAnnouncement(editingAnnouncement.id, input);
else await createAdminAnnouncement(input);
setDraftImageResourceId("");
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="flex min-w-0 items-center gap-3 py-0.5">
{announcement.imageUrl ? <img src={announcementImageUrl(announcement)} alt="" loading="lazy" decoding="async" className="size-14 shrink-0 rounded-md border border-border/70 bg-muted/20 object-contain p-0.5" /> : null}
<div className="min-w-0">
<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>
</div>
),
},
{
title: "置顶",
dataIndex: "pinned",
width: 90,
render: (pinned: boolean) => pinned ? <Tag color="gold" icon={<Pin className="size-3" />}>置顶</Tag> : <span className="text-foreground/35">--</span>,
},
{
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={editorMode === "preview" ? (editingAnnouncement ? "确认并重新发布" : "确认并发布") : "请先预览"} cancelText="取消" confirmLoading={publishing} okButtonProps={{ disabled: imageUploading || editorMode !== "preview" }} cancelButtonProps={{ disabled: imageUploading }} onOk={() => void publish()} onCancel={() => void closeEditor()} destroyOnHidden>
<div className="flex justify-end border-b border-border/70 pb-3 pt-3">
<Segmented
value={editorMode}
onChange={(value) => setEditorMode(value as "edit" | "preview")}
options={[
{ value: "edit", label: <span className="inline-flex items-center gap-1.5"><Pencil className="size-3.5" />编辑</span> },
{ value: "preview", label: <span className="inline-flex items-center gap-1.5"><Eye className="size-3.5" />预览</span> },
]}
/>
</div>
{editorMode === "preview" ? <AnnouncementDraftPreview form={form} imagePreviewUrl={imagePreviewUrl} /> : null}
<Form form={form} layout="vertical" className={editorMode === "preview" ? "hidden" : "pt-4"} 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="pinned" label="展示方式" valuePropName="checked" extra="置顶公告会优先展示。">
<Switch checkedChildren="置顶" unCheckedChildren="普通" />
</Form.Item>
<Form.Item name="imageResourceId" hidden><Input /></Form.Item>
<Form.Item label="公告配图" extra="可选,真实图片文件不超过 10MB;取消、替换或移除时会回收未发布草稿。">
<div className="space-y-2">
{imagePreviewUrl ? (
<div className="relative flex min-h-28 items-center justify-center overflow-hidden rounded-md border border-border/70 bg-muted/20 p-2">
<img src={imagePreviewUrl} alt="公告配图预览" className="max-h-44 w-full object-contain" />
<Button type="text" size="small" danger disabled={imageUploading} icon={<X className="size-3.5" />} className="!absolute right-1 top-1 !size-7 !min-w-7 !p-0" onClick={() => void clearAnnouncementImage()} aria-label="移除公告配图" title="移除公告配图" />
</div>
) : <div className="flex min-h-24 items-center justify-center rounded-md border border-dashed border-border/80 bg-muted/10 text-xs text-foreground/45">暂未添加配图</div>}
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => void uploadAnnouncementImage(event)} />
<Button icon={<Upload className="size-3.5" />} loading={imageUploading} onClick={() => imageInputRef.current?.click()}>
{imagePreviewUrl ? "更换配图" : "上传配图"}
</Button>
</div>
</Form.Item>
<Form.Item
name="content"
label="公告正文(支持 Markdown)"
extra="支持标题、加粗、列表、链接、引用和表格;不支持原始 HTML。"
rules={[{ required: true, whitespace: true, message: "请填写公告正文" }, { max: 4000, message: "正文不能超过 4000 个字符" }]}
>
<Input.TextArea maxLength={4000} showCount autoSize={{ minRows: 6, maxRows: 12 }} placeholder="填写服务状态、影响范围和用户需要采取的操作,可使用 Markdown 语法" />
</Form.Item>
</Form>
</Modal>
</>
);
}
function AnnouncementDraftPreview({ form, imagePreviewUrl }: { form: FormInstance<AnnouncementFormValues>; imagePreviewUrl: string }) {
const title = Form.useWatch("title", form) || "未填写标题";
const content = Form.useWatch("content", form) || "暂未填写公告正文";
const level = Form.useWatch("level", form) || "info";
const pinned = Form.useWatch("pinned", form);
const meta = levelMeta[level as AnnouncementLevel] || levelMeta.info;
return (
<div className="min-h-80 py-5">
<div className="mx-auto max-w-2xl overflow-hidden rounded-xl border border-border/70 bg-background shadow-sm">
<div className="flex items-center justify-between border-b border-border/60 px-5 py-3">
<div className="flex items-center gap-2">
<AdminStatusBadge label={meta.label} tone={meta.tone} />
{pinned ? <span className="inline-flex items-center gap-1 text-xs font-medium text-amber-500"><Pin className="size-3" />置顶</span> : null}
</div>
<span className="text-xs text-foreground/40">预览效果</span>
</div>
<div className="p-5">
<h2 className="text-lg font-semibold leading-7 text-foreground">{title}</h2>
{imagePreviewUrl ? <img src={imagePreviewUrl} alt="公告配图预览" className="mt-4 max-h-56 w-full rounded-lg border border-border/70 bg-muted/20 object-contain p-1" /> : null}
<AnnouncementContent content={content} className="mt-3 text-sm leading-6 text-foreground/75" />
</div>
</div>
</div>
);
}
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("/", "-");
}