From 9e39e16248b736465980633a8be956cd967f5c20 Mon Sep 17 00:00:00 2001 From: chengkai3 Date: Tue, 16 Jun 2026 17:50:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:[FL-144][=E6=94=B9=E9=80=A0=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=B6=88=E6=81=AF=E9=A1=B5=E9=9D=A2]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- memory/2026-06-16.md | 18 + web/src/app/admin/system-messages/page.tsx | 693 +++++++++++++++------ 2 files changed, 517 insertions(+), 194 deletions(-) diff --git a/memory/2026-06-16.md b/memory/2026-06-16.md index e4e5a2f..1146c23 100644 --- a/memory/2026-06-16.md +++ b/memory/2026-06-16.md @@ -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 作为普通文本存储并在前端渲染。 diff --git a/web/src/app/admin/system-messages/page.tsx b/web/src/app/admin/system-messages/page.tsx index ab5c358..21974b0 100644 --- a/web/src/app/admin/system-messages/page.tsx +++ b/web/src/app/admin/system-messages/page.tsx @@ -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; @@ -56,6 +58,222 @@ const MESSAGE_TYPE_COLORS: Record = { 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({token.slice(1, -1)}); + } else if (token.startsWith("**")) { + nodes.push({token.slice(2, -2)}); + } else if (token.startsWith("*")) { + nodes.push({token.slice(1, -1)}); + } else { + const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token); + if (linkMatch && isSafeMarkdownLink(linkMatch[2].trim())) { + nodes.push( + + {linkMatch[1]} + , + ); + } 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( +
+          {codeLines.join("\n")}
+        
, + ); + 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( + + {renderInlineMarkdown(headingMatch[2], `heading-${index}`)} + , + ); + 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( +
+ {quoteLines.map((quoteLine, quoteIndex) => ( + + {renderInlineMarkdown(quoteLine, `quote-${index}-${quoteIndex}`)} + + ))} +
, + ); + 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( + + {listItems.map((item, itemIndex) => ( +
  • + {renderInlineMarkdown(item, `list-${index}-${itemIndex}`)} +
  • + ))} +
    , + ); + continue; + } + + const paragraphLines: string[] = []; + while ( + index < lines.length + && lines[index].trim() + && !isMarkdownBlockStart(lines[index]) + ) { + paragraphLines.push(lines[index].trim()); + index += 1; + } + blocks.push( + + {renderInlineMarkdown(paragraphLines.join(" "), `paragraph-${index}`)} + , + ); + } + + return blocks; +} + +function MarkdownPreview({ + content, + compact = false, + placeholder = "暂无内容", +}: { + content: string; + compact?: boolean; + placeholder?: string; +}) { + const trimmedContent = content.trim(); + if (!trimmedContent) { + return {placeholder}; + } + + return ( +
    + {renderMarkdownBlocks(trimmedContent, compact)} +
    + ); +} + 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(); + const contentPreview = Form.useWatch("content", formApi) ?? ""; const [messageTypeFilter, setMessageTypeFilter] = useState("all"); const [unreadOnly, setUnreadOnly] = useState(false); const [deletingMessageId, setDeletingMessageId] = useState(null); + const [createMessageModalOpen, setCreateMessageModalOpen] = useState(false); + const [detailMessage, setDetailMessage] = useState(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>( - () => [ - { - title: "标题", - dataIndex: "title", - key: "title", - width: 220, - render: (_, item) => ( - - {item.title} - - {item.target_user_id ? `用户:${item.target_user_id}` : "全员广播"} - - - ), - }, - { - title: "类型", - dataIndex: "message_type", - key: "message_type", - width: 90, - render: (value: SystemMessageType) => ( - {MESSAGE_TYPE_LABELS[value]} - ), - }, - { - title: "内容", - dataIndex: "content", - key: "content", - ellipsis: true, - render: (value: string) => {value}, - }, - { - title: "状态", - dataIndex: "is_read", - key: "is_read", - width: 90, - render: (value: boolean) => ( - {value ? "已读" : "未读"} - ), - }, - { - 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 ( - - + + {canManage && ( + deleteMutation.mutate(item.id)} > - 标记已读 - - {canManage && ( - deleteMutation.mutate(item.id)} - > - - - )} - - ); - }, + + + )} + + ); }, - ], - [canManage, deleteMutation, deletingMessageId, markReadMutation], - ); + }, + ]; if (initializing) { return ( -
    - +
    +
    ); } + if (!user) { + return ( +
    +

    请先登录后再访问系统消息页面。

    + + 返回首页 + +
    + ); + } + return ( - - - - 系统消息 - - - 管理当前账号可见的系统消息,并发送全员或指定用户通知。 - - - - {listQuery.isError && ( - - )} - - {canManage && ( - -
    createMutation.mutate()} - > -
    - - - - - - - -
    - - - - - - - - - -
    -
    - )} - - {!canManage && ( - - )} - +
    - 消息列表 + title="消息列表" + extra={( + + {listQuery.isFetching && } 总数 {listQuery.data?.total ?? 0} 未读 {listQuery.data?.unread_count ?? 0} - - )} - extra={( - - setUnreadOnly(value === "unread")} - /> - + {canManage && ( + + )} )} > + {listQuery.isError && ( + + )} + + {!canManage && ( + + )} + +
    + + + value={messageTypeFilter} + options={[ + { label: "全部类型", value: "all" }, + ...MESSAGE_TYPE_OPTIONS, + ]} + onChange={setMessageTypeFilter} + /> + + + + + value={unreadOnly ? "unread" : "all"} + options={[ + { label: "全部状态", value: "all" }, + { label: "仅未读", value: "unread" }, + ]} + onChange={(value) => setUnreadOnly(value === "unread")} + /> + + + + + +
    + rowKey="id" + className="mt-4" columns={columns} dataSource={messages} loading={listQuery.isFetching} locale={{ emptyText: }} - pagination={{ pageSize: 20, showSizeChanger: true }} - scroll={{ x: 980 }} + pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} + scroll={{ x: 1100 }} />
    - + + formApi.submit()} + okText="发送消息" + cancelText="取消" + confirmLoading={createMutation.isPending} + > + + form={formApi} + layout="vertical" + initialValues={{ message_type: "info", target_user_id: "" }} + onFinish={handleSubmitCreateMessage} + autoComplete="off" + > +
    + + + + + + + +
    + + +
    + + + + +
    + 预览 + +
    +
    +
    + +
    + + setDetailMessage(null)} + > + {detailMessage && ( + + + + {MESSAGE_TYPE_LABELS[detailMessage.message_type]} + + + {detailMessage.is_read ? "已读" : "未读"} + + + {detailMessage.target_user_id ? `用户:${detailMessage.target_user_id}` : "全员广播"} + + + {formatDateTime(detailMessage.created_at)} + + + + + )} + +
    ); }