改造一键流式优化首页

This commit is contained in:
czj
2026-07-01 13:25:07 +08:00
parent 8a1ceb3b14
commit 016b653b76
5 changed files with 621 additions and 351 deletions
+193 -2
View File
@@ -121,9 +121,10 @@ h3 {
}
.workflow-grid {
align-items: start;
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: minmax(22rem, 0.86fr) minmax(0, 1.14fr);
}
.panel {
@@ -134,6 +135,19 @@ h3 {
padding: 1rem;
}
.compact-panel {
min-height: auto;
padding: 0.85rem;
}
.input-panel {
grid-row: span 2;
}
.fact-card-panel {
gap: 0.7rem;
}
.stack {
display: grid;
align-content: start;
@@ -158,8 +172,16 @@ h3 {
font-weight: 800;
}
.panel-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
}
.body-input {
min-height: 14rem;
min-height: 20rem;
}
.two-col {
@@ -256,6 +278,141 @@ h3 {
padding: 0.25rem 0.55rem;
}
.warn-tag {
background: #fff7e6;
border-color: #f0c978;
color: #7b560b;
}
.fact-summary-grid {
display: grid;
gap: 0.55rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.fact-summary-grid div {
background: #f4faf8;
border: 1px solid #d9ebe6;
border-radius: 6px;
display: grid;
gap: 0.22rem;
min-width: 0;
padding: 0.55rem;
}
.fact-summary-grid span {
color: #667085;
font-size: 0.72rem;
font-weight: 700;
}
.fact-summary-grid strong {
color: #172033;
font-size: 0.9rem;
overflow-wrap: anywhere;
}
.fact-compact-list,
.fact-edit-grid {
display: grid;
gap: 0.55rem;
}
.fact-edit-grid {
border-top: 1px solid #e5e9f0;
padding-top: 0.7rem;
}
.optimized-panel {
min-height: 24rem;
overflow: hidden;
position: relative;
}
.optimized-panel.is-streaming {
border-color: #91cdbf;
box-shadow: 0 0 0 3px rgba(38, 138, 116, 0.08);
}
.stream-status {
align-items: center;
color: #0d6b58;
display: inline-flex;
font-size: 0.78rem;
font-weight: 800;
gap: 0.45rem;
}
.stream-cursor {
animation: stream-spin 0.8s linear infinite;
border: 2px solid #b7ddd5;
border-radius: 999px;
border-top-color: #0d6b58;
display: inline-block;
height: 1rem;
width: 1rem;
}
.streaming-preview {
border-left-color: #20846d;
}
.streaming-preview pre {
animation: preview-breathe 1.8s ease-in-out infinite;
background: #f4fbf8;
border: 1px solid #d6eee8;
}
.stream-placeholder {
display: grid;
gap: 0.7rem;
}
.preview-pulse-line,
.preview-pulse-block {
animation: preview-breathe 1.6s ease-in-out infinite;
background: linear-gradient(90deg, #edf5f2, #dceee8, #edf5f2);
border-radius: 6px;
}
.preview-pulse-line {
height: 1rem;
width: 82%;
}
.preview-pulse-line.title-line {
height: 1.5rem;
width: 54%;
}
.preview-pulse-line.short-line {
width: 66%;
}
.preview-pulse-block {
height: 12rem;
width: 100%;
}
.stream-word-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
min-height: 1.8rem;
}
.stream-word {
animation: word-pop 0.46s ease both;
background: #e9f6f2;
border: 1px solid #b7ddd5;
border-radius: 999px;
color: #0d6b58;
font-size: 0.78rem;
font-weight: 800;
padding: 0.22rem 0.5rem;
}
.qa-list {
display: grid;
gap: 0.7rem;
@@ -348,12 +505,46 @@ h3 {
@media (max-width: 900px) {
.workflow-grid,
.two-col,
.fact-summary-grid,
.calibration-metrics {
grid-template-columns: 1fr;
}
.input-panel {
grid-row: auto;
}
.topbar {
align-items: stretch;
flex-direction: column;
}
}
@keyframes stream-spin {
to {
transform: rotate(360deg);
}
}
@keyframes preview-breathe {
0%,
100% {
opacity: 0.72;
}
50% {
opacity: 1;
}
}
@keyframes word-pop {
0% {
opacity: 0;
transform: translateY(0.4rem) scale(0.9);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
+124 -174
View File
@@ -6,20 +6,21 @@ import {
ArticleInputForm,
type ArticleInputPayload,
} from "../components/article-input-form";
import {
FactCardEditor,
toConfirmedFactCard,
} from "../components/fact-card-editor";
import { FactCardEditor } from "../components/fact-card-editor";
import { OptimizedPreview } from "../components/optimized-preview";
import { PerformanceCalibrationPanel } from "../components/performance-calibration-panel";
import { ProgressPanel } from "../components/progress-panel";
import { QaReportPanel } from "../components/qa-report-panel";
import type {
CandidateFactCard,
OptimizationFactCard,
OptimizedArticle,
QaReport,
} from "../lib/domain/types";
import type { ProgressAction } from "../lib/progress/progress";
import {
parseOptimizationStreamChunk,
type OptimizationStreamEvent,
} from "../lib/workflow/stream-events";
const initialInput: ArticleInputPayload = {
title: "",
@@ -31,7 +32,6 @@ const initialInput: ArticleInputPayload = {
interface ApiErrorResponse {
error?: string;
timing?: TimingSummary;
}
interface TimingStep {
@@ -44,42 +44,11 @@ interface TimingSummary {
steps: TimingStep[];
}
interface ProgressStep {
label: string;
status: "running" | "completed" | "failed";
duration_ms?: number;
}
interface WorkflowProgress {
current_step: string | null;
status: "idle" | "running" | "completed" | "failed";
steps: ProgressStep[];
}
interface ProgressResponse extends ApiErrorResponse {
progress: WorkflowProgress;
}
interface CreateJobResponse extends ApiErrorResponse {
job: { id: string };
candidateFactCard: CandidateFactCard;
timing?: TimingSummary;
}
interface ConfirmFactCardResponse extends ApiErrorResponse {
factCard: CandidateFactCard;
}
interface OptimizeJobResponse extends ApiErrorResponse {
optimizedArticle: OptimizedArticle;
qaReport: QaReport;
timing?: TimingSummary;
}
export default function Home() {
const [input, setInput] = useState(initialInput);
const [jobId, setJobId] = useState<string | null>(null);
const [factCard, setFactCard] = useState<CandidateFactCard | null>(null);
const [factCard, setFactCard] = useState<OptimizationFactCard | null>(null);
const [draftArticle, setDraftArticle] = useState<OptimizedArticle | null>(null);
const [optimizedArticle, setOptimizedArticle] =
useState<OptimizedArticle | null>(null);
const [qaReport, setQaReport] = useState<QaReport | null>(null);
@@ -88,10 +57,7 @@ export default function Home() {
const [apiAccessKey, setApiAccessKey] = useState("");
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [lastTiming, setLastTiming] = useState<TimingSummary | null>(null);
const [liveProgress, setLiveProgress] = useState<WorkflowProgress | null>(null);
const canOptimize =
Boolean(jobId) && Boolean(factCard) && factCard?.uncertain_items.length === 0;
const [streamActivity, setStreamActivity] = useState("");
useEffect(() => {
if (!busyAction) return;
@@ -102,127 +68,98 @@ export default function Home() {
return () => window.clearInterval(timer);
}, [busyAction]);
useEffect(() => {
if (busyAction !== "optimize" || !jobId) return;
let cancelled = false;
async function pollProgress() {
try {
const response = await fetch(`/api/jobs/${jobId}/progress`, {
headers: apiHeaders(apiAccessKey),
});
const body = (await response.json()) as ProgressResponse;
if (!cancelled && response.ok) {
setLiveProgress(body.progress);
}
} catch {
// The main optimization request still owns user-facing errors.
}
}
void pollProgress();
const timer = window.setInterval(pollProgress, 2000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [apiAccessKey, busyAction, jobId]);
function startBusyAction(action: ProgressAction) {
setElapsedSeconds(0);
setBusyAction(action);
}
async function analyze() {
startBusyAction("analyze");
setMessage("");
setLastTiming(null);
setLiveProgress(null);
setOptimizedArticle(null);
setQaReport(null);
try {
const response = await fetch("/api/jobs", {
method: "POST",
headers: apiHeaders(apiAccessKey),
body: JSON.stringify(input),
});
const body = (await response.json()) as CreateJobResponse;
if (!response.ok) throw new ApiResponseError(body.error ?? "分析失败", body.timing);
setJobId(body.job.id);
setFactCard(body.candidateFactCard);
setLastTiming(body.timing ?? null);
setMessage(
body.timing
? `候选事实卡已生成,用时 ${formatTiming(body.timing.total_ms)}`
: "候选事实卡已生成。",
);
} catch (error) {
if (error instanceof ApiResponseError) {
setLastTiming(error.timing ?? null);
}
setMessage(error instanceof Error ? error.message : "分析失败");
} finally {
setBusyAction(null);
}
}
async function confirmFactCard() {
if (!jobId || !factCard) return;
startBusyAction("confirm");
setMessage("");
setLastTiming(null);
try {
const response = await fetch(`/api/jobs/${jobId}/confirm-fact-card`, {
method: "POST",
headers: apiHeaders(apiAccessKey),
body: JSON.stringify(toConfirmedFactCard(factCard)),
});
const body = (await response.json()) as ConfirmFactCardResponse;
if (!response.ok) throw new Error(body.error ?? "确认失败");
setFactCard(body.factCard);
setMessage("事实卡已确认。");
} catch (error) {
setMessage(error instanceof Error ? error.message : "确认失败");
} finally {
setBusyAction(null);
}
}
async function optimize() {
if (!jobId) return;
startBusyAction("optimize");
setMessage("");
setLastTiming(null);
setLiveProgress(null);
setDraftArticle(null);
setOptimizedArticle(null);
setQaReport(null);
setStreamActivity("正在创建任务");
try {
const response = await fetch(`/api/jobs/${jobId}/optimize`, {
const payload: ArticleInputPayload & {
fact_card?: OptimizationFactCard;
} = {
...input,
title: "",
};
if (factCard) {
payload.fact_card = factCard;
}
const response = await fetch("/api/jobs/optimize-stream", {
method: "POST",
headers: apiHeaders(apiAccessKey),
body: JSON.stringify(payload),
});
const body = (await response.json()) as OptimizeJobResponse;
if (!response.ok) throw new ApiResponseError(body.error ?? "优化失败", body.timing);
setOptimizedArticle(body.optimizedArticle);
setQaReport(body.qaReport);
setLastTiming(body.timing ?? null);
setLiveProgress(null);
const timingText = body.timing
? `用时 ${formatTiming(body.timing.total_ms)}`
: "";
setMessage(
body.qaReport.overall_status === "fail"
? `质检发现需要复核的问题,已保留导出文件。${timingText}`
: `优化完成。${timingText}`,
);
} catch (error) {
if (error instanceof ApiResponseError) {
setLastTiming(error.timing ?? null);
if (!response.ok) {
const body = (await response.json()) as ApiErrorResponse;
throw new Error(body.error ?? "优化失败");
}
await readOptimizationStream(response, handleStreamEvent);
} catch (error) {
setMessage(error instanceof Error ? error.message : "优化失败");
} finally {
setBusyAction(null);
}
}
function handleStreamEvent(event: OptimizationStreamEvent) {
switch (event.type) {
case "job_created":
setJobId(event.job.id);
setStreamActivity("任务已创建");
break;
case "fact_card_ready":
setFactCard(event.fact_card);
setStreamActivity("事实卡已就绪");
break;
case "draft_started":
setStreamActivity(event.message);
break;
case "draft_ready":
setDraftArticle(event.article);
setStreamActivity("草稿已生成");
break;
case "qa_started":
setStreamActivity(event.message);
break;
case "qa_ready":
setQaReport(event.qa_report);
setStreamActivity("质量检查完成");
break;
case "rewrite_started":
setStreamActivity(`正在修复第 ${event.round}`);
break;
case "rewrite_ready":
setDraftArticle(event.article);
setStreamActivity(`${event.round} 轮修复完成`);
break;
case "final_ready":
setOptimizedArticle(event.optimized_article);
setDraftArticle(null);
setQaReport(event.qa_report);
setStreamActivity("终稿已生成");
setMessage(
event.qa_report.overall_status === "fail"
? "优化完成,质检发现需要复核的问题。"
: "优化完成。",
);
break;
case "failed":
setStreamActivity("优化失败");
throw new Error(event.error);
}
}
return (
<main className="app-shell">
<header className="topbar">
@@ -239,35 +176,30 @@ export default function Home() {
value={apiAccessKey}
/>
</label>
<button
disabled={!canOptimize || busyAction === "optimize"}
onClick={optimize}
type="button"
>
{busyAction === "optimize" ? "优化中..." : "开始优化"}
</button>
</header>
<ProgressPanel
action={busyAction}
elapsedSeconds={elapsedSeconds}
lastTiming={lastTiming}
liveProgress={liveProgress}
liveProgress={null}
/>
<div className="workflow-grid">
<ArticleInputForm
isSubmitting={busyAction === "analyze"}
isSubmitting={busyAction === "optimize"}
value={input}
onChange={setInput}
onSubmit={analyze}
onSubmit={optimize}
/>
<FactCardEditor
factCard={factCard}
isSaving={busyAction === "confirm"}
isSaving={busyAction === "optimize"}
onChange={setFactCard}
onConfirm={confirmFactCard}
/>
<OptimizedPreview
activityText={streamActivity}
article={optimizedArticle}
draftArticle={draftArticle}
isStreaming={busyAction === "optimize"}
jobId={jobId}
/>
<QaReportPanel report={qaReport} />
@@ -282,6 +214,42 @@ export default function Home() {
);
}
async function readOptimizationStream(
response: Response,
onEvent: (event: OptimizationStreamEvent) => void,
) {
if (!response.body) {
throw new Error("当前浏览器不支持流式响应");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let remainder = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const parsed = parseOptimizationStreamChunk(
remainder,
decoder.decode(value, { stream: true }),
);
remainder = parsed.remainder;
parsed.events.forEach(onEvent);
}
const finalText = decoder.decode();
if (finalText) {
const parsed = parseOptimizationStreamChunk(remainder, finalText);
remainder = parsed.remainder;
parsed.events.forEach(onEvent);
}
if (remainder.trim().length > 0) {
onEvent(JSON.parse(remainder) as OptimizationStreamEvent);
}
}
function apiHeaders(apiAccessKey: string) {
const headers: Record<string, string> = { "content-type": "application/json" };
if (apiAccessKey) {
@@ -289,21 +257,3 @@ function apiHeaders(apiAccessKey: string) {
}
return headers;
}
function formatTiming(milliseconds: number) {
const seconds = Math.round(milliseconds / 1000);
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}${remainingSeconds}`;
}
class ApiResponseError extends Error {
constructor(
message: string,
public readonly timing?: TimingSummary,
) {
super(message);
this.name = "ApiResponseError";
}
}
+4 -12
View File
@@ -45,23 +45,15 @@ export function ArticleInputForm({
}
return (
<form className="panel stack" onSubmit={handleSubmit}>
<form className="panel stack input-panel" onSubmit={handleSubmit}>
<div className="panel-heading">
<span></span>
<button disabled={isSubmitting} type="submit">
{isSubmitting ? "分析中..." : "分析文章"}
<button disabled={isSubmitting || value.body.trim().length === 0} type="submit">
{isSubmitting ? "优化中..." : "开始优化"}
</button>
</div>
<label>
<span></span>
<input
required
value={value.title}
onChange={(event) => update("title", event.target.value)}
/>
</label>
<label>
<span></span>
<span></span>
<textarea
required
className="body-input"
+180 -113
View File
@@ -1,12 +1,13 @@
"use client";
import type { CandidateFactCard, ConfirmedFactCard } from "../lib/domain/types";
import { useState } from "react";
import type { CandidateFactCard, OptimizationFactCard } from "../lib/domain/types";
interface FactCardEditorProps {
factCard: CandidateFactCard | null;
factCard: OptimizationFactCard | null;
isSaving: boolean;
onChange: (factCard: CandidateFactCard) => void;
onConfirm: () => void;
onChange: (factCard: OptimizationFactCard) => void;
}
const listFields = [
@@ -47,30 +48,35 @@ export function FactCardEditor({
factCard,
isSaving,
onChange,
onConfirm,
}: FactCardEditorProps) {
const [isEditing, setIsEditing] = useState(false);
if (!factCard) {
return (
<section className="panel empty-panel">
<div className="panel-heading"></div>
<section className="panel compact-panel fact-card-panel empty-panel">
<div className="panel-heading">
<span></span>
<span className="status-pill warn"></span>
</div>
</section>
);
}
const currentFactCard = factCard;
const canOptimize = currentFactCard.uncertain_items.length === 0;
const hasUncertainItems = factCard.uncertain_items.length > 0;
function update<K extends keyof CandidateFactCard>(
function update<K extends keyof OptimizationFactCard>(
key: K,
value: CandidateFactCard[K],
value: OptimizationFactCard[K],
) {
if (!factCard) return;
onChange({
...currentFactCard,
...factCard,
[key]: value,
confirmed_by_user: false,
is_ready_for_optimization:
key === "uncertain_items"
? (value as string[]).length === 0
: currentFactCard.uncertain_items.length === 0,
: factCard.uncertain_items.length === 0,
});
}
@@ -78,122 +84,183 @@ export function FactCardEditor({
itemIndex: number,
target: UncertainItemResolutionTarget,
) {
onChange(resolveUncertainItem(currentFactCard, itemIndex, target));
if (!factCard) return;
onChange(resolveUncertainItem(factCard, itemIndex, target));
}
return (
<section className="panel stack">
<section className="panel compact-panel fact-card-panel stack">
<div className="panel-heading">
<span></span>
<button disabled={!canOptimize || isSaving} onClick={onConfirm} type="button">
{isSaving ? "保存中..." : "确认事实卡"}
</button>
<div className="panel-actions">
<span className={`status-pill ${hasUncertainItems ? "warn" : "pass"}`}>
{hasUncertainItems ? "待复核" : "可用"}
</span>
<button
className="secondary-button"
disabled={isSaving}
onClick={() => setIsEditing((editing) => !editing)}
type="button"
>
{isEditing ? "收起编辑" : "编辑事实卡"}
</button>
</div>
</div>
<label>
<span></span>
<input
value={factCard.company_full_name}
onChange={(event) => update("company_full_name", event.target.value)}
/>
</label>
<div className="two-col">
<label>
<span></span>
<input
value={factCard.target_industry}
onChange={(event) => update("target_industry", event.target.value)}
/>
</label>
<label>
<span></span>
<input
value={factCard.target_audience}
onChange={(event) => update("target_audience", event.target.value)}
/>
</label>
<div className="fact-summary-grid">
<div>
<span></span>
<strong>{factCard.company_full_name || "待补充"}</strong>
</div>
<div>
<span></span>
<strong>{factCard.target_industry || "待补充"}</strong>
</div>
<div>
<span></span>
<strong>{factCard.target_audience || "待补充"}</strong>
</div>
<div>
<span></span>
<strong>
{factCard.experience_years === null
? "待补充"
: `${factCard.experience_years}`}
</strong>
</div>
</div>
<label>
<span></span>
<input
min="0"
type="number"
value={factCard.experience_years ?? ""}
onChange={(event) =>
update(
"experience_years",
event.target.value ? Number(event.target.value) : null,
)
}
/>
</label>
{listFields.map((field) => (
<label key={field}>
<span>{fieldLabels[field]}</span>
<textarea
value={factCard[field].join("\n")}
onChange={(event) =>
update(
field,
event.target.value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean) as CandidateFactCard[typeof field],
)
}
/>
</label>
))}
<div className="field-group">
<div className="field-label"></div>
{factCard.uncertain_items.length > 0 ? (
<div className="uncertain-list">
{factCard.uncertain_items.map((item, index) => (
<div className="uncertain-row" key={`${item}-${index}`}>
<p>{item}</p>
<div className="uncertain-actions">
{uncertainItemActions.map((action) => (
<button
className="secondary-button"
key={action.target}
onClick={() => resolveItem(index, action.target)}
type="button"
>
{action.label}
</button>
))}
</div>
</div>
{factCard.core_claims.length > 0 && (
<div className="fact-compact-list">
<span className="field-label"></span>
<div className="tag-row">
{factCard.core_claims.slice(0, 4).map((claim) => (
<span className="tag" key={claim}>
{claim}
</span>
))}
</div>
) : (
<p className="status-text pass"></p>
)}
</div>
{!canOptimize && (
<p className="status-text fail">
</p>
</div>
)}
{hasUncertainItems && (
<div className="fact-compact-list">
<span className="field-label"></span>
<div className="tag-row">
{factCard.uncertain_items.slice(0, 4).map((item) => (
<span className="tag warn-tag" key={item}>
{item}
</span>
))}
</div>
</div>
)}
{isEditing && (
<div className="fact-edit-grid">
<label>
<span></span>
<input
disabled={isSaving}
value={factCard.company_full_name}
onChange={(event) =>
update("company_full_name", event.target.value)
}
/>
</label>
<div className="two-col">
<label>
<span></span>
<input
disabled={isSaving}
value={factCard.target_industry}
onChange={(event) =>
update("target_industry", event.target.value)
}
/>
</label>
<label>
<span></span>
<input
disabled={isSaving}
value={factCard.target_audience}
onChange={(event) =>
update("target_audience", event.target.value)
}
/>
</label>
</div>
<label>
<span></span>
<input
disabled={isSaving}
min="0"
type="number"
value={factCard.experience_years ?? ""}
onChange={(event) =>
update(
"experience_years",
event.target.value ? Number(event.target.value) : null,
)
}
/>
</label>
{listFields.map((field) => (
<label key={field}>
<span>{fieldLabels[field]}</span>
<textarea
disabled={isSaving}
value={factCard[field].join("\n")}
onChange={(event) =>
update(
field,
event.target.value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean) as OptimizationFactCard[typeof field],
)
}
/>
</label>
))}
<div className="field-group">
<div className="field-label"></div>
{factCard.uncertain_items.length > 0 ? (
<div className="uncertain-list">
{factCard.uncertain_items.map((item, index) => (
<div className="uncertain-row" key={`${item}-${index}`}>
<p>{item}</p>
<div className="uncertain-actions">
{uncertainItemActions.map((action) => (
<button
className="secondary-button"
disabled={isSaving}
key={action.target}
onClick={() => resolveItem(index, action.target)}
type="button"
>
{action.label}
</button>
))}
</div>
</div>
))}
</div>
) : (
<p className="status-text pass"></p>
)}
</div>
</div>
)}
</section>
);
}
export function toConfirmedFactCard(
factCard: CandidateFactCard,
): ConfirmedFactCard {
return {
...factCard,
uncertain_items: [],
is_ready_for_optimization: true,
confirmed_by_user: true,
};
}
export function resolveUncertainItem(
factCard: CandidateFactCard,
export function resolveUncertainItem<T extends CandidateFactCard>(
factCard: T,
itemIndex: number,
target: UncertainItemResolutionTarget,
): CandidateFactCard {
): T {
const item = factCard.uncertain_items[itemIndex]?.trim();
if (!item) return factCard;
+120 -50
View File
@@ -1,81 +1,151 @@
"use client";
import { useEffect, useState } from "react";
import type { OptimizedArticle } from "../lib/domain/types";
interface OptimizedPreviewProps {
article: OptimizedArticle | null;
draftArticle?: OptimizedArticle | null;
jobId: string | null;
isStreaming?: boolean;
activityText?: string;
}
const exportFiles = ["optimized.md", "optimized.docx", "qa_report.json"];
const streamWords = ["事实", "标题", "结构", "语气", "检索", "改写", "终稿"];
export function OptimizedPreview({
article,
draftArticle = null,
jobId,
isStreaming = false,
activityText = "",
}: OptimizedPreviewProps) {
if (!article) {
const [streamTick, setStreamTick] = useState(0);
const visibleArticle = article ?? draftArticle;
const visibleWordCount = isStreaming ? Math.min(streamTick, 6) : 0;
const visibleWords = Array.from({ length: visibleWordCount }, (_, offset) => {
const index = streamTick - visibleWordCount + offset;
return streamWords[index % streamWords.length];
});
useEffect(() => {
if (!isStreaming) return;
const timer = window.setInterval(() => {
setStreamTick((tick) => tick + 1);
}, 850);
return () => window.clearInterval(timer);
}, [isStreaming]);
if (!visibleArticle && !isStreaming) {
return (
<section className="panel empty-panel">
<section className="panel empty-panel optimized-panel">
<div className="panel-heading"></div>
</section>
);
}
return (
<section className="panel stack">
<div className="panel-heading"></div>
<article className="preview">
<h2>{article.title}</h2>
<p>{article.summary}</p>
<pre>{article.body_markdown}</pre>
</article>
<div>
<h3></h3>
<ul>
{article.image_suggestions.map((item) => (
<li key={item.source}>
<strong>{item.source}</strong>: {item.suggestion}
</li>
))}
</ul>
<section
aria-live="polite"
className={`panel stack optimized-panel ${isStreaming ? "is-streaming" : ""}`}
>
<div className="panel-heading">
<span></span>
{isStreaming && (
<span className="stream-status">
<span className="stream-cursor" aria-hidden="true" />
{activityText || "正在优化"}
</span>
)}
</div>
<div>
<h3></h3>
<div className="tag-row">
{article.changed_sections.map((section) => (
<span className="tag" key={section}>
{section}
{visibleArticle ? (
<article className={`preview ${isStreaming ? "streaming-preview" : ""}`}>
<h2>{visibleArticle.title}</h2>
<p>{visibleArticle.summary}</p>
<pre>{visibleArticle.body_markdown}</pre>
</article>
) : (
<div className="preview stream-placeholder">
<div className="preview-pulse-line title-line" />
<div className="preview-pulse-line" />
<div className="preview-pulse-line short-line" />
<div className="preview-pulse-block" />
</div>
)}
{isStreaming && visibleWords.length > 0 && (
<div className="stream-word-row" aria-hidden="true">
{visibleWords.map((word, index) => (
<span className="stream-word" key={`${word}-${streamTick}-${index}`}>
{word}
</span>
))}
</div>
</div>
{article.requires_user_confirmation.length > 0 && (
<div>
<h3></h3>
<ul>
{article.requires_user_confirmation.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
{visibleArticle && (
<>
<div>
<h3></h3>
{visibleArticle.image_suggestions.length > 0 ? (
<ul>
{visibleArticle.image_suggestions.map((item) => (
<li key={item.source}>
<strong>{item.source}</strong>: {item.suggestion}
</li>
))}
</ul>
) : (
<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>
))}
</div>
</div>
{visibleArticle.requires_user_confirmation.length > 0 && (
<div>
<h3></h3>
<ul>
{visibleArticle.requires_user_confirmation.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
)}
</>
)}
{article && (
<div className="export-row">
{exportFiles.map((fileName) =>
jobId ? (
<a
className="download-link"
href={`/api/jobs/${jobId}/exports/${fileName}`}
key={fileName}
>
{fileName}
</a>
) : (
<button disabled key={fileName} type="button">
{fileName}
</button>
),
)}
</div>
)}
<div className="export-row">
{exportFiles.map((fileName) =>
jobId ? (
<a
className="download-link"
href={`/api/jobs/${jobId}/exports/${fileName}`}
key={fileName}
>
{fileName}
</a>
) : (
<button disabled key={fileName} type="button">
{fileName}
</button>
),
)}
</div>
</section>
);
}