feat: bootstrap auto virtual tryon admin frontend
This commit is contained in:
436
src/features/reviews/review-workbench-detail.tsx
Normal file
436
src/features/reviews/review-workbench-detail.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
"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 { PageHeader } from "@/components/ui/page-header";
|
||||
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-8">
|
||||
<PageHeader
|
||||
eyebrow="Review detail"
|
||||
title={`订单 #${orderDetail.orderId}`}
|
||||
description="审核详情页只处理单个订单,列表筛选和切单行为统一留在审核工作台首页。"
|
||||
meta={`更新于 ${formatTimestamp(orderDetail.updatedAt)}`}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Order status
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<StatusBadge status={orderDetail.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Workflow
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||
{orderDetail.workflowId ?? "暂未分配"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Current step
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<StatusBadge variant="workflowStep" status={orderDetail.currentStep} />
|
||||
<span className="text-sm text-[var(--ink-muted)]">
|
||||
{orderDetail.currentStepLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||
Workflow status
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<StatusBadge status={workflowDetail.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<ReviewImagePanel
|
||||
error={contextError}
|
||||
isLoading={isLoadingContext}
|
||||
order={orderDetail}
|
||||
selectedAssetId={selectedAssetId}
|
||||
onSelectAsset={setSelectedAssetId}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<ReviewRevisionPanel
|
||||
isSubmitting={isSubmitting}
|
||||
order={orderDetail}
|
||||
selectedAsset={selectedAsset}
|
||||
workflow={workflowDetail}
|
||||
revisionError={revisionError}
|
||||
revisionResult={revisionResult}
|
||||
confirmResult={submissionResult}
|
||||
onRegisterRevision={handleRegisterRevision}
|
||||
onConfirmRevision={handleConfirmRevision}
|
||||
/>
|
||||
<ReviewActionPanel
|
||||
key={`${orderDetail.orderId}-${submissionResult?.decision ?? "idle"}`}
|
||||
isSubmitting={isSubmitting}
|
||||
order={orderDetail}
|
||||
selectedAsset={selectedAsset}
|
||||
submissionError={submissionError}
|
||||
submissionResult={submissionResult}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
<ReviewWorkflowSummary
|
||||
error={contextError}
|
||||
isLoading={isLoadingContext}
|
||||
workflow={workflowDetail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user