优化结果预览渲染体验
This commit is contained in:
+50
-1
@@ -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("其他内容");
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user