From b0db8cc3d887baf171d240d492f6f63a17aaf199 Mon Sep 17 00:00:00 2001 From: Administrator Date: Mon, 29 Jun 2026 17:08:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=20Markdown=20=E9=98=85=E8=AF=BB=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/wiki/ReportMarkdownView.tsx | 189 +++++++++++++++ frontend-web/src/components/wiki/wiki.css | 225 ++++++++++++++++++ .../src/pages/PublicScheduledTaskPage.tsx | 48 ++-- .../src/pages/ScheduledTaskRunDetailPage.tsx | 21 +- 4 files changed, 459 insertions(+), 24 deletions(-) create mode 100644 frontend-web/src/components/wiki/ReportMarkdownView.tsx diff --git a/frontend-web/src/components/wiki/ReportMarkdownView.tsx b/frontend-web/src/components/wiki/ReportMarkdownView.tsx new file mode 100644 index 00000000..af5e0d68 --- /dev/null +++ b/frontend-web/src/components/wiki/ReportMarkdownView.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { BookOpen, ListTree } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; +import { Badge } from "@/components/ui/badge"; +import { reasoningPlugins } from "@/core/streamdown"; +import { createMarkdownAnchorResolver } from "@/core/utils/markdown"; +import { cn } from "@/lib/utils"; + +import "./wiki.css"; + +function stripMarkdownToText(markdown: string): string { + return markdown + .replace(/```[\s\S]*?```/g, " ") + .replace(/~~~[\s\S]*?~~~/g, " ") + .replace(/!\[[^\]]*\]\([^)]+\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/^#{1,6}\s+/gm, "") + .replace(/^[>\-*+\d.)\s]+/gm, "") + .replace(/[`*_~|]/g, "") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function getMarkdownExcerpt(markdown: string): string { + const text = stripMarkdownToText(markdown); + if (!text) return ""; + return text.length > 150 ? `${text.slice(0, 150)}...` : text; +} + +function countReadableWords(markdown: string): number { + const text = stripMarkdownToText(markdown); + if (!text) return 0; + const cjk = text.match(/[\u4e00-\u9fff]/g)?.length ?? 0; + const words = text.replace(/[\u4e00-\u9fff]/g, " ").match(/[A-Za-z0-9]+/g)?.length ?? 0; + return cjk + words; +} + +function selectorForId(id: string): string { + const css = window.CSS; + if (css?.escape) return `#${css.escape(id)}`; + return `[id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`; +} + +export function ReportMarkdownView({ + content, + title, + subtitle, + badgeLabel = "报告阅读", + className, + compactHeader = false, +}: { + content: string; + title?: string; + subtitle?: string; + badgeLabel?: string; + className?: string; + compactHeader?: boolean; +}) { + const rootRef = useRef(null); + const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]); + const headings = useMemo( + () => anchorResolver.headings.filter((item) => item.level >= 1 && item.level <= 3), + [anchorResolver], + ); + const [activeHeadingId, setActiveHeadingId] = useState(null); + const excerpt = useMemo(() => getMarkdownExcerpt(content), [content]); + const sectionCount = headings.length; + const wordCount = useMemo(() => countReadableWords(content), [content]); + + useEffect(() => { + setActiveHeadingId(headings[0]?.id ?? null); + }, [headings]); + + useEffect(() => { + const root = rootRef.current; + if (!root || headings.length === 0) return; + + const targets = headings + .map((item) => root.querySelector(selectorForId(item.id))) + .filter((item): item is HTMLElement => !!item); + if (targets.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((entry) => entry.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; + if (visible?.target.id) setActiveHeadingId(visible.target.id); + }, + { + root: root.closest("[data-radix-scroll-area-viewport]") ?? null, + rootMargin: "-96px 0px -65% 0px", + threshold: [0, 1], + }, + ); + + targets.forEach((target) => observer.observe(target)); + return () => observer.disconnect(); + }, [headings, content]); + + const scrollToHeading = useCallback((id: string) => { + const root = rootRef.current; + const target = + root?.querySelector(selectorForId(id)) ?? document.getElementById(id); + setActiveHeadingId(id); + target?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, []); + + return ( +
+
+
+
+ + + {badgeLabel} + + {subtitle && ( + + {subtitle} + + )} +
+ {title &&

{title}

} + {!compactHeader && excerpt &&

{excerpt}

} +
+ {sectionCount || 1} 个章节 + 约 {wordCount} 字 +
+
+ +
+ +
+
+ + +
+ ); +} diff --git a/frontend-web/src/components/wiki/wiki.css b/frontend-web/src/components/wiki/wiki.css index fe3b3965..5c510c4e 100644 --- a/frontend-web/src/components/wiki/wiki.css +++ b/frontend-web/src/components/wiki/wiki.css @@ -220,6 +220,231 @@ border-color: #334155; } +.report-markdown-shell { + align-items: start; + padding: 2px; +} + +.report-markdown-paper { + border: 1px solid rgba(203, 213, 225, 0.85); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0) 220px), + #ffffff; + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.04), + 0 18px 44px rgba(15, 23, 42, 0.07); +} + +.report-markdown-header { + border-bottom: 1px solid #e8edf4; + padding: 26px 34px 22px; +} + +.report-markdown-header-compact { + padding-bottom: 18px; +} + +.report-markdown-badge { + gap: 6px; + border: 1px solid #dbe3ef; + background: #ffffff; + color: #475569; +} + +.report-markdown-title { + margin: 14px 0 0; + color: #0f172a; + font-size: 26px; + font-weight: 700; + line-height: 1.35; + letter-spacing: 0; +} + +.report-markdown-excerpt { + margin: 14px 0 0; + max-width: 840px; + color: #64748b; + font-size: 14px; + line-height: 1.8; +} + +.report-markdown-metrics { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; + color: #64748b; + font-size: 12px; +} + +.report-markdown-metrics span { + border: 1px solid #e2e8f0; + border-radius: 6px; + background: rgba(255, 255, 255, 0.78); + padding: 4px 8px; +} + +.report-markdown-body { + max-width: 920px; + margin: 0 auto; + padding: 32px 42px 44px; +} + +.report-markdown-body h1 { + font-size: 28px; +} + +.report-markdown-body h2 { + position: relative; + margin-top: 44px; + border-bottom-color: #e5edf7; + font-size: 22px; +} + +.report-markdown-body h2::before { + content: ""; + position: absolute; + left: -16px; + top: 0.34em; + width: 4px; + height: 1.05em; + border-radius: 999px; + background: #2563eb; +} + +.report-markdown-body h3 { + color: #1e293b; + font-size: 18px; +} + +.report-markdown-body p, +.report-markdown-body li { + font-size: 16px; + line-height: 1.92; +} + +.report-markdown-body blockquote { + border-left-color: #2563eb; + background: #f8fbff; +} + +.report-markdown-body table { + box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04); +} + +.report-markdown-toc { + position: sticky; + top: 16px; + max-height: calc(100vh - 32px); + overflow: hidden; + border: 1px solid rgba(203, 213, 225, 0.82); + border-radius: 8px; + background: rgba(255, 255, 255, 0.92); + padding: 14px; + box-shadow: 0 12px 34px rgba(15, 23, 42, 0.06); + backdrop-filter: blur(10px); +} + +.report-markdown-toc-list { + max-height: calc(100vh - 128px); + overflow: auto; + padding-right: 4px; +} + +.report-markdown-toc-item { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-left: 2px solid transparent; + border-radius: 6px; + padding: 7px 8px; + color: #64748b; + font-size: 12px; + line-height: 1.4; + text-align: left; + transition: background 160ms ease, border-color 160ms ease, color 160ms ease; +} + +.report-markdown-toc-item:hover { + background: #f1f5f9; + color: #0f172a; +} + +.report-markdown-toc-item-active { + border-left-color: #2563eb; + background: #eff6ff; + color: #1d4ed8; + font-weight: 600; +} + +.report-markdown-toc-level-2 { + padding-left: 18px; +} + +.report-markdown-toc-level-3 { + padding-left: 30px; + color: #94a3b8; +} + +.dark .report-markdown-paper { + border-color: #1f2937; + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.9), rgba(2, 6, 23, 0) 220px), + #020617; + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.24); +} + +.dark .report-markdown-header { + border-bottom-color: #1e293b; +} + +.dark .report-markdown-badge, +.dark .report-markdown-metrics span { + border-color: #334155; + background: #0f172a; + color: #cbd5e1; +} + +.dark .report-markdown-title { + color: #f8fafc; +} + +.dark .report-markdown-excerpt, +.dark .report-markdown-metrics { + color: #94a3b8; +} + +.dark .report-markdown-body h3 { + color: #e2e8f0; +} + +.dark .report-markdown-body h2::before { + background: #60a5fa; +} + +.dark .report-markdown-body blockquote { + background: #0f172a; + border-left-color: #60a5fa; +} + +.dark .report-markdown-toc { + border-color: #1f2937; + background: rgba(2, 6, 23, 0.88); +} + +.dark .report-markdown-toc-item:hover { + background: #0f172a; + color: #f8fafc; +} + +.dark .report-markdown-toc-item-active { + background: rgba(37, 99, 235, 0.16); + color: #93c5fd; +} + /* Anchor target offset so headings clear the floating "目录" pill. */ .wiki-markdown h1, .wiki-markdown h2, diff --git a/frontend-web/src/pages/PublicScheduledTaskPage.tsx b/frontend-web/src/pages/PublicScheduledTaskPage.tsx index 190493e1..fdd5de51 100644 --- a/frontend-web/src/pages/PublicScheduledTaskPage.tsx +++ b/frontend-web/src/pages/PublicScheduledTaskPage.tsx @@ -24,6 +24,11 @@ import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { Dialog, DialogContent, @@ -43,13 +48,13 @@ import { } from "@/components/ui/tooltip"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { HtmlPreviewFrame } from "@/components/html-preview-frame"; +import { ReportMarkdownView } from "@/components/wiki/ReportMarkdownView"; import { getBackendBaseURL } from "@/core/config"; import { usePublicBackground } from "@/core/embed/background"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { reasoningPlugins } from "@/core/streamdown"; import { createMarkdownAnchorResolver } from "@/core/utils/markdown"; import { cn } from "@/lib/utils"; -import "@/components/wiki/wiki.css"; type PublicTask = { task_id: string; @@ -875,13 +880,10 @@ function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreen } if (preview.mode === "markdown") { return ( - ); } @@ -889,13 +891,10 @@ function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreen const content = preview.file.content || "无文件内容"; if (isMarkdownFile(preview.file)) { return ( - ); } @@ -920,6 +919,7 @@ function publicFullscreenHref(taskId: string, runId: string, tab: ContentTab): s function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) { const queryClient = useQueryClient(); const [fullscreenPreview, setFullscreenPreview] = useState(null); + const [runMetaOpen, setRunMetaOpen] = useState(false); // HTML page run: render a sandboxed iframe preview instead of the MD tabs. const isHtmlRun = @@ -1159,7 +1159,29 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) { )} -

+ + + + + +

+
计划:{formatDate(run.scheduled_for)}
+
开始:{formatDate(run.started_at)}
+
完成:{formatDate(run.finished_at)}
+
+ + +

开始:{formatDate(run.started_at)} · 完成:{formatDate(run.finished_at)}

@@ -1311,7 +1333,7 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) { 无内容。点击「编辑」开始添加。 ) : ( - )} diff --git a/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx b/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx index 9002fbcb..125c2252 100644 --- a/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx +++ b/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx @@ -20,6 +20,7 @@ import { } from "@/components/workspace/workspace-container"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { HtmlPreviewFrame } from "@/components/html-preview-frame"; +import { ReportMarkdownView } from "@/components/wiki/ReportMarkdownView"; import { downloadScheduledTaskRun, downloadScheduledTaskRunFile, @@ -91,11 +92,12 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) { } if (isMarkdownFile(file)) { return ( - ); } @@ -114,11 +116,11 @@ function FullscreenPreviewContent({ preview }: { preview: FullscreenPreview }) { } if (preview.mode === "markdown") { return ( - ); } @@ -653,11 +655,12 @@ export default function ScheduledTaskRunDetailPage() { {result.format === "markdown" || result.format === "mixed" ? ( - ) : (