feat: show request progress timing

This commit is contained in:
Codex
2026-06-21 23:43:03 +08:00
parent 60cf55f755
commit fd7e92b026
3 changed files with 202 additions and 6 deletions
+46
View File
@@ -249,6 +249,52 @@ h3 {
color: #2f6fdd; color: #2f6fdd;
} }
.progress-panel {
border: 1px solid #dce2eb;
border-radius: 8px;
background: #ffffff;
display: grid;
gap: 0.65rem;
padding: 0.85rem 1rem;
}
.progress-summary {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: space-between;
}
.progress-summary strong {
color: #172033;
}
.progress-summary span,
.progress-panel p {
color: #586174;
}
.progress-panel p {
margin: 0;
}
.progress-steps {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.progress-steps li {
border: 1px solid #cbd3df;
border-radius: 999px;
color: #586174;
padding: 0.25rem 0.55rem;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.workflow-grid, .workflow-grid,
.two-col { .two-col {
+74 -6
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
ArticleInputForm, ArticleInputForm,
@@ -11,12 +11,14 @@ import {
toConfirmedFactCard, toConfirmedFactCard,
} from "../components/fact-card-editor"; } from "../components/fact-card-editor";
import { OptimizedPreview } from "../components/optimized-preview"; import { OptimizedPreview } from "../components/optimized-preview";
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, CandidateFactCard,
OptimizedArticle, OptimizedArticle,
QaReport, QaReport,
} from "../lib/domain/types"; } from "../lib/domain/types";
import type { ProgressAction } from "../lib/progress/progress";
const initialInput: ArticleInputPayload = { const initialInput: ArticleInputPayload = {
title: "", title: "",
@@ -28,11 +30,23 @@ const initialInput: ArticleInputPayload = {
interface ApiErrorResponse { interface ApiErrorResponse {
error?: string; error?: string;
timing?: TimingSummary;
}
interface TimingStep {
label: string;
duration_ms: number;
}
interface TimingSummary {
total_ms: number;
steps: TimingStep[];
} }
interface CreateJobResponse extends ApiErrorResponse { interface CreateJobResponse extends ApiErrorResponse {
job: { id: string }; job: { id: string };
candidateFactCard: CandidateFactCard; candidateFactCard: CandidateFactCard;
timing?: TimingSummary;
} }
interface ConfirmFactCardResponse extends ApiErrorResponse { interface ConfirmFactCardResponse extends ApiErrorResponse {
@@ -42,6 +56,7 @@ interface ConfirmFactCardResponse extends ApiErrorResponse {
interface OptimizeJobResponse extends ApiErrorResponse { interface OptimizeJobResponse extends ApiErrorResponse {
optimizedArticle: OptimizedArticle; optimizedArticle: OptimizedArticle;
qaReport: QaReport; qaReport: QaReport;
timing?: TimingSummary;
} }
export default function Home() { export default function Home() {
@@ -54,6 +69,8 @@ export default function Home() {
const [busyAction, setBusyAction] = useState<string | null>(null); const [busyAction, setBusyAction] = useState<string | null>(null);
const [message, setMessage] = useState<string>(""); const [message, setMessage] = useState<string>("");
const [apiAccessKey, setApiAccessKey] = useState(""); const [apiAccessKey, setApiAccessKey] = useState("");
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [lastTiming, setLastTiming] = useState<TimingSummary | null>(null);
const exportBlocked = useMemo( const exportBlocked = useMemo(
() => qaReport?.overall_status === "fail", () => qaReport?.overall_status === "fail",
@@ -62,9 +79,20 @@ export default function Home() {
const canOptimize = const canOptimize =
Boolean(jobId) && Boolean(factCard) && factCard?.uncertain_items.length === 0; Boolean(jobId) && Boolean(factCard) && factCard?.uncertain_items.length === 0;
useEffect(() => {
if (!busyAction) return;
setElapsedSeconds(0);
const startedAt = Date.now();
const timer = window.setInterval(() => {
setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000));
}, 1000);
return () => window.clearInterval(timer);
}, [busyAction]);
async function analyze() { async function analyze() {
setBusyAction("analyze"); setBusyAction("analyze");
setMessage(""); setMessage("");
setLastTiming(null);
setOptimizedArticle(null); setOptimizedArticle(null);
setQaReport(null); setQaReport(null);
try { try {
@@ -74,11 +102,19 @@ export default function Home() {
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
const body = (await response.json()) as CreateJobResponse; const body = (await response.json()) as CreateJobResponse;
if (!response.ok) throw new Error(body.error ?? "分析失败"); if (!response.ok) throw new ApiResponseError(body.error ?? "分析失败", body.timing);
setJobId(body.job.id); setJobId(body.job.id);
setFactCard(body.candidateFactCard); setFactCard(body.candidateFactCard);
setMessage("候选事实卡已生成。"); setLastTiming(body.timing ?? null);
setMessage(
body.timing
? `候选事实卡已生成,用时 ${formatTiming(body.timing.total_ms)}`
: "候选事实卡已生成。",
);
} catch (error) { } catch (error) {
if (error instanceof ApiResponseError) {
setLastTiming(error.timing ?? null);
}
setMessage(error instanceof Error ? error.message : "分析失败"); setMessage(error instanceof Error ? error.message : "分析失败");
} finally { } finally {
setBusyAction(null); setBusyAction(null);
@@ -89,6 +125,7 @@ export default function Home() {
if (!jobId || !factCard) return; if (!jobId || !factCard) return;
setBusyAction("confirm"); setBusyAction("confirm");
setMessage(""); setMessage("");
setLastTiming(null);
try { try {
const response = await fetch(`/api/jobs/${jobId}/confirm-fact-card`, { const response = await fetch(`/api/jobs/${jobId}/confirm-fact-card`, {
method: "POST", method: "POST",
@@ -110,21 +147,29 @@ export default function Home() {
if (!jobId) return; if (!jobId) return;
setBusyAction("optimize"); setBusyAction("optimize");
setMessage(""); setMessage("");
setLastTiming(null);
try { try {
const response = await fetch(`/api/jobs/${jobId}/optimize`, { const response = await fetch(`/api/jobs/${jobId}/optimize`, {
method: "POST", method: "POST",
headers: apiHeaders(apiAccessKey), headers: apiHeaders(apiAccessKey),
}); });
const body = (await response.json()) as OptimizeJobResponse; const body = (await response.json()) as OptimizeJobResponse;
if (!response.ok) throw new Error(body.error ?? "优化失败"); if (!response.ok) throw new ApiResponseError(body.error ?? "优化失败", body.timing);
setOptimizedArticle(body.optimizedArticle); setOptimizedArticle(body.optimizedArticle);
setQaReport(body.qaReport); setQaReport(body.qaReport);
setLastTiming(body.timing ?? null);
const timingText = body.timing
? `用时 ${formatTiming(body.timing.total_ms)}`
: "";
setMessage( setMessage(
body.qaReport.overall_status === "fail" body.qaReport.overall_status === "fail"
? "质检发现硬性失败,已阻止导出。" ? `质检发现硬性失败,已阻止导出。${timingText}`
: "优化完成。", : `优化完成。${timingText}`,
); );
} catch (error) { } catch (error) {
if (error instanceof ApiResponseError) {
setLastTiming(error.timing ?? null);
}
setMessage(error instanceof Error ? error.message : "优化失败"); setMessage(error instanceof Error ? error.message : "优化失败");
} finally { } finally {
setBusyAction(null); setBusyAction(null);
@@ -155,6 +200,11 @@ export default function Home() {
{busyAction === "optimize" ? "优化中..." : "开始优化"} {busyAction === "optimize" ? "优化中..." : "开始优化"}
</button> </button>
</header> </header>
<ProgressPanel
action={busyAction as ProgressAction | null}
elapsedSeconds={elapsedSeconds}
lastTiming={lastTiming}
/>
<div className="workflow-grid"> <div className="workflow-grid">
<ArticleInputForm <ArticleInputForm
isSubmitting={busyAction === "analyze"} isSubmitting={busyAction === "analyze"}
@@ -186,3 +236,21 @@ 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";
}
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import {
formatElapsedSeconds,
getElapsedNotice,
getProgressStages,
type ProgressAction,
} from "../lib/progress/progress";
interface TimingStep {
label: string;
duration_ms: number;
}
interface TimingSummary {
total_ms: number;
steps: TimingStep[];
}
interface ProgressPanelProps {
action: ProgressAction | null;
elapsedSeconds: number;
lastTiming: TimingSummary | null;
}
export function ProgressPanel({
action,
elapsedSeconds,
lastTiming,
}: ProgressPanelProps) {
if (!action && !lastTiming) return null;
const completedTiming = lastTiming;
return (
<section className="progress-panel" aria-live="polite">
{action ? (
<>
<div className="progress-summary">
<strong>{getActionLabel(action)}</strong>
<span> {formatElapsedSeconds(elapsedSeconds)}</span>
</div>
<p>{getElapsedNotice(elapsedSeconds)}</p>
<ol className="progress-steps">
{getProgressStages(action).map((stage) => (
<li key={stage.label}>{stage.label}</li>
))}
</ol>
</>
) : completedTiming ? (
<>
<div className="progress-summary">
<strong></strong>
<span>{formatMilliseconds(completedTiming.total_ms)}</span>
</div>
{completedTiming.steps.length > 0 && (
<ol className="progress-steps">
{completedTiming.steps.map((step) => (
<li key={`${step.label}-${step.duration_ms}`}>
{step.label}: {formatMilliseconds(step.duration_ms)}
</li>
))}
</ol>
)}
</>
) : null}
</section>
);
}
function getActionLabel(action: ProgressAction) {
if (action === "analyze") return "正在分析文章";
if (action === "confirm") return "正在确认事实卡";
return "正在优化文章";
}
function formatMilliseconds(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}`;
}