diff --git a/src/app/globals.css b/src/app/globals.css index 5bd4ec7..b2f2d42 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -264,6 +264,43 @@ h3 { white-space: pre-wrap; } +.markdown-preview { + background: #f6f7f9; + border-radius: 6px; + display: grid; + gap: 0.75rem; + margin-top: 0.75rem; + padding: 0.85rem; +} + +.markdown-block { + animation: markdown-block-in 0.36s ease both; +} + +.markdown-heading { + color: #172033; + font-size: 1rem; + font-weight: 800; + line-height: 1.45; + margin-top: 0.2rem; +} + +.markdown-paragraph { + color: #172033; + line-height: 1.75; + margin: 0; + white-space: pre-wrap; +} + +.markdown-list { + color: #172033; + display: grid; + gap: 0.45rem; + line-height: 1.7; + margin: 0; + padding-left: 1.2rem; +} + .tag-row, .export-row { display: flex; @@ -357,7 +394,7 @@ h3 { border-left-color: #20846d; } -.streaming-preview pre { +.streaming-preview .markdown-preview { animation: preview-breathe 1.8s ease-in-out infinite; background: #f4fbf8; border: 1px solid #d6eee8; @@ -548,3 +585,15 @@ h3 { transform: translateY(0) scale(1); } } + +@keyframes markdown-block-in { + 0% { + opacity: 0; + transform: translateY(0.35rem); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/components/__tests__/optimized-preview.test.ts b/src/components/__tests__/optimized-preview.test.ts new file mode 100644 index 0000000..20ede45 --- /dev/null +++ b/src/components/__tests__/optimized-preview.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { + formatChangedSection, + getVisibleMarkdownBlocks, + parseMarkdownBlocks, +} from "../optimized-preview"; + +describe("OptimizedPreview markdown helpers", () => { + it("parses markdown headings, paragraphs, and bullets without exposing markers", () => { + const blocks = parseMarkdownBlocks( + "## 服务特点\n\n- **定制化方案**:拒绝模板化。\n- 数据驱动:提升运营效率。\n\n伟思德鲁提供全域增长服务。", + ); + + expect(blocks).toEqual([ + { type: "heading", text: "服务特点", level: 2 }, + { + type: "list", + items: ["**定制化方案**:拒绝模板化。", "数据驱动:提升运营效率。"], + }, + { type: "paragraph", text: "伟思德鲁提供全域增长服务。" }, + ]); + expect(blocks.map((block) => JSON.stringify(block)).join("\n")).not.toContain( + "##", + ); + }); + + it("reveals markdown blocks progressively while streaming", () => { + const blocks = parseMarkdownBlocks( + "## 第一段\n\n正文一。\n\n## 第二段\n\n正文二。", + ); + + expect(getVisibleMarkdownBlocks(blocks, true, 2)).toEqual(blocks.slice(0, 2)); + expect(getVisibleMarkdownBlocks(blocks, false, 1)).toEqual(blocks); + }); + + it("formats changed section keys as customer-readable Chinese labels", () => { + expect(formatChangedSection("title")).toBe("标题优化"); + expect(formatChangedSection("body_markdown")).toBe("正文结构与表达"); + expect(formatChangedSection("client_cases")).toBe("客户案例"); + expect(formatChangedSection("unknown_internal_key")).toBe("其他内容"); + }); +}); diff --git a/src/components/optimized-preview.tsx b/src/components/optimized-preview.tsx index e80ab9d..53ae1f0 100644 --- a/src/components/optimized-preview.tsx +++ b/src/components/optimized-preview.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react"; import type { OptimizedArticle } from "../lib/domain/types"; @@ -14,6 +14,22 @@ interface OptimizedPreviewProps { const exportFiles = ["optimized.md", "optimized.docx", "qa_report.json"]; const streamWords = ["事实", "标题", "结构", "语气", "检索", "改写", "终稿"]; +const changedSectionLabels: Record = { + title: "标题优化", + summary: "摘要优化", + body: "正文结构与表达", + body_markdown: "正文结构与表达", + client_cases: "客户案例", + social_contribution: "社会价值", + team_background_claims: "团队背景", + core_competitiveness_claims: "核心竞争力", + image_suggestions: "图片建议", +}; + +export type MarkdownBlock = + | { type: "heading"; text: string; level: number } + | { type: "paragraph"; text: string } + | { type: "list"; items: string[] }; export function OptimizedPreview({ article, @@ -24,6 +40,14 @@ export function OptimizedPreview({ }: OptimizedPreviewProps) { const [streamTick, setStreamTick] = useState(0); const visibleArticle = article ?? draftArticle; + const bodyMarkdown = visibleArticle?.body_markdown ?? ""; + const markdownBlocks = useMemo( + () => parseMarkdownBlocks(bodyMarkdown), + [bodyMarkdown], + ); + const changedLabels = visibleArticle + ? getChangedSectionLabels(visibleArticle.changed_sections) + : []; const visibleWordCount = isStreaming ? Math.min(streamTick, 6) : 0; const visibleWords = Array.from({ length: visibleWordCount }, (_, offset) => { const index = streamTick - visibleWordCount + offset; @@ -67,7 +91,11 @@ export function OptimizedPreview({

{visibleArticle.title}

{visibleArticle.summary}

-
{visibleArticle.body_markdown}
+
) : (
@@ -104,16 +132,18 @@ export function OptimizedPreview({

暂无图片建议。

)}
-
-

修改位置

-
- {visibleArticle.changed_sections.map((section) => ( - - {section} - - ))} + {changedLabels.length > 0 && ( +
+

优化调整

+
+ {changedLabels.map((section) => ( + + {section} + + ))} +
-
+ )} {visibleArticle.requires_user_confirmation.length > 0 && (

需要人工确认

@@ -149,3 +179,153 @@ export function OptimizedPreview({ ); } + +export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] { + const blocks: MarkdownBlock[] = []; + const paragraphLines: string[] = []; + const listItems: string[] = []; + + function flushParagraph() { + if (paragraphLines.length === 0) return; + blocks.push({ type: "paragraph", text: paragraphLines.join("\n").trim() }); + paragraphLines.length = 0; + } + + function flushList() { + if (listItems.length === 0) return; + blocks.push({ type: "list", items: [...listItems] }); + listItems.length = 0; + } + + for (const rawLine of markdown.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) { + flushParagraph(); + flushList(); + continue; + } + + const heading = line.match(/^(#{1,6})\s+(.+)$/); + if (heading) { + flushParagraph(); + flushList(); + blocks.push({ + type: "heading", + level: heading[1].length, + text: heading[2].trim(), + }); + continue; + } + + const listItem = line.match(/^[-*]\s+(.+)$/); + if (listItem) { + flushParagraph(); + listItems.push(listItem[1].trim()); + continue; + } + + flushList(); + paragraphLines.push(line); + } + + flushParagraph(); + flushList(); + return blocks; +} + +export function getVisibleMarkdownBlocks( + blocks: MarkdownBlock[], + isStreaming: boolean, + visibleCount: number, +) { + if (!isStreaming) return blocks; + return blocks.slice(0, Math.min(Math.max(visibleCount, 1), blocks.length)); +} + +export function formatChangedSection(section: string) { + const key = section.trim().toLowerCase().split(":")[0]?.trim() ?? ""; + return changedSectionLabels[key] ?? "其他内容"; +} + +function getChangedSectionLabels(sections: string[]) { + return Array.from(new Set(sections.map(formatChangedSection))); +} + +function MarkdownPreview({ blocks }: { blocks: MarkdownBlock[] }) { + if (blocks.length === 0) { + return

暂无正文内容。

; + } + + return ( +
+ {blocks.map((block, index) => ( + + ))} +
+ ); +} + +function ProgressiveMarkdownPreview({ + blocks, + isStreaming, +}: { + blocks: MarkdownBlock[]; + isStreaming: boolean; +}) { + const [visibleCount, setVisibleCount] = useState(1); + + useEffect(() => { + if (!isStreaming || blocks.length <= 1) return; + + const timer = window.setInterval(() => { + setVisibleCount((count) => Math.min(count + 1, blocks.length)); + }, 720); + + return () => window.clearInterval(timer); + }, [blocks.length, isStreaming]); + + return ( + + ); +} + +function MarkdownBlockView({ block }: { block: MarkdownBlock }) { + if (block.type === "heading") { + return ( +
+ {renderInlineMarkdown(block.text)} +
+ ); + } + + if (block.type === "list") { + return ( +
    + {block.items.map((item) => ( +
  • {renderInlineMarkdown(item)}
  • + ))} +
+ ); + } + + return ( +

+ {renderInlineMarkdown(block.text)} +

+ ); +} + +function renderInlineMarkdown(text: string): ReactNode { + return text.split(/(\*\*[^*]+\*\*)/g).map((part, index) => { + if (part.startsWith("**") && part.endsWith("**")) { + return {part.slice(2, -2)}; + } + return {part}; + }); +}