feat:[FL-144][改造系统消息页面]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-16 17:50:54 +08:00
parent 3899a2345e
commit 9e39e16248
2 changed files with 517 additions and 194 deletions
+18
View File
@@ -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 作为普通文本存储并在前端渲染。
+499 -194
View File
@@ -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>
);
}