feat: show request progress timing
This commit is contained in:
@@ -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
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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} 秒`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user