feat: bootstrap auto virtual tryon admin frontend

This commit is contained in:
afei A
2026-03-27 23:38:50 +08:00
commit 98c6b741d6
119 changed files with 19046 additions and 0 deletions

View 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>
);
}