改造一键流式优化首页
This commit is contained in:
+193
-2
@@ -121,9 +121,10 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workflow-grid {
|
.workflow-grid {
|
||||||
|
align-items: start;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: minmax(22rem, 0.86fr) minmax(0, 1.14fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
@@ -134,6 +135,19 @@ h3 {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-panel {
|
||||||
|
min-height: auto;
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-panel {
|
||||||
|
grid-row: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact-card-panel {
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
.stack {
|
.stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -158,8 +172,16 @@ h3 {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.body-input {
|
.body-input {
|
||||||
min-height: 14rem;
|
min-height: 20rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.two-col {
|
.two-col {
|
||||||
@@ -256,6 +278,141 @@ h3 {
|
|||||||
padding: 0.25rem 0.55rem;
|
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 {
|
.qa-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
@@ -348,12 +505,46 @@ h3 {
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.workflow-grid,
|
.workflow-grid,
|
||||||
.two-col,
|
.two-col,
|
||||||
|
.fact-summary-grid,
|
||||||
.calibration-metrics {
|
.calibration-metrics {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-panel {
|
||||||
|
grid-row: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
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
@@ -6,20 +6,21 @@ import {
|
|||||||
ArticleInputForm,
|
ArticleInputForm,
|
||||||
type ArticleInputPayload,
|
type ArticleInputPayload,
|
||||||
} from "../components/article-input-form";
|
} from "../components/article-input-form";
|
||||||
import {
|
import { FactCardEditor } from "../components/fact-card-editor";
|
||||||
FactCardEditor,
|
|
||||||
toConfirmedFactCard,
|
|
||||||
} from "../components/fact-card-editor";
|
|
||||||
import { OptimizedPreview } from "../components/optimized-preview";
|
import { OptimizedPreview } from "../components/optimized-preview";
|
||||||
import { PerformanceCalibrationPanel } from "../components/performance-calibration-panel";
|
import { PerformanceCalibrationPanel } from "../components/performance-calibration-panel";
|
||||||
import { ProgressPanel } from "../components/progress-panel";
|
import { ProgressPanel } from "../components/progress-panel";
|
||||||
import { QaReportPanel } from "../components/qa-report-panel";
|
import { QaReportPanel } from "../components/qa-report-panel";
|
||||||
import type {
|
import type {
|
||||||
CandidateFactCard,
|
OptimizationFactCard,
|
||||||
OptimizedArticle,
|
OptimizedArticle,
|
||||||
QaReport,
|
QaReport,
|
||||||
} from "../lib/domain/types";
|
} from "../lib/domain/types";
|
||||||
import type { ProgressAction } from "../lib/progress/progress";
|
import type { ProgressAction } from "../lib/progress/progress";
|
||||||
|
import {
|
||||||
|
parseOptimizationStreamChunk,
|
||||||
|
type OptimizationStreamEvent,
|
||||||
|
} from "../lib/workflow/stream-events";
|
||||||
|
|
||||||
const initialInput: ArticleInputPayload = {
|
const initialInput: ArticleInputPayload = {
|
||||||
title: "",
|
title: "",
|
||||||
@@ -31,7 +32,6 @@ const initialInput: ArticleInputPayload = {
|
|||||||
|
|
||||||
interface ApiErrorResponse {
|
interface ApiErrorResponse {
|
||||||
error?: string;
|
error?: string;
|
||||||
timing?: TimingSummary;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimingStep {
|
interface TimingStep {
|
||||||
@@ -44,42 +44,11 @@ interface TimingSummary {
|
|||||||
steps: TimingStep[];
|
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() {
|
export default function Home() {
|
||||||
const [input, setInput] = useState(initialInput);
|
const [input, setInput] = useState(initialInput);
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
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] =
|
const [optimizedArticle, setOptimizedArticle] =
|
||||||
useState<OptimizedArticle | null>(null);
|
useState<OptimizedArticle | null>(null);
|
||||||
const [qaReport, setQaReport] = useState<QaReport | null>(null);
|
const [qaReport, setQaReport] = useState<QaReport | null>(null);
|
||||||
@@ -88,10 +57,7 @@ export default function Home() {
|
|||||||
const [apiAccessKey, setApiAccessKey] = useState("");
|
const [apiAccessKey, setApiAccessKey] = useState("");
|
||||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
const [lastTiming, setLastTiming] = useState<TimingSummary | null>(null);
|
const [lastTiming, setLastTiming] = useState<TimingSummary | null>(null);
|
||||||
const [liveProgress, setLiveProgress] = useState<WorkflowProgress | null>(null);
|
const [streamActivity, setStreamActivity] = useState("");
|
||||||
|
|
||||||
const canOptimize =
|
|
||||||
Boolean(jobId) && Boolean(factCard) && factCard?.uncertain_items.length === 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!busyAction) return;
|
if (!busyAction) return;
|
||||||
@@ -102,127 +68,98 @@ export default function Home() {
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [busyAction]);
|
}, [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) {
|
function startBusyAction(action: ProgressAction) {
|
||||||
setElapsedSeconds(0);
|
setElapsedSeconds(0);
|
||||||
setBusyAction(action);
|
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() {
|
async function optimize() {
|
||||||
if (!jobId) return;
|
|
||||||
startBusyAction("optimize");
|
startBusyAction("optimize");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
setLastTiming(null);
|
setLastTiming(null);
|
||||||
setLiveProgress(null);
|
setDraftArticle(null);
|
||||||
|
setOptimizedArticle(null);
|
||||||
|
setQaReport(null);
|
||||||
|
setStreamActivity("正在创建任务");
|
||||||
|
|
||||||
try {
|
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",
|
method: "POST",
|
||||||
headers: apiHeaders(apiAccessKey),
|
headers: apiHeaders(apiAccessKey),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
const body = (await response.json()) as OptimizeJobResponse;
|
|
||||||
if (!response.ok) throw new ApiResponseError(body.error ?? "优化失败", body.timing);
|
if (!response.ok) {
|
||||||
setOptimizedArticle(body.optimizedArticle);
|
const body = (await response.json()) as ApiErrorResponse;
|
||||||
setQaReport(body.qaReport);
|
throw new Error(body.error ?? "优化失败");
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await readOptimizationStream(response, handleStreamEvent);
|
||||||
|
} catch (error) {
|
||||||
setMessage(error instanceof Error ? error.message : "优化失败");
|
setMessage(error instanceof Error ? error.message : "优化失败");
|
||||||
} finally {
|
} finally {
|
||||||
setBusyAction(null);
|
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 (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-shell">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
@@ -239,35 +176,30 @@ export default function Home() {
|
|||||||
value={apiAccessKey}
|
value={apiAccessKey}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
|
||||||
disabled={!canOptimize || busyAction === "optimize"}
|
|
||||||
onClick={optimize}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{busyAction === "optimize" ? "优化中..." : "开始优化"}
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
<ProgressPanel
|
<ProgressPanel
|
||||||
action={busyAction}
|
action={busyAction}
|
||||||
elapsedSeconds={elapsedSeconds}
|
elapsedSeconds={elapsedSeconds}
|
||||||
lastTiming={lastTiming}
|
lastTiming={lastTiming}
|
||||||
liveProgress={liveProgress}
|
liveProgress={null}
|
||||||
/>
|
/>
|
||||||
<div className="workflow-grid">
|
<div className="workflow-grid">
|
||||||
<ArticleInputForm
|
<ArticleInputForm
|
||||||
isSubmitting={busyAction === "analyze"}
|
isSubmitting={busyAction === "optimize"}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={setInput}
|
onChange={setInput}
|
||||||
onSubmit={analyze}
|
onSubmit={optimize}
|
||||||
/>
|
/>
|
||||||
<FactCardEditor
|
<FactCardEditor
|
||||||
factCard={factCard}
|
factCard={factCard}
|
||||||
isSaving={busyAction === "confirm"}
|
isSaving={busyAction === "optimize"}
|
||||||
onChange={setFactCard}
|
onChange={setFactCard}
|
||||||
onConfirm={confirmFactCard}
|
|
||||||
/>
|
/>
|
||||||
<OptimizedPreview
|
<OptimizedPreview
|
||||||
|
activityText={streamActivity}
|
||||||
article={optimizedArticle}
|
article={optimizedArticle}
|
||||||
|
draftArticle={draftArticle}
|
||||||
|
isStreaming={busyAction === "optimize"}
|
||||||
jobId={jobId}
|
jobId={jobId}
|
||||||
/>
|
/>
|
||||||
<QaReportPanel report={qaReport} />
|
<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) {
|
function apiHeaders(apiAccessKey: string) {
|
||||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||||
if (apiAccessKey) {
|
if (apiAccessKey) {
|
||||||
@@ -289,21 +257,3 @@ function apiHeaders(apiAccessKey: string) {
|
|||||||
}
|
}
|
||||||
return headers;
|
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -45,23 +45,15 @@ export function ArticleInputForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="panel stack" onSubmit={handleSubmit}>
|
<form className="panel stack input-panel" onSubmit={handleSubmit}>
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<span>文章输入</span>
|
<span>文章输入</span>
|
||||||
<button disabled={isSubmitting} type="submit">
|
<button disabled={isSubmitting || value.body.trim().length === 0} type="submit">
|
||||||
{isSubmitting ? "分析中..." : "分析文章"}
|
{isSubmitting ? "优化中..." : "开始优化"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
<span>标题</span>
|
<span>文章内容</span>
|
||||||
<input
|
|
||||||
required
|
|
||||||
value={value.title}
|
|
||||||
onChange={(event) => update("title", event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>正文</span>
|
|
||||||
<textarea
|
<textarea
|
||||||
required
|
required
|
||||||
className="body-input"
|
className="body-input"
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CandidateFactCard, ConfirmedFactCard } from "../lib/domain/types";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { CandidateFactCard, OptimizationFactCard } from "../lib/domain/types";
|
||||||
|
|
||||||
interface FactCardEditorProps {
|
interface FactCardEditorProps {
|
||||||
factCard: CandidateFactCard | null;
|
factCard: OptimizationFactCard | null;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
onChange: (factCard: CandidateFactCard) => void;
|
onChange: (factCard: OptimizationFactCard) => void;
|
||||||
onConfirm: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const listFields = [
|
const listFields = [
|
||||||
@@ -47,30 +48,35 @@ export function FactCardEditor({
|
|||||||
factCard,
|
factCard,
|
||||||
isSaving,
|
isSaving,
|
||||||
onChange,
|
onChange,
|
||||||
onConfirm,
|
|
||||||
}: FactCardEditorProps) {
|
}: FactCardEditorProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
if (!factCard) {
|
if (!factCard) {
|
||||||
return (
|
return (
|
||||||
<section className="panel empty-panel">
|
<section className="panel compact-panel fact-card-panel empty-panel">
|
||||||
<div className="panel-heading">事实卡</div>
|
<div className="panel-heading">
|
||||||
|
<span>事实卡</span>
|
||||||
|
<span className="status-pill warn">待生成</span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentFactCard = factCard;
|
const hasUncertainItems = factCard.uncertain_items.length > 0;
|
||||||
const canOptimize = currentFactCard.uncertain_items.length === 0;
|
|
||||||
|
|
||||||
function update<K extends keyof CandidateFactCard>(
|
function update<K extends keyof OptimizationFactCard>(
|
||||||
key: K,
|
key: K,
|
||||||
value: CandidateFactCard[K],
|
value: OptimizationFactCard[K],
|
||||||
) {
|
) {
|
||||||
|
if (!factCard) return;
|
||||||
onChange({
|
onChange({
|
||||||
...currentFactCard,
|
...factCard,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
|
confirmed_by_user: false,
|
||||||
is_ready_for_optimization:
|
is_ready_for_optimization:
|
||||||
key === "uncertain_items"
|
key === "uncertain_items"
|
||||||
? (value as string[]).length === 0
|
? (value as string[]).length === 0
|
||||||
: currentFactCard.uncertain_items.length === 0,
|
: factCard.uncertain_items.length === 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,43 +84,116 @@ export function FactCardEditor({
|
|||||||
itemIndex: number,
|
itemIndex: number,
|
||||||
target: UncertainItemResolutionTarget,
|
target: UncertainItemResolutionTarget,
|
||||||
) {
|
) {
|
||||||
onChange(resolveUncertainItem(currentFactCard, itemIndex, target));
|
if (!factCard) return;
|
||||||
|
onChange(resolveUncertainItem(factCard, itemIndex, target));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel stack">
|
<section className="panel compact-panel fact-card-panel stack">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<span>事实卡</span>
|
<span>事实卡</span>
|
||||||
<button disabled={!canOptimize || isSaving} onClick={onConfirm} type="button">
|
<div className="panel-actions">
|
||||||
{isSaving ? "保存中..." : "确认事实卡"}
|
<span className={`status-pill ${hasUncertainItems ? "warn" : "pass"}`}>
|
||||||
|
{hasUncertainItems ? "待复核" : "可用"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => setIsEditing((editing) => !editing)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isEditing ? "收起编辑" : "编辑事实卡"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
</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>
|
<label>
|
||||||
<span>公司全称</span>
|
<span>公司全称</span>
|
||||||
<input
|
<input
|
||||||
|
disabled={isSaving}
|
||||||
value={factCard.company_full_name}
|
value={factCard.company_full_name}
|
||||||
onChange={(event) => update("company_full_name", event.target.value)}
|
onChange={(event) =>
|
||||||
|
update("company_full_name", event.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="two-col">
|
<div className="two-col">
|
||||||
<label>
|
<label>
|
||||||
<span>目标行业</span>
|
<span>目标行业</span>
|
||||||
<input
|
<input
|
||||||
|
disabled={isSaving}
|
||||||
value={factCard.target_industry}
|
value={factCard.target_industry}
|
||||||
onChange={(event) => update("target_industry", event.target.value)}
|
onChange={(event) =>
|
||||||
|
update("target_industry", event.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>目标受众</span>
|
<span>目标受众</span>
|
||||||
<input
|
<input
|
||||||
|
disabled={isSaving}
|
||||||
value={factCard.target_audience}
|
value={factCard.target_audience}
|
||||||
onChange={(event) => update("target_audience", event.target.value)}
|
onChange={(event) =>
|
||||||
|
update("target_audience", event.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
<span>经验年限</span>
|
<span>经验年限</span>
|
||||||
<input
|
<input
|
||||||
|
disabled={isSaving}
|
||||||
min="0"
|
min="0"
|
||||||
type="number"
|
type="number"
|
||||||
value={factCard.experience_years ?? ""}
|
value={factCard.experience_years ?? ""}
|
||||||
@@ -130,6 +209,7 @@ export function FactCardEditor({
|
|||||||
<label key={field}>
|
<label key={field}>
|
||||||
<span>{fieldLabels[field]}</span>
|
<span>{fieldLabels[field]}</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
disabled={isSaving}
|
||||||
value={factCard[field].join("\n")}
|
value={factCard[field].join("\n")}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
update(
|
update(
|
||||||
@@ -137,7 +217,7 @@ export function FactCardEditor({
|
|||||||
event.target.value
|
event.target.value
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean) as CandidateFactCard[typeof field],
|
.filter(Boolean) as OptimizationFactCard[typeof field],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -154,6 +234,7 @@ export function FactCardEditor({
|
|||||||
{uncertainItemActions.map((action) => (
|
{uncertainItemActions.map((action) => (
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
|
disabled={isSaving}
|
||||||
key={action.target}
|
key={action.target}
|
||||||
onClick={() => resolveItem(index, action.target)}
|
onClick={() => resolveItem(index, action.target)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -169,31 +250,17 @@ export function FactCardEditor({
|
|||||||
<p className="status-text pass">待确认事项已处理。</p>
|
<p className="status-text pass">待确认事项已处理。</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!canOptimize && (
|
</div>
|
||||||
<p className="status-text fail">
|
|
||||||
请先处理待确认事项,再开始优化。
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toConfirmedFactCard(
|
export function resolveUncertainItem<T extends CandidateFactCard>(
|
||||||
factCard: CandidateFactCard,
|
factCard: T,
|
||||||
): ConfirmedFactCard {
|
|
||||||
return {
|
|
||||||
...factCard,
|
|
||||||
uncertain_items: [],
|
|
||||||
is_ready_for_optimization: true,
|
|
||||||
confirmed_by_user: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveUncertainItem(
|
|
||||||
factCard: CandidateFactCard,
|
|
||||||
itemIndex: number,
|
itemIndex: number,
|
||||||
target: UncertainItemResolutionTarget,
|
target: UncertainItemResolutionTarget,
|
||||||
): CandidateFactCard {
|
): T {
|
||||||
const item = factCard.uncertain_items[itemIndex]?.trim();
|
const item = factCard.uncertain_items[itemIndex]?.trim();
|
||||||
if (!item) return factCard;
|
if (!item) return factCard;
|
||||||
|
|
||||||
|
|||||||
@@ -1,64 +1,133 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { OptimizedArticle } from "../lib/domain/types";
|
import type { OptimizedArticle } from "../lib/domain/types";
|
||||||
|
|
||||||
interface OptimizedPreviewProps {
|
interface OptimizedPreviewProps {
|
||||||
article: OptimizedArticle | null;
|
article: OptimizedArticle | null;
|
||||||
|
draftArticle?: OptimizedArticle | null;
|
||||||
jobId: string | null;
|
jobId: string | null;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
activityText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportFiles = ["optimized.md", "optimized.docx", "qa_report.json"];
|
const exportFiles = ["optimized.md", "optimized.docx", "qa_report.json"];
|
||||||
|
const streamWords = ["事实", "标题", "结构", "语气", "检索", "改写", "终稿"];
|
||||||
|
|
||||||
export function OptimizedPreview({
|
export function OptimizedPreview({
|
||||||
article,
|
article,
|
||||||
|
draftArticle = null,
|
||||||
jobId,
|
jobId,
|
||||||
|
isStreaming = false,
|
||||||
|
activityText = "",
|
||||||
}: OptimizedPreviewProps) {
|
}: 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 (
|
return (
|
||||||
<section className="panel empty-panel">
|
<section className="panel empty-panel optimized-panel">
|
||||||
<div className="panel-heading">优化结果</div>
|
<div className="panel-heading">优化结果</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel stack">
|
<section
|
||||||
<div className="panel-heading">优化结果</div>
|
aria-live="polite"
|
||||||
<article className="preview">
|
className={`panel stack optimized-panel ${isStreaming ? "is-streaming" : ""}`}
|
||||||
<h2>{article.title}</h2>
|
>
|
||||||
<p>{article.summary}</p>
|
<div className="panel-heading">
|
||||||
<pre>{article.body_markdown}</pre>
|
<span>优化结果</span>
|
||||||
|
{isStreaming && (
|
||||||
|
<span className="stream-status">
|
||||||
|
<span className="stream-cursor" aria-hidden="true" />
|
||||||
|
{activityText || "正在优化"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visibleArticle ? (
|
||||||
|
<article className={`preview ${isStreaming ? "streaming-preview" : ""}`}>
|
||||||
|
<h2>{visibleArticle.title}</h2>
|
||||||
|
<p>{visibleArticle.summary}</p>
|
||||||
|
<pre>{visibleArticle.body_markdown}</pre>
|
||||||
</article>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleArticle && (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<h3>图片建议</h3>
|
<h3>图片建议</h3>
|
||||||
|
{visibleArticle.image_suggestions.length > 0 ? (
|
||||||
<ul>
|
<ul>
|
||||||
{article.image_suggestions.map((item) => (
|
{visibleArticle.image_suggestions.map((item) => (
|
||||||
<li key={item.source}>
|
<li key={item.source}>
|
||||||
<strong>{item.source}</strong>: {item.suggestion}
|
<strong>{item.source}</strong>: {item.suggestion}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="empty-panel">暂无图片建议。</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>修改位置</h3>
|
<h3>修改位置</h3>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
{article.changed_sections.map((section) => (
|
{visibleArticle.changed_sections.map((section) => (
|
||||||
<span className="tag" key={section}>
|
<span className="tag" key={section}>
|
||||||
{section}
|
{section}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{article.requires_user_confirmation.length > 0 && (
|
{visibleArticle.requires_user_confirmation.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3>需要人工确认</h3>
|
<h3>需要人工确认</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{article.requires_user_confirmation.map((item) => (
|
{visibleArticle.requires_user_confirmation.map((item) => (
|
||||||
<li key={item}>{item}</li>
|
<li key={item}>{item}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{article && (
|
||||||
<div className="export-row">
|
<div className="export-row">
|
||||||
{exportFiles.map((fileName) =>
|
{exportFiles.map((fileName) =>
|
||||||
jobId ? (
|
jobId ? (
|
||||||
@@ -76,6 +145,7 @@ export function OptimizedPreview({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user