From fd7e92b02638bf44b04197390b1a94ae32131a87 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 17 Jun 2026 09:54:10 +0800 Subject: [PATCH] feat: show request progress timing --- src/app/globals.css | 46 +++++++++++++++++ src/app/page.tsx | 80 +++++++++++++++++++++++++++--- src/components/progress-panel.tsx | 82 +++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 src/components/progress-panel.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 06a94f7..414d24d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -249,6 +249,52 @@ h3 { 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) { .workflow-grid, .two-col { diff --git a/src/app/page.tsx b/src/app/page.tsx index a9076bd..05732cc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ArticleInputForm, @@ -11,12 +11,14 @@ import { toConfirmedFactCard, } from "../components/fact-card-editor"; import { OptimizedPreview } from "../components/optimized-preview"; +import { ProgressPanel } from "../components/progress-panel"; import { QaReportPanel } from "../components/qa-report-panel"; import type { CandidateFactCard, OptimizedArticle, QaReport, } from "../lib/domain/types"; +import type { ProgressAction } from "../lib/progress/progress"; const initialInput: ArticleInputPayload = { title: "", @@ -28,11 +30,23 @@ const initialInput: ArticleInputPayload = { interface ApiErrorResponse { error?: string; + timing?: TimingSummary; +} + +interface TimingStep { + label: string; + duration_ms: number; +} + +interface TimingSummary { + total_ms: number; + steps: TimingStep[]; } interface CreateJobResponse extends ApiErrorResponse { job: { id: string }; candidateFactCard: CandidateFactCard; + timing?: TimingSummary; } interface ConfirmFactCardResponse extends ApiErrorResponse { @@ -42,6 +56,7 @@ interface ConfirmFactCardResponse extends ApiErrorResponse { interface OptimizeJobResponse extends ApiErrorResponse { optimizedArticle: OptimizedArticle; qaReport: QaReport; + timing?: TimingSummary; } export default function Home() { @@ -54,6 +69,8 @@ export default function Home() { const [busyAction, setBusyAction] = useState(null); const [message, setMessage] = useState(""); const [apiAccessKey, setApiAccessKey] = useState(""); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [lastTiming, setLastTiming] = useState(null); const exportBlocked = useMemo( () => qaReport?.overall_status === "fail", @@ -62,9 +79,20 @@ export default function Home() { const canOptimize = 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() { setBusyAction("analyze"); setMessage(""); + setLastTiming(null); setOptimizedArticle(null); setQaReport(null); try { @@ -74,11 +102,19 @@ export default function Home() { body: JSON.stringify(input), }); 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); setFactCard(body.candidateFactCard); - setMessage("候选事实卡已生成。"); + 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); @@ -89,6 +125,7 @@ export default function Home() { if (!jobId || !factCard) return; setBusyAction("confirm"); setMessage(""); + setLastTiming(null); try { const response = await fetch(`/api/jobs/${jobId}/confirm-fact-card`, { method: "POST", @@ -110,21 +147,29 @@ export default function Home() { if (!jobId) return; setBusyAction("optimize"); setMessage(""); + setLastTiming(null); try { const response = await fetch(`/api/jobs/${jobId}/optimize`, { method: "POST", headers: apiHeaders(apiAccessKey), }); 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); setQaReport(body.qaReport); + setLastTiming(body.timing ?? 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); + } setMessage(error instanceof Error ? error.message : "优化失败"); } finally { setBusyAction(null); @@ -155,6 +200,11 @@ export default function Home() { {busyAction === "optimize" ? "优化中..." : "开始优化"} +
+ {action ? ( + <> +
+ {getActionLabel(action)} + 已用时 {formatElapsedSeconds(elapsedSeconds)} +
+

{getElapsedNotice(elapsedSeconds)}

+
    + {getProgressStages(action).map((stage) => ( +
  1. {stage.label}
  2. + ))} +
+ + ) : completedTiming ? ( + <> +
+ 最近一次耗时 + {formatMilliseconds(completedTiming.total_ms)} +
+ {completedTiming.steps.length > 0 && ( +
    + {completedTiming.steps.map((step) => ( +
  1. + {step.label}: {formatMilliseconds(step.duration_ms)} +
  2. + ))} +
+ )} + + ) : null} + + ); +} + +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} 秒`; +}