424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { useEffect, useState } from "react";
|
|
|
|
import { EmptyState } from "@/components/ui/empty-state";
|
|
import { MetricChip } from "@/components/ui/metric-chip";
|
|
import { StatusBadge } from "@/components/ui/status-badge";
|
|
import { ReviewActionPanel } from "@/features/reviews/components/review-action-panel";
|
|
import { ReviewImagePanel } from "@/features/reviews/components/review-image-panel";
|
|
import { ReviewRevisionPanel } from "@/features/reviews/components/review-revision-panel";
|
|
import { ReviewWorkflowSummary } from "@/features/reviews/components/review-workflow-summary";
|
|
import type { ReviewDecision } from "@/lib/types/backend";
|
|
import type {
|
|
AssetViewModel,
|
|
OrderDetailVM,
|
|
ReviewSubmissionVM,
|
|
RevisionRegistrationVM,
|
|
WorkflowDetailVM,
|
|
} from "@/lib/types/view-models";
|
|
|
|
type ApiEnvelope<T> = {
|
|
data?: T;
|
|
message?: string;
|
|
};
|
|
|
|
type ReviewWorkbenchDetailScreenProps = {
|
|
orderId: number;
|
|
};
|
|
|
|
const REVIEWER_ID = 1;
|
|
|
|
function isRerunDecision(decision: ReviewDecision) {
|
|
return (
|
|
decision === "rerun_scene" ||
|
|
decision === "rerun_face" ||
|
|
decision === "rerun_fusion"
|
|
);
|
|
}
|
|
|
|
function getPreferredAsset(order: OrderDetailVM) {
|
|
return order.finalAsset ?? order.assets[0] ?? null;
|
|
}
|
|
|
|
async function parseEnvelope<T>(response: Response): Promise<ApiEnvelope<T>> {
|
|
return (await response.json()) as ApiEnvelope<T>;
|
|
}
|
|
|
|
function formatTimestamp(timestamp: string) {
|
|
return timestamp.replace("T", " ").replace("Z", " UTC");
|
|
}
|
|
|
|
export function ReviewWorkbenchDetailScreen({
|
|
orderId,
|
|
}: ReviewWorkbenchDetailScreenProps) {
|
|
const router = useRouter();
|
|
const [orderDetail, setOrderDetail] = useState<OrderDetailVM | null>(null);
|
|
const [workflowDetail, setWorkflowDetail] = useState<WorkflowDetailVM | null>(
|
|
null,
|
|
);
|
|
const [selectedAssetId, setSelectedAssetId] = useState<number | null>(null);
|
|
const [contextError, setContextError] = useState<string | null>(null);
|
|
const [submissionError, setSubmissionError] = useState<string | null>(null);
|
|
const [submissionResult, setSubmissionResult] =
|
|
useState<ReviewSubmissionVM | null>(null);
|
|
const [revisionError, setRevisionError] = useState<string | null>(null);
|
|
const [revisionResult, setRevisionResult] =
|
|
useState<RevisionRegistrationVM | null>(null);
|
|
const [isLoadingContext, setIsLoadingContext] = useState(true);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
|
|
async function loadReviewContext() {
|
|
setIsLoadingContext(true);
|
|
|
|
try {
|
|
const [orderResponse, workflowResponse] = await Promise.all([
|
|
fetch(`/api/orders/${orderId}`),
|
|
fetch(`/api/workflows/${orderId}`),
|
|
]);
|
|
const [orderPayload, workflowPayload] = await Promise.all([
|
|
parseEnvelope<OrderDetailVM>(orderResponse),
|
|
parseEnvelope<WorkflowDetailVM>(workflowResponse),
|
|
]);
|
|
const nextOrder = orderPayload.data;
|
|
const nextWorkflow = workflowPayload.data;
|
|
|
|
if (
|
|
!orderResponse.ok ||
|
|
!workflowResponse.ok ||
|
|
!nextOrder ||
|
|
!nextWorkflow
|
|
) {
|
|
throw new Error("CONTEXT_LOAD_FAILED");
|
|
}
|
|
|
|
if (!active) {
|
|
return;
|
|
}
|
|
|
|
const preferredAsset = getPreferredAsset(nextOrder);
|
|
|
|
setOrderDetail(nextOrder);
|
|
setWorkflowDetail(nextWorkflow);
|
|
setContextError(null);
|
|
setSelectedAssetId((current) => {
|
|
if (
|
|
current &&
|
|
[
|
|
...(nextOrder.finalAsset ? [nextOrder.finalAsset] : []),
|
|
...nextOrder.assets,
|
|
].some((asset) => asset.id === current)
|
|
) {
|
|
return current;
|
|
}
|
|
|
|
return preferredAsset?.id ?? null;
|
|
});
|
|
} catch {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
|
|
setOrderDetail(null);
|
|
setWorkflowDetail(null);
|
|
setSelectedAssetId(null);
|
|
setContextError("订单详情或流程摘要加载失败,请稍后重试。");
|
|
} finally {
|
|
if (active) {
|
|
setIsLoadingContext(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void loadReviewContext();
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [orderId]);
|
|
|
|
const selectedAsset: AssetViewModel | null =
|
|
(orderDetail?.finalAsset?.id === selectedAssetId
|
|
? orderDetail.finalAsset
|
|
: orderDetail?.assets.find((asset) => asset.id === selectedAssetId)) ??
|
|
orderDetail?.finalAsset ??
|
|
orderDetail?.assets[0] ??
|
|
null;
|
|
|
|
const handleSubmit = async (decision: ReviewDecision, comment: string) => {
|
|
if (!orderDetail) {
|
|
return;
|
|
}
|
|
|
|
if (isRerunDecision(decision) && !comment.trim()) {
|
|
setSubmissionError("请填写审核备注");
|
|
setSubmissionResult(null);
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
setSubmissionError(null);
|
|
setSubmissionResult(null);
|
|
|
|
try {
|
|
const response = await fetch(`/api/reviews/${orderDetail.orderId}/submit`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
decision,
|
|
reviewer_id: REVIEWER_ID,
|
|
selected_asset_id: selectedAsset?.id ?? null,
|
|
comment: comment.trim() ? comment : null,
|
|
}),
|
|
});
|
|
const payload = await parseEnvelope<ReviewSubmissionVM>(response);
|
|
|
|
if (!response.ok || !payload.data) {
|
|
setSubmissionError(payload.message ?? "审核动作提交失败,请稍后重试。");
|
|
return;
|
|
}
|
|
|
|
setSubmissionResult(payload.data);
|
|
router.push("/reviews/workbench");
|
|
} catch {
|
|
setSubmissionError("审核动作提交失败,请检查网络后重试。");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleRegisterRevision = async (payload: {
|
|
uploadedUri: string;
|
|
comment: string;
|
|
}) => {
|
|
if (!orderDetail || !selectedAsset) {
|
|
return;
|
|
}
|
|
|
|
if (!payload.uploadedUri.trim()) {
|
|
setRevisionError("请填写修订稿 URI");
|
|
setRevisionResult(null);
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
setRevisionError(null);
|
|
setRevisionResult(null);
|
|
setSubmissionResult(null);
|
|
|
|
try {
|
|
const response = await fetch(`/api/orders/${orderDetail.orderId}/revisions`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
parent_asset_id: selectedAsset.id,
|
|
uploaded_uri: payload.uploadedUri.trim(),
|
|
reviewer_id: REVIEWER_ID,
|
|
comment: payload.comment.trim() ? payload.comment : null,
|
|
}),
|
|
});
|
|
const revisionPayload = await parseEnvelope<RevisionRegistrationVM>(response);
|
|
|
|
if (!response.ok || !revisionPayload.data) {
|
|
setRevisionError(revisionPayload.message ?? "人工修订稿登记失败,请稍后重试。");
|
|
return;
|
|
}
|
|
|
|
const nextRevision = revisionPayload.data;
|
|
|
|
setRevisionResult(nextRevision);
|
|
setOrderDetail((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
currentRevisionAssetId: nextRevision.assetId,
|
|
currentRevisionVersion: nextRevision.versionNo,
|
|
latestRevisionAssetId: nextRevision.latestRevisionAssetId,
|
|
latestRevisionVersion: nextRevision.versionNo,
|
|
revisionCount: nextRevision.revisionCount,
|
|
reviewTaskStatus: nextRevision.reviewTaskStatus,
|
|
pendingManualConfirm: true,
|
|
}
|
|
: current,
|
|
);
|
|
setWorkflowDetail((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
currentRevisionAssetId: nextRevision.assetId,
|
|
currentRevisionVersion: nextRevision.versionNo,
|
|
latestRevisionAssetId: nextRevision.latestRevisionAssetId,
|
|
latestRevisionVersion: nextRevision.versionNo,
|
|
revisionCount: nextRevision.revisionCount,
|
|
reviewTaskStatus: nextRevision.reviewTaskStatus,
|
|
pendingManualConfirm: true,
|
|
}
|
|
: current,
|
|
);
|
|
} catch {
|
|
setRevisionError("人工修订稿登记失败,请检查网络后重试。");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleConfirmRevision = async (comment: string) => {
|
|
if (!orderDetail) {
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
setRevisionError(null);
|
|
setSubmissionError(null);
|
|
setSubmissionResult(null);
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/reviews/${orderDetail.orderId}/confirm-revision`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
reviewer_id: REVIEWER_ID,
|
|
comment: comment.trim() ? comment : null,
|
|
}),
|
|
},
|
|
);
|
|
const payload = await parseEnvelope<ReviewSubmissionVM>(response);
|
|
|
|
if (!response.ok || !payload.data) {
|
|
setRevisionError(payload.message ?? "确认修订失败,请稍后重试。");
|
|
return;
|
|
}
|
|
|
|
setSubmissionResult(payload.data);
|
|
router.push("/reviews/workbench");
|
|
} catch {
|
|
setRevisionError("确认修订失败,请检查网络后重试。");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (isLoadingContext) {
|
|
return (
|
|
<section className="space-y-4">
|
|
<div className="rounded-[28px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-6 py-10 text-sm text-[var(--ink-muted)]">
|
|
正在加载审核详情…
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (contextError || !orderDetail || !workflowDetail) {
|
|
return (
|
|
<EmptyState
|
|
eyebrow="Review detail error"
|
|
title="审核详情暂时不可用"
|
|
description={contextError ?? "当前审核详情还无法展示,请稍后重试。"}
|
|
actions={
|
|
<Link
|
|
href="/reviews/workbench"
|
|
className="inline-flex min-h-11 items-center justify-center rounded-full border border-[var(--border-strong)] bg-[var(--surface)] px-4 text-sm font-medium text-[var(--ink-strong)] transition hover:bg-[var(--surface-muted)]"
|
|
>
|
|
返回审核列表
|
|
</Link>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="space-y-5">
|
|
<div className="sticky top-0 z-10 -mx-4 border-b border-[var(--border-soft)] bg-[rgba(255,250,242,0.95)] px-4 py-4 backdrop-blur md:-mx-6 md:px-6">
|
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
|
<div className="space-y-2">
|
|
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-[var(--ink-faint)]">
|
|
Review detail
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h1 className="text-2xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]">
|
|
订单 #{orderDetail.orderId}
|
|
</h1>
|
|
<StatusBadge status={orderDetail.status} />
|
|
<StatusBadge variant="workflowStep" status={orderDetail.currentStep} />
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<MetricChip
|
|
label="workflow"
|
|
value={orderDetail.workflowId ?? "暂未分配"}
|
|
/>
|
|
<MetricChip label="step" value={orderDetail.currentStepLabel} />
|
|
<MetricChip
|
|
label="revision"
|
|
value={orderDetail.pendingManualConfirm ? "修订待确认" : "无待确认修订"}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-start gap-2 xl:items-end">
|
|
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--ink-faint)]">
|
|
更新于 {formatTimestamp(orderDetail.updatedAt)}
|
|
</p>
|
|
<Link
|
|
href="/reviews/workbench"
|
|
className="inline-flex h-9 items-center justify-center rounded-md border border-[var(--border-strong)] bg-[var(--surface)] px-3 text-sm font-medium text-[var(--ink-strong)] transition hover:bg-[var(--surface-muted)]"
|
|
>
|
|
返回审核列表
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
|
|
<ReviewImagePanel
|
|
error={contextError}
|
|
isLoading={isLoadingContext}
|
|
order={orderDetail}
|
|
selectedAssetId={selectedAssetId}
|
|
onSelectAsset={setSelectedAssetId}
|
|
/>
|
|
|
|
<div className="grid gap-4 xl:sticky xl:top-24 xl:self-start">
|
|
<ReviewActionPanel
|
|
key={`${orderDetail.orderId}-${submissionResult?.decision ?? "idle"}`}
|
|
isSubmitting={isSubmitting}
|
|
order={orderDetail}
|
|
selectedAsset={selectedAsset}
|
|
submissionError={submissionError}
|
|
submissionResult={submissionResult}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
<ReviewRevisionPanel
|
|
isSubmitting={isSubmitting}
|
|
order={orderDetail}
|
|
selectedAsset={selectedAsset}
|
|
workflow={workflowDetail}
|
|
revisionError={revisionError}
|
|
revisionResult={revisionResult}
|
|
confirmResult={submissionResult}
|
|
onRegisterRevision={handleRegisterRevision}
|
|
onConfirmRevision={handleConfirmRevision}
|
|
/>
|
|
<ReviewWorkflowSummary
|
|
error={contextError}
|
|
isLoading={isLoadingContext}
|
|
workflow={workflowDetail}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|