feat(admin): 系统公告 - 支持配图草稿与自动回收
Adapted from ddcat-ai/open-ai-canvas#339 Original commit: fcdbe2aa833f31f683f709b7f409a0534ed78e20 Refs #339 Co-authored-by: 宇熙 <101980758+yuxiwl@users.noreply.github.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { Modal } from "antd";
|
||||
import { Image as AntImage, Modal } from "antd";
|
||||
import { Bell, BellOff, CircleAlert, Info, Pin, ShieldAlert, Wrench } from "lucide-react";
|
||||
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { AnnouncementContent } from "@/components/ui/announcement-content";
|
||||
import type { AnnouncementLevel, SystemAnnouncement } from "@/services/api/announcements";
|
||||
import { announcementImageUrl, type AnnouncementLevel, type SystemAnnouncement } from "@/services/api/announcements";
|
||||
|
||||
type AnnouncementTimelineModalProps = {
|
||||
open: boolean;
|
||||
@@ -110,6 +110,7 @@ function AnnouncementTimelineItem({ announcement, last, reducedMotion }: { annou
|
||||
<span className="inline-flex items-center gap-1 text-[var(--fs-label)] font-medium text-foreground/45"><Icon className="size-3" />{meta.label}</span>
|
||||
{announcement.pinned ? <span className="inline-flex items-center gap-1 text-[var(--fs-label)] font-medium text-amber-500"><Pin className="size-3" />置顶</span> : null}
|
||||
</div>
|
||||
{announcement.imageUrl ? <AntImage src={announcementImageUrl(announcement)} alt={`${announcement.title} 配图`} preview className="mt-3 max-h-36 w-56 max-w-full rounded-lg border border-border/70 bg-muted/20 object-contain p-1" /> : null}
|
||||
<AnnouncementContent content={announcement.content} className="mt-1 text-sm leading-6 text-foreground/75 sm:text-[var(--fs-body-lg)]" />
|
||||
<time dateTime={announcement.publishedAt} className="mt-2 block text-xs tabular-nums text-foreground/40">{relativeTime(announcement.publishedAt)} · {formatDateTime(announcement.publishedAt)}</time>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import { App, Button, Form, Input, Modal, Select, Switch, Tag } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { Pin, Plus, Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { 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 { 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;
|
||||
};
|
||||
@@ -52,6 +57,10 @@ export default function AdminAnnouncementsPanel() {
|
||||
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 imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -72,23 +81,89 @@ export default function AdminAnnouncementsPanel() {
|
||||
|
||||
const openPublishModal = () => {
|
||||
setEditingAnnouncement(null);
|
||||
form.setFieldsValue({ title: "", content: "", level: "info", pinned: false });
|
||||
form.setFieldsValue({ title: "", content: "", imageResourceId: "", level: "info", pinned: false });
|
||||
setImagePreviewUrl("");
|
||||
setDraftImageResourceId("");
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (announcement: SystemAnnouncement) => {
|
||||
setEditingAnnouncement(announcement);
|
||||
form.setFieldsValue({ title: announcement.title, content: announcement.content, level: announcement.level, pinned: announcement.pinned });
|
||||
form.setFieldsValue({ title: announcement.title, content: announcement.content, imageResourceId: announcement.imageResourceId || "", level: announcement.level, pinned: announcement.pinned });
|
||||
setImagePreviewUrl(announcementImageUrl(announcement));
|
||||
setDraftImageResourceId("");
|
||||
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(), level: values.level, pinned: Boolean(values.pinned) };
|
||||
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);
|
||||
@@ -120,9 +195,12 @@ export default function AdminAnnouncementsPanel() {
|
||||
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 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>
|
||||
),
|
||||
},
|
||||
@@ -181,7 +259,7 @@ export default function AdminAnnouncementsPanel() {
|
||||
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>
|
||||
<Modal title={editingAnnouncement ? "编辑并重新发布公告" : "发布系统公告"} open={modalOpen} width={760} centered okText={editingAnnouncement ? "保存并重新发布" : "立即发布"} cancelText="取消" confirmLoading={publishing} okButtonProps={{ disabled: imageUploading }} cancelButtonProps={{ disabled: imageUploading }} onOk={() => void publish()} onCancel={() => void closeEditor()} 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="例如:视频模型已恢复正常使用" />
|
||||
@@ -192,6 +270,21 @@ export default function AdminAnnouncementsPanel() {
|
||||
<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="公告正文" rules={[{ required: true, whitespace: true, message: "请填写公告正文" }, { max: 4000, message: "正文不能超过 4000 个字符" }]}>
|
||||
<Input.TextArea maxLength={4000} showCount autoSize={{ minRows: 6, maxRows: 12 }} placeholder="填写服务状态、影响范围和用户需要采取的操作" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiClient, request } from "@/services/api/request";
|
||||
import { apiBaseURL, apiClient, request } from "@/services/api/request";
|
||||
import type { RemoteResource } from "@/services/api/resources";
|
||||
|
||||
export type AnnouncementLevel = "info" | "success" | "warning" | "critical";
|
||||
export type AnnouncementStatus = "active" | "closed";
|
||||
@@ -7,6 +8,8 @@ export type SystemAnnouncement = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
imageResourceId?: string;
|
||||
imageUrl?: string;
|
||||
level: AnnouncementLevel;
|
||||
pinned: boolean;
|
||||
status: AnnouncementStatus;
|
||||
@@ -35,6 +38,13 @@ export function getAnnouncementFeed() {
|
||||
return request<AnnouncementFeed>(api.get("/announcements"));
|
||||
}
|
||||
|
||||
export function announcementImageUrl(announcement: Pick<SystemAnnouncement, "imageUrl">) {
|
||||
const imageUrl = announcement.imageUrl;
|
||||
if (!imageUrl || !imageUrl.startsWith("/api/")) return imageUrl || "";
|
||||
const base = String(apiBaseURL).replace(/\/+$/, "");
|
||||
return base === "/api" ? imageUrl : `${base}${imageUrl.slice("/api".length)}`;
|
||||
}
|
||||
|
||||
export function markAnnouncementsRead(announcementIds: string[]) {
|
||||
return request<{ unreadCount: number }>(api.post("/announcements/read", { announcementIds }));
|
||||
}
|
||||
@@ -43,11 +53,21 @@ export function listAdminAnnouncements(params: AdminAnnouncementListParams = {})
|
||||
return request<{ announcements: SystemAnnouncement[]; total: number; page: number; limit: number }>(api.get("/admin/announcements", { params }));
|
||||
}
|
||||
|
||||
export function createAdminAnnouncement(input: { title: string; content: string; level: AnnouncementLevel; pinned: boolean }) {
|
||||
export function uploadAdminAnnouncementImage(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file, file.name);
|
||||
return request<{ resource: RemoteResource }>(api.post("/admin/announcement-images", formData));
|
||||
}
|
||||
|
||||
export function discardAdminAnnouncementImage(id: string) {
|
||||
return request<{ ok: boolean }>(api.delete(`/admin/announcement-images/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
export function createAdminAnnouncement(input: { title: string; content: string; imageResourceId?: string; level: AnnouncementLevel; pinned: boolean }) {
|
||||
return request<{ announcement: SystemAnnouncement }>(api.post("/admin/announcements", input));
|
||||
}
|
||||
|
||||
export function updateAdminAnnouncement(id: string, input: { title: string; content: string; level: AnnouncementLevel; pinned: boolean }) {
|
||||
export function updateAdminAnnouncement(id: string, input: { title: string; content: string; imageResourceId?: string; level: AnnouncementLevel; pinned: boolean }) {
|
||||
return request<{ announcement: SystemAnnouncement }>(api.patch(`/admin/announcements/${encodeURIComponent(id)}`, input));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user