From b60efaf44a61c09fa2bc94631a05498e7dd05a58 Mon Sep 17 00:00:00 2001 From: ddCat Date: Thu, 23 Jul 2026 09:03:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(platform):=20=E7=B3=BB=E7=BB=9F=E5=85=AC?= =?UTF-8?q?=E5=91=8A=20-=20=E6=94=AF=E6=8C=81=E5=8F=97=E6=8E=A7=20HTML=20?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E4=B8=8E=E6=A0=BC=E5=BC=8F=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../announcement-timeline-modal.tsx | 3 +- .../components/ui/announcement-content.tsx | 82 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 web/src/components/ui/announcement-content.tsx diff --git a/web/src/components/ui/aceternity/announcement-timeline-modal.tsx b/web/src/components/ui/aceternity/announcement-timeline-modal.tsx index 8b1e01559..71fcdb08a 100644 --- a/web/src/components/ui/aceternity/announcement-timeline-modal.tsx +++ b/web/src/components/ui/aceternity/announcement-timeline-modal.tsx @@ -3,6 +3,7 @@ import { Modal } from "antd"; import { Bell, BellOff, CircleAlert, Info, 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"; type AnnouncementTimelineModalProps = { @@ -108,7 +109,7 @@ function AnnouncementTimelineItem({ announcement, last, reducedMotion }: { annou

{announcement.title}

{meta.label} -

{announcement.content}

+ diff --git a/web/src/components/ui/announcement-content.tsx b/web/src/components/ui/announcement-content.tsx new file mode 100644 index 000000000..3eebd4ffc --- /dev/null +++ b/web/src/components/ui/announcement-content.tsx @@ -0,0 +1,82 @@ +import { Fragment, createElement, type ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +type AnnouncementContentProps = { + content: string; + className?: string; +}; + +const elementMap = { + a: "a", + b: "strong", + blockquote: "blockquote", + br: "br", + code: "code", + del: "del", + div: "div", + em: "em", + i: "em", + li: "li", + mark: "mark", + ol: "ol", + p: "p", + s: "s", + strong: "strong", + u: "u", + ul: "ul", +} as const; + +const ignoredTags = new Set(["iframe", "object", "script", "style", "svg", "template"]); + +/** + * 公告由管理员输入但会在所有用户页面展示,因此只将受控标签转换成 React 节点,避免直接注入 HTML。 + * 链接强制新窗口打开,并且只允许 http/https 协议。 + */ +export function AnnouncementContent({ content, className }: AnnouncementContentProps) { + return
{parseAnnouncementContent(content)}
; +} + +function parseAnnouncementContent(content: string): ReactNode { + if (typeof DOMParser === "undefined") return content; + + const document = new DOMParser().parseFromString(content, "text/html"); + return Array.from(document.body.childNodes).map((node, index) => renderNode(node, `announcement-${index}`)); +} + +function renderNode(node: ChildNode, key: string): ReactNode { + if (node.nodeType === Node.TEXT_NODE) { + return {node.textContent}; + } + if (node.nodeType !== Node.ELEMENT_NODE) return null; + + const element = node as HTMLElement; + const tag = element.tagName.toLowerCase(); + if (ignoredTags.has(tag)) return null; + + const children = Array.from(element.childNodes).map((child, index) => renderNode(child, `${key}-${index}`)); + if (!(tag in elementMap)) return {children}; + + if (tag === "a") { + const href = safeHref(element.getAttribute("href")); + if (!href) return {children}; + return ( + + {children} + + ); + } + + const component = elementMap[tag as keyof typeof elementMap]; + return createElement(component, { key }, children); +} + +function safeHref(value: string | null) { + if (!value) return null; + try { + const url = new URL(value, window.location.href); + return url.protocol === "http:" || url.protocol === "https:" ? url.href : null; + } catch { + return null; + } +}