优化结果预览渲染体验
This commit is contained in:
+50
-1
@@ -264,6 +264,43 @@ h3 {
|
|||||||
white-space: pre-wrap;
|
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,
|
.tag-row,
|
||||||
.export-row {
|
.export-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -357,7 +394,7 @@ h3 {
|
|||||||
border-left-color: #20846d;
|
border-left-color: #20846d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.streaming-preview pre {
|
.streaming-preview .markdown-preview {
|
||||||
animation: preview-breathe 1.8s ease-in-out infinite;
|
animation: preview-breathe 1.8s ease-in-out infinite;
|
||||||
background: #f4fbf8;
|
background: #f4fbf8;
|
||||||
border: 1px solid #d6eee8;
|
border: 1px solid #d6eee8;
|
||||||
@@ -548,3 +585,15 @@ h3 {
|
|||||||
transform: translateY(0) scale(1);
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { OptimizedArticle } from "../lib/domain/types";
|
import type { OptimizedArticle } from "../lib/domain/types";
|
||||||
|
|
||||||
@@ -14,6 +14,22 @@ interface OptimizedPreviewProps {
|
|||||||
|
|
||||||
const exportFiles = ["optimized.md", "optimized.docx", "qa_report.json"];
|
const exportFiles = ["optimized.md", "optimized.docx", "qa_report.json"];
|
||||||
const streamWords = ["事实", "标题", "结构", "语气", "检索", "改写", "终稿"];
|
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({
|
export function OptimizedPreview({
|
||||||
article,
|
article,
|
||||||
@@ -24,6 +40,14 @@ export function OptimizedPreview({
|
|||||||
}: OptimizedPreviewProps) {
|
}: OptimizedPreviewProps) {
|
||||||
const [streamTick, setStreamTick] = useState(0);
|
const [streamTick, setStreamTick] = useState(0);
|
||||||
const visibleArticle = article ?? draftArticle;
|
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 visibleWordCount = isStreaming ? Math.min(streamTick, 6) : 0;
|
||||||
const visibleWords = Array.from({ length: visibleWordCount }, (_, offset) => {
|
const visibleWords = Array.from({ length: visibleWordCount }, (_, offset) => {
|
||||||
const index = streamTick - visibleWordCount + offset;
|
const index = streamTick - visibleWordCount + offset;
|
||||||
@@ -67,7 +91,11 @@ export function OptimizedPreview({
|
|||||||
<article className={`preview ${isStreaming ? "streaming-preview" : ""}`}>
|
<article className={`preview ${isStreaming ? "streaming-preview" : ""}`}>
|
||||||
<h2>{visibleArticle.title}</h2>
|
<h2>{visibleArticle.title}</h2>
|
||||||
<p>{visibleArticle.summary}</p>
|
<p>{visibleArticle.summary}</p>
|
||||||
<pre>{visibleArticle.body_markdown}</pre>
|
<ProgressiveMarkdownPreview
|
||||||
|
blocks={markdownBlocks}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
key={bodyMarkdown}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
) : (
|
) : (
|
||||||
<div className="preview stream-placeholder">
|
<div className="preview stream-placeholder">
|
||||||
@@ -104,16 +132,18 @@ export function OptimizedPreview({
|
|||||||
<p className="empty-panel">暂无图片建议。</p>
|
<p className="empty-panel">暂无图片建议。</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{changedLabels.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3>修改位置</h3>
|
<h3>优化调整</h3>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
{visibleArticle.changed_sections.map((section) => (
|
{changedLabels.map((section) => (
|
||||||
<span className="tag" key={section}>
|
<span className="tag" key={section}>
|
||||||
{section}
|
{section}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{visibleArticle.requires_user_confirmation.length > 0 && (
|
{visibleArticle.requires_user_confirmation.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3>需要人工确认</h3>
|
<h3>需要人工确认</h3>
|
||||||
@@ -149,3 +179,153 @@ export function OptimizedPreview({
|
|||||||
</section>
|
</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