feat:[FL-144][改造系统消息页面]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -32,3 +32,21 @@
|
||||
|
||||
- 风险与关注点:
|
||||
- 当前删除为管理端物理删除系统消息记录;广播消息删除后对所有用户不可见。
|
||||
|
||||
## Work Log - 系统消息页面布局与 Markdown 支持(2026-06-16)
|
||||
|
||||
- 背景:
|
||||
- FL-144 要求参考用户管理页面改造系统消息页面:发送消息改为按钮点击后弹出表单,并支持 Markdown 编辑与展示。
|
||||
|
||||
- 本次处理:
|
||||
- 将系统消息页面收敛为“消息列表”卡片布局,顶部动作区提供刷新、全部已读、发送消息入口,筛选项改为与用户管理页面一致的行内表单风格。
|
||||
- 将发送消息表单迁移到 Ant Design Modal,内容输入支持 Markdown,并提供实时预览。
|
||||
- 列表内容与详情弹窗使用安全的本地 Markdown 渲染,支持标题、列表、引用、代码块、强调、行内代码和安全链接。
|
||||
|
||||
- 验证:
|
||||
- 基线:`npx eslint src/app/admin/system-messages/page.tsx src/app/admin/users/page.tsx` 通过,存在 `users/page.tsx` 既有 3 条 warning。
|
||||
- 修改后:`npx eslint src/app/admin/system-messages/page.tsx src/app/admin/users/page.tsx` 通过,仍仅存在同样 3 条既有 warning。
|
||||
- 修改后:`npx tsc --noEmit` 通过。
|
||||
|
||||
- 风险与关注点:
|
||||
- 改动仅涉及前端系统消息页面,不改变 API 字段、数据库结构或消息存储格式;Markdown 作为普通文本存储并在前端渲染。
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
@@ -18,7 +19,8 @@ import {
|
||||
type CardProps,
|
||||
type TableColumnsType,
|
||||
} from "antd";
|
||||
import type { ComponentType } from "react";
|
||||
import Link from "next/link";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
@@ -30,7 +32,7 @@ type CreateMessageValues = {
|
||||
title: string;
|
||||
content: string;
|
||||
message_type: SystemMessageType;
|
||||
target_user_id: string;
|
||||
target_user_id?: string;
|
||||
};
|
||||
|
||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
||||
@@ -56,6 +58,222 @@ const MESSAGE_TYPE_COLORS: Record<SystemMessageType, string> = {
|
||||
error: "red",
|
||||
};
|
||||
|
||||
const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
|
||||
|
||||
function isSafeMarkdownLink(href: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(href, "http://local.invalid");
|
||||
return SAFE_LINK_PROTOCOLS.has(parsed.protocol) || href.startsWith("/");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text: string, keyPrefix: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
const inlinePattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = inlinePattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const token = match[0];
|
||||
const key = `${keyPrefix}-${match.index}`;
|
||||
if (token.startsWith("`")) {
|
||||
nodes.push(<Typography.Text code key={key}>{token.slice(1, -1)}</Typography.Text>);
|
||||
} else if (token.startsWith("**")) {
|
||||
nodes.push(<Typography.Text strong key={key}>{token.slice(2, -2)}</Typography.Text>);
|
||||
} else if (token.startsWith("*")) {
|
||||
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
|
||||
} else {
|
||||
const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token);
|
||||
if (linkMatch && isSafeMarkdownLink(linkMatch[2].trim())) {
|
||||
nodes.push(
|
||||
<Typography.Link
|
||||
href={linkMatch[2].trim()}
|
||||
key={key}
|
||||
rel="noreferrer"
|
||||
target={linkMatch[2].trim().startsWith("/") ? undefined : "_blank"}
|
||||
>
|
||||
{linkMatch[1]}
|
||||
</Typography.Link>,
|
||||
);
|
||||
} else if (linkMatch) {
|
||||
nodes.push(linkMatch[1]);
|
||||
} else {
|
||||
nodes.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
lastIndex = match.index + token.length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return nodes.length > 0 ? nodes : [text];
|
||||
}
|
||||
|
||||
function isUnorderedListLine(line: string): boolean {
|
||||
return /^\s*[-*+]\s+/.test(line);
|
||||
}
|
||||
|
||||
function isOrderedListLine(line: string): boolean {
|
||||
return /^\s*\d+\.\s+/.test(line);
|
||||
}
|
||||
|
||||
function isMarkdownBlockStart(line: string): boolean {
|
||||
return (
|
||||
line.startsWith("```")
|
||||
|| /^#{1,6}\s+/.test(line)
|
||||
|| /^\s*>\s?/.test(line)
|
||||
|| isUnorderedListLine(line)
|
||||
|| isOrderedListLine(line)
|
||||
);
|
||||
}
|
||||
|
||||
function renderMarkdownBlocks(content: string, compact: boolean): ReactNode[] {
|
||||
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
||||
const blocks: ReactNode[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("```")) {
|
||||
const codeLines: string[] = [];
|
||||
index += 1;
|
||||
while (index < lines.length && !lines[index].startsWith("```")) {
|
||||
codeLines.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) {
|
||||
index += 1;
|
||||
}
|
||||
blocks.push(
|
||||
<pre
|
||||
className="my-2 overflow-auto rounded-md border border-[var(--ant-color-border)] bg-[var(--ant-color-fill-quaternary)] p-3 text-xs leading-5"
|
||||
key={`code-${index}`}
|
||||
>
|
||||
<code>{codeLines.join("\n")}</code>
|
||||
</pre>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = /^(#{1,6})\s+(.+)$/.exec(line);
|
||||
if (headingMatch) {
|
||||
const level = Math.min(5, headingMatch[1].length + 3) as 1 | 2 | 3 | 4 | 5;
|
||||
blocks.push(
|
||||
<Typography.Title className="!mb-2 !mt-3" key={`heading-${index}`} level={level}>
|
||||
{renderInlineMarkdown(headingMatch[2], `heading-${index}`)}
|
||||
</Typography.Title>,
|
||||
);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*>\s?/.test(line)) {
|
||||
const quoteLines: string[] = [];
|
||||
while (index < lines.length && /^\s*>\s?/.test(lines[index])) {
|
||||
quoteLines.push(lines[index].replace(/^\s*>\s?/, ""));
|
||||
index += 1;
|
||||
}
|
||||
blocks.push(
|
||||
<blockquote
|
||||
className="my-2 border-l-4 border-[var(--ant-color-border)] pl-3 text-[var(--ant-color-text-secondary)]"
|
||||
key={`quote-${index}`}
|
||||
>
|
||||
{quoteLines.map((quoteLine, quoteIndex) => (
|
||||
<Typography.Paragraph className="!mb-1" key={`quote-${index}-${quoteIndex}`}>
|
||||
{renderInlineMarkdown(quoteLine, `quote-${index}-${quoteIndex}`)}
|
||||
</Typography.Paragraph>
|
||||
))}
|
||||
</blockquote>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isUnorderedListLine(line) || isOrderedListLine(line)) {
|
||||
const ordered = isOrderedListLine(line);
|
||||
const listItems: string[] = [];
|
||||
while (
|
||||
index < lines.length
|
||||
&& (ordered ? isOrderedListLine(lines[index]) : isUnorderedListLine(lines[index]))
|
||||
) {
|
||||
listItems.push(lines[index].replace(ordered ? /^\s*\d+\.\s+/ : /^\s*[-*+]\s+/, ""));
|
||||
index += 1;
|
||||
}
|
||||
const ListTag = ordered ? "ol" : "ul";
|
||||
blocks.push(
|
||||
<ListTag
|
||||
className={ordered ? "my-2 list-decimal pl-5" : "my-2 list-disc pl-5"}
|
||||
key={`list-${index}`}
|
||||
>
|
||||
{listItems.map((item, itemIndex) => (
|
||||
<li className="mb-1" key={`list-${index}-${itemIndex}`}>
|
||||
{renderInlineMarkdown(item, `list-${index}-${itemIndex}`)}
|
||||
</li>
|
||||
))}
|
||||
</ListTag>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraphLines: string[] = [];
|
||||
while (
|
||||
index < lines.length
|
||||
&& lines[index].trim()
|
||||
&& !isMarkdownBlockStart(lines[index])
|
||||
) {
|
||||
paragraphLines.push(lines[index].trim());
|
||||
index += 1;
|
||||
}
|
||||
blocks.push(
|
||||
<Typography.Paragraph className={compact ? "!mb-1" : "!mb-2"} key={`paragraph-${index}`}>
|
||||
{renderInlineMarkdown(paragraphLines.join(" "), `paragraph-${index}`)}
|
||||
</Typography.Paragraph>,
|
||||
);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function MarkdownPreview({
|
||||
content,
|
||||
compact = false,
|
||||
placeholder = "暂无内容",
|
||||
}: {
|
||||
content: string;
|
||||
compact?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const trimmedContent = content.trim();
|
||||
if (!trimmedContent) {
|
||||
return <Typography.Text type="secondary">{placeholder}</Typography.Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? "max-h-24 min-w-[260px] overflow-hidden text-sm leading-6"
|
||||
: "min-h-[180px] overflow-auto rounded-md border border-[var(--ant-color-border)] bg-[var(--ant-color-fill-quaternary)] p-3 text-sm leading-6"
|
||||
}
|
||||
>
|
||||
{renderMarkdownBlocks(trimmedContent, compact)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -71,9 +289,12 @@ export default function AdminSystemMessagesPage() {
|
||||
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [formApi] = Form.useForm<CreateMessageValues>();
|
||||
const contentPreview = Form.useWatch("content", formApi) ?? "";
|
||||
const [messageTypeFilter, setMessageTypeFilter] = useState<SystemMessageType | "all">("all");
|
||||
const [unreadOnly, setUnreadOnly] = useState(false);
|
||||
const [deletingMessageId, setDeletingMessageId] = useState<string | null>(null);
|
||||
const [createMessageModalOpen, setCreateMessageModalOpen] = useState(false);
|
||||
const [detailMessage, setDetailMessage] = useState<SystemMessageSummary | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
@@ -106,12 +327,11 @@ export default function AdminSystemMessagesPage() {
|
||||
}, [queryClient]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async (values: CreateMessageValues) => {
|
||||
if (!canManage) {
|
||||
throw new Error("缺少 admin.system_message 权限");
|
||||
}
|
||||
|
||||
const values = await formApi.validateFields();
|
||||
const response = await fetchWithAuth("/api/v1/system-messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -119,7 +339,7 @@ export default function AdminSystemMessagesPage() {
|
||||
title: values.title.trim(),
|
||||
content: values.content.trim(),
|
||||
message_type: values.message_type,
|
||||
target_user_id: values.target_user_id.trim() || null,
|
||||
target_user_id: values.target_user_id?.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -131,6 +351,7 @@ export default function AdminSystemMessagesPage() {
|
||||
setError("");
|
||||
setSuccess("系统消息已发送");
|
||||
formApi.resetFields();
|
||||
setCreateMessageModalOpen(false);
|
||||
await refreshMessages();
|
||||
},
|
||||
onError: (candidate) => {
|
||||
@@ -215,206 +436,144 @@ export default function AdminSystemMessagesPage() {
|
||||
[messages],
|
||||
);
|
||||
|
||||
const columns = useMemo<TableColumnsType<SystemMessageSummary>>(
|
||||
() => [
|
||||
{
|
||||
title: "标题",
|
||||
dataIndex: "title",
|
||||
key: "title",
|
||||
width: 220,
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text strong={!item.is_read}>{item.title}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.target_user_id ? `用户:${item.target_user_id}` : "全员广播"}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "message_type",
|
||||
key: "message_type",
|
||||
width: 90,
|
||||
render: (value: SystemMessageType) => (
|
||||
<Tag color={MESSAGE_TYPE_COLORS[value]}>{MESSAGE_TYPE_LABELS[value]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "内容",
|
||||
dataIndex: "content",
|
||||
key: "content",
|
||||
ellipsis: true,
|
||||
render: (value: string) => <Typography.Text>{value}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "is_read",
|
||||
key: "is_read",
|
||||
width: 90,
|
||||
render: (value: boolean) => (
|
||||
<Tag color={value ? "default" : "processing"}>{value ? "已读" : "未读"}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 180,
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 120,
|
||||
fixed: "right",
|
||||
render: (_, item) => {
|
||||
const isDeleting = deletingMessageId === item.id;
|
||||
const openCreateMessageModal = () => {
|
||||
setError("");
|
||||
setSuccess("");
|
||||
formApi.resetFields();
|
||||
setCreateMessageModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Space size="small">
|
||||
<Button
|
||||
disabled={item.is_read || isDeleting}
|
||||
loading={markReadMutation.isPending && markReadMutation.variables?.includes(item.id)}
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => markReadMutation.mutate([item.id])}
|
||||
const closeCreateMessageModal = () => {
|
||||
if (createMutation.isPending) return;
|
||||
setCreateMessageModalOpen(false);
|
||||
formApi.resetFields();
|
||||
};
|
||||
|
||||
const handleSubmitCreateMessage = (values: CreateMessageValues) => {
|
||||
setError("");
|
||||
setSuccess("");
|
||||
createMutation.mutate(values);
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<SystemMessageSummary> = [
|
||||
{
|
||||
title: "标题",
|
||||
dataIndex: "title",
|
||||
key: "title",
|
||||
width: 220,
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text strong={!item.is_read}>{item.title}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.target_user_id ? `用户:${item.target_user_id}` : "全员广播"}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "message_type",
|
||||
key: "message_type",
|
||||
width: 90,
|
||||
render: (value: SystemMessageType) => (
|
||||
<Tag color={MESSAGE_TYPE_COLORS[value]}>{MESSAGE_TYPE_LABELS[value]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "内容",
|
||||
dataIndex: "content",
|
||||
key: "content",
|
||||
render: (value: string) => <MarkdownPreview compact content={value} />,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "is_read",
|
||||
key: "is_read",
|
||||
width: 90,
|
||||
render: (value: boolean) => (
|
||||
<Tag color={value ? "default" : "processing"}>{value ? "已读" : "未读"}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 180,
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 190,
|
||||
fixed: "right",
|
||||
render: (_, item) => {
|
||||
const isDeleting = deletingMessageId === item.id;
|
||||
|
||||
return (
|
||||
<Space size="small">
|
||||
<Button size="small" type="link" onClick={() => setDetailMessage(item)}>
|
||||
查看
|
||||
</Button>
|
||||
<Button
|
||||
disabled={item.is_read || isDeleting}
|
||||
loading={markReadMutation.isPending && markReadMutation.variables?.includes(item.id)}
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => markReadMutation.mutate([item.id])}
|
||||
>
|
||||
标记已读
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Popconfirm
|
||||
title="删除系统消息"
|
||||
description={`确认删除系统消息「${item.title}」吗?`}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true, loading: isDeleting }}
|
||||
onConfirm={() => deleteMutation.mutate(item.id)}
|
||||
>
|
||||
标记已读
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Popconfirm
|
||||
title="删除系统消息"
|
||||
description={`确认删除系统消息「${item.title}」吗?`}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true, loading: isDeleting }}
|
||||
onConfirm={() => deleteMutation.mutate(item.id)}
|
||||
>
|
||||
<Button danger loading={isDeleting} size="small" type="link">
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
<Button danger disabled={isDeleting} loading={isDeleting} size="small" type="link">
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
],
|
||||
[canManage, deleteMutation, deletingMessageId, markReadMutation],
|
||||
);
|
||||
},
|
||||
];
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex min-h-[360px] items-center justify-center">
|
||||
<Spin />
|
||||
<div className="flex min-h-[240px] items-center justify-center">
|
||||
<Spin tip="初始化中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
|
||||
<p className="text-sm text-[var(--gray-11)]">请先登录后再访问系统消息页面。</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex w-fit items-center justify-center rounded-md border border-[var(--gray-6)] bg-[var(--gray-a2)] px-4 py-2 text-sm font-medium text-[var(--gray-12)] transition hover:bg-[var(--gray-a3)]"
|
||||
>
|
||||
返回首页
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Space align="start" direction="vertical" size={4}>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
系统消息
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
管理当前账号可见的系统消息,并发送全员或指定用户通知。
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
{listQuery.isError && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
message="系统消息加载失败"
|
||||
description={listQuery.error instanceof Error ? listQuery.error.message : "请检查后端服务。"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<AntCard title="发送消息">
|
||||
<Form
|
||||
form={formApi}
|
||||
layout="vertical"
|
||||
initialValues={{ message_type: "info", target_user_id: "" }}
|
||||
onFinish={() => createMutation.mutate()}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_220px_260px]">
|
||||
<Form.Item
|
||||
label="标题"
|
||||
name="title"
|
||||
rules={[{ required: true, message: "请输入标题" }]}
|
||||
>
|
||||
<Input maxLength={255} placeholder="请输入消息标题" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="类型" name="message_type">
|
||||
<Select options={MESSAGE_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="目标用户 ID" name="target_user_id">
|
||||
<Input allowClear placeholder="留空表示全员广播" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="内容"
|
||||
name="content"
|
||||
rules={[{ required: true, message: "请输入内容" }]}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 3, maxRows: 8 }} placeholder="请输入消息内容" />
|
||||
</Form.Item>
|
||||
|
||||
<Space>
|
||||
<Button htmlType="submit" loading={createMutation.isPending} type="primary">
|
||||
发送消息
|
||||
</Button>
|
||||
<Button htmlType="button" onClick={() => formApi.resetFields()}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</AntCard>
|
||||
)}
|
||||
|
||||
{!canManage && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="当前账号仅可查看和标记自己的系统消息。"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<AntCard
|
||||
title={(
|
||||
<Space wrap>
|
||||
<span>消息列表</span>
|
||||
title="消息列表"
|
||||
extra={(
|
||||
<Space>
|
||||
{listQuery.isFetching && <Spin size="small" />}
|
||||
<Tag color="blue">总数 {listQuery.data?.total ?? 0}</Tag>
|
||||
<Tag color="processing">未读 {listQuery.data?.unread_count ?? 0}</Tag>
|
||||
</Space>
|
||||
)}
|
||||
extra={(
|
||||
<Space wrap>
|
||||
<Select
|
||||
style={{ width: 132 }}
|
||||
value={messageTypeFilter}
|
||||
options={[
|
||||
{ label: "全部类型", value: "all" },
|
||||
...MESSAGE_TYPE_OPTIONS,
|
||||
]}
|
||||
onChange={setMessageTypeFilter}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 112 }}
|
||||
value={unreadOnly ? "unread" : "all"}
|
||||
options={[
|
||||
{ label: "全部状态", value: "all" },
|
||||
{ label: "仅未读", value: "unread" },
|
||||
]}
|
||||
onChange={(value) => setUnreadOnly(value === "unread")}
|
||||
/>
|
||||
<Button onClick={() => void listQuery.refetch()}>刷新</Button>
|
||||
<Button
|
||||
disabled={unreadIds.length === 0}
|
||||
loading={markReadMutation.isPending && unreadIds.some((id) => markReadMutation.variables?.includes(id))}
|
||||
@@ -422,19 +581,165 @@ export default function AdminSystemMessagesPage() {
|
||||
>
|
||||
全部已读
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button type="primary" onClick={openCreateMessageModal}>
|
||||
发送消息
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
{listQuery.isError && (
|
||||
<Alert
|
||||
showIcon
|
||||
className="mb-4"
|
||||
type="error"
|
||||
message="系统消息加载失败"
|
||||
description={listQuery.error instanceof Error ? listQuery.error.message : "请检查后端服务。"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!canManage && (
|
||||
<Alert
|
||||
showIcon
|
||||
className="mb-4"
|
||||
type="info"
|
||||
message="当前账号仅可查看和标记自己的系统消息。"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form layout="inline" style={{ rowGap: 12 }}>
|
||||
<Form.Item label="类型" className="min-w-[170px]">
|
||||
<Select<SystemMessageType | "all">
|
||||
value={messageTypeFilter}
|
||||
options={[
|
||||
{ label: "全部类型", value: "all" },
|
||||
...MESSAGE_TYPE_OPTIONS,
|
||||
]}
|
||||
onChange={setMessageTypeFilter}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="状态" className="min-w-[170px]">
|
||||
<Select<"all" | "unread">
|
||||
value={unreadOnly ? "unread" : "all"}
|
||||
options={[
|
||||
{ label: "全部状态", value: "all" },
|
||||
{ label: "仅未读", value: "unread" },
|
||||
]}
|
||||
onChange={(value) => setUnreadOnly(value === "unread")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button onClick={() => void listQuery.refetch()}>刷新</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table<SystemMessageSummary>
|
||||
rowKey="id"
|
||||
className="mt-4"
|
||||
columns={columns}
|
||||
dataSource={messages}
|
||||
loading={listQuery.isFetching}
|
||||
locale={{ emptyText: <Empty description="暂无系统消息" /> }}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true }}
|
||||
scroll={{ x: 980 }}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
</AntCard>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title="发送消息"
|
||||
open={createMessageModalOpen}
|
||||
destroyOnClose
|
||||
width={960}
|
||||
onCancel={closeCreateMessageModal}
|
||||
onOk={() => formApi.submit()}
|
||||
okText="发送消息"
|
||||
cancelText="取消"
|
||||
confirmLoading={createMutation.isPending}
|
||||
>
|
||||
<Form<CreateMessageValues>
|
||||
form={formApi}
|
||||
layout="vertical"
|
||||
initialValues={{ message_type: "info", target_user_id: "" }}
|
||||
onFinish={handleSubmitCreateMessage}
|
||||
autoComplete="off"
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Form.Item
|
||||
label="标题"
|
||||
name="title"
|
||||
rules={[
|
||||
{ required: true, message: "请输入标题" },
|
||||
{ max: 255, message: "标题不能超过 255 字符" },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={255} placeholder="请输入消息标题" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="类型"
|
||||
name="message_type"
|
||||
rules={[{ required: true, message: "请选择类型" }]}
|
||||
>
|
||||
<Select options={MESSAGE_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="目标用户 ID" name="target_user_id" className="md:col-span-2">
|
||||
<Input allowClear placeholder="留空表示全员广播" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item label="内容(Markdown)" required style={{ marginBottom: 0 }}>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Form.Item
|
||||
name="content"
|
||||
rules={[{ required: true, message: "请输入内容" }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 10, maxRows: 18 }}
|
||||
placeholder={"支持 Markdown,例如:\n# 标题\n- 列表项\n**重点内容**\n[链接](https://example.com)"}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Typography.Text strong>预览</Typography.Text>
|
||||
<MarkdownPreview content={contentPreview} placeholder="输入 Markdown 后在这里预览" />
|
||||
</div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={detailMessage ? detailMessage.title : "消息详情"}
|
||||
open={!!detailMessage}
|
||||
width={760}
|
||||
footer={null}
|
||||
onCancel={() => setDetailMessage(null)}
|
||||
>
|
||||
{detailMessage && (
|
||||
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
||||
<Space wrap>
|
||||
<Tag color={MESSAGE_TYPE_COLORS[detailMessage.message_type]}>
|
||||
{MESSAGE_TYPE_LABELS[detailMessage.message_type]}
|
||||
</Tag>
|
||||
<Tag color={detailMessage.is_read ? "default" : "processing"}>
|
||||
{detailMessage.is_read ? "已读" : "未读"}
|
||||
</Tag>
|
||||
<Typography.Text type="secondary">
|
||||
{detailMessage.target_user_id ? `用户:${detailMessage.target_user_id}` : "全员广播"}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{formatDateTime(detailMessage.created_at)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<MarkdownPreview content={detailMessage.content} />
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user