优化结果预览渲染体验

This commit is contained in:
czj
2026-07-02 13:28:41 +08:00
parent e0b0e9eb8a
commit 31d53d3165
3 changed files with 284 additions and 12 deletions
+50 -1
View File
@@ -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);
}
}
@@ -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("其他内容");
});
});
+191 -11
View File
@@ -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<string, string> = {
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({
<article className={`preview ${isStreaming ? "streaming-preview" : ""}`}>
<h2>{visibleArticle.title}</h2>
<p>{visibleArticle.summary}</p>
<pre>{visibleArticle.body_markdown}</pre>
<ProgressiveMarkdownPreview
blocks={markdownBlocks}
isStreaming={isStreaming}
key={bodyMarkdown}
/>
</article>
) : (
<div className="preview stream-placeholder">
@@ -104,16 +132,18 @@ export function OptimizedPreview({
<p className="empty-panel"></p>
)}
</div>
<div>
<h3></h3>
<div className="tag-row">
{visibleArticle.changed_sections.map((section) => (
<span className="tag" key={section}>
{section}
</span>
))}
{changedLabels.length > 0 && (
<div>
<h3></h3>
<div className="tag-row">
{changedLabels.map((section) => (
<span className="tag" key={section}>
{section}
</span>
))}
</div>
</div>
</div>
)}
{visibleArticle.requires_user_confirmation.length > 0 && (
<div>
<h3></h3>
@@ -149,3 +179,153 @@ export function OptimizedPreview({
</section>
);
}
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 <p className="empty-panel"></p>;
}
return (
<div className="markdown-preview">
{blocks.map((block, index) => (
<MarkdownBlockView block={block} key={`${block.type}-${index}`} />
))}
</div>
);
}
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 (
<MarkdownPreview
blocks={getVisibleMarkdownBlocks(blocks, isStreaming, visibleCount)}
/>
);
}
function MarkdownBlockView({ block }: { block: MarkdownBlock }) {
if (block.type === "heading") {
return (
<div
aria-level={Math.min(block.level + 1, 6)}
className="markdown-heading markdown-block"
role="heading"
>
{renderInlineMarkdown(block.text)}
</div>
);
}
if (block.type === "list") {
return (
<ul className="markdown-list markdown-block">
{block.items.map((item) => (
<li key={item}>{renderInlineMarkdown(item)}</li>
))}
</ul>
);
}
return (
<p className="markdown-paragraph markdown-block">
{renderInlineMarkdown(block.text)}
</p>
);
}
function renderInlineMarkdown(text: string): ReactNode {
return text.split(/(\*\*[^*]+\*\*)/g).map((part, index) => {
if (part.startsWith("**") && part.endsWith("**")) {
return <strong key={index}>{part.slice(2, -2)}</strong>;
}
return <Fragment key={index}>{part}</Fragment>;
});
}