feat: enhance order asset selection and previews
This commit is contained in:
1
package-lock.json
generated
1
package-lock.json
generated
@@ -7,6 +7,7 @@
|
||||
"name": "auto-virtual-tryon-frontend",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
|
||||
107
src/components/ui/dialog.tsx
Normal file
107
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-[rgba(52,39,27,0.42)] backdrop-blur-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Content>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 grid max-h-[88vh] w-[min(96vw,78rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-hidden rounded-[32px] border border-[var(--border-soft)] bg-[var(--surface)] shadow-[0_24px_80px_rgba(39,31,24,0.18)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-5 top-5 inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--border-soft)] bg-[var(--surface)] text-[var(--ink-muted)] transition hover:bg-[var(--surface-muted)] hover:text-[var(--ink-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]"
|
||||
aria-label="关闭弹窗"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
function DialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<"div">) {
|
||||
return <div className={cn("space-y-2 px-6 pt-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogBody({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<"div">) {
|
||||
return <div className={cn("overflow-y-auto px-6 pb-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
const DialogTitle = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Title>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"pr-14 text-2xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Description>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm leading-6 text-[var(--ink-muted)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { OrderSummaryCard } from "@/features/orders/components/order-summary-card";
|
||||
import { ResourcePickerCard } from "@/features/orders/components/resource-picker-card";
|
||||
import { ResourcePickerModal } from "@/features/orders/components/resource-picker-modal";
|
||||
import {
|
||||
SERVICE_MODE_LABELS,
|
||||
type ModelPickerOption,
|
||||
@@ -19,6 +20,8 @@ import type {
|
||||
CustomerLevel,
|
||||
ServiceMode,
|
||||
} from "@/lib/types/backend";
|
||||
import type { LibraryType } from "@/lib/types/view-models";
|
||||
import { useState } from "react";
|
||||
|
||||
type SubmissionSuccess = {
|
||||
orderId: number;
|
||||
@@ -68,125 +71,161 @@ export function CreateOrderForm({
|
||||
onServiceModeChange,
|
||||
onSubmit,
|
||||
}: CreateOrderFormProps) {
|
||||
const [activePicker, setActivePicker] = useState<LibraryType | null>(null);
|
||||
const selectedModel = models.find((item) => item.id === value.modelId) ?? null;
|
||||
const selectedScene = scenes.find((item) => item.id === value.sceneId) ?? null;
|
||||
const selectedGarment =
|
||||
garments.find((item) => item.id === value.garmentId) ?? null;
|
||||
|
||||
const activePickerConfig = activePicker
|
||||
? {
|
||||
models: {
|
||||
items: models,
|
||||
label: "模特资源",
|
||||
selectedId: value.modelId,
|
||||
onSelect: onModelChange,
|
||||
},
|
||||
scenes: {
|
||||
items: scenes,
|
||||
label: "场景资源",
|
||||
selectedId: value.sceneId,
|
||||
onSelect: onSceneChange,
|
||||
},
|
||||
garments: {
|
||||
items: garments,
|
||||
label: "服装资源",
|
||||
selectedId: value.garmentId,
|
||||
onSelect: onGarmentChange,
|
||||
},
|
||||
}[activePicker]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.85fr)]"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardEyebrow>Business inputs</CardEyebrow>
|
||||
<CardTitle>订单参数</CardTitle>
|
||||
<CardDescription>
|
||||
先确定客户层级,再由表单自动约束允许的服务模式。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
||||
<span className="font-medium">客户层级</span>
|
||||
<Select
|
||||
aria-label="客户层级"
|
||||
className="min-h-12 rounded-[18px] border-[var(--border-strong)] bg-[var(--surface-muted)] px-4"
|
||||
disabled={isSubmitting}
|
||||
options={[
|
||||
{ value: "low", label: "低客单 low" },
|
||||
{ value: "mid", label: "中客单 mid" },
|
||||
]}
|
||||
value={value.customerLevel}
|
||||
onValueChange={(nextValue) =>
|
||||
onCustomerLevelChange(nextValue as CustomerLevel)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<>
|
||||
<form
|
||||
className="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.85fr)]"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardEyebrow>Business inputs</CardEyebrow>
|
||||
<CardTitle>订单参数</CardTitle>
|
||||
<CardDescription>
|
||||
先确定客户层级,再由表单自动约束允许的服务模式。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
||||
<span className="font-medium">客户层级</span>
|
||||
<Select
|
||||
aria-label="客户层级"
|
||||
className="min-h-12 rounded-[18px] border-[var(--border-strong)] bg-[var(--surface-muted)] px-4"
|
||||
disabled={isSubmitting}
|
||||
options={[
|
||||
{ value: "low", label: "低客单 low" },
|
||||
{ value: "mid", label: "中客单 mid" },
|
||||
]}
|
||||
value={value.customerLevel}
|
||||
onValueChange={(nextValue) =>
|
||||
onCustomerLevelChange(nextValue as CustomerLevel)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
||||
<span className="font-medium">服务模式</span>
|
||||
<Select
|
||||
aria-label="服务模式"
|
||||
className="min-h-12 rounded-[18px] border-[var(--border-strong)] bg-[var(--surface-muted)] px-4"
|
||||
disabled={isSubmitting}
|
||||
options={[
|
||||
{
|
||||
value: "auto_basic",
|
||||
label: `${SERVICE_MODE_LABELS.auto_basic} auto_basic`,
|
||||
disabled: allowedServiceMode !== "auto_basic",
|
||||
},
|
||||
{
|
||||
value: "semi_pro",
|
||||
label: `${SERVICE_MODE_LABELS.semi_pro} semi_pro`,
|
||||
disabled: allowedServiceMode !== "semi_pro",
|
||||
},
|
||||
]}
|
||||
value={value.serviceMode}
|
||||
onValueChange={(nextValue) =>
|
||||
onServiceModeChange(nextValue as ServiceMode)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
||||
<span className="font-medium">服务模式</span>
|
||||
<Select
|
||||
aria-label="服务模式"
|
||||
className="min-h-12 rounded-[18px] border-[var(--border-strong)] bg-[var(--surface-muted)] px-4"
|
||||
disabled={isSubmitting}
|
||||
options={[
|
||||
{
|
||||
value: "auto_basic",
|
||||
label: `${SERVICE_MODE_LABELS.auto_basic} auto_basic`,
|
||||
disabled: allowedServiceMode !== "auto_basic",
|
||||
},
|
||||
{
|
||||
value: "semi_pro",
|
||||
label: `${SERVICE_MODE_LABELS.semi_pro} semi_pro`,
|
||||
disabled: allowedServiceMode !== "semi_pro",
|
||||
},
|
||||
]}
|
||||
value={value.serviceMode}
|
||||
onValueChange={(nextValue) =>
|
||||
onServiceModeChange(nextValue as ServiceMode)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<ResourcePickerCard
|
||||
description="使用资源库素材挑选模特,提交时会映射到真实后端资源 ID。"
|
||||
disabled={isSubmitting}
|
||||
isLoading={isLoadingResources}
|
||||
items={models}
|
||||
label="模特资源"
|
||||
title="模特"
|
||||
value={value.modelId}
|
||||
onChange={onModelChange}
|
||||
/>
|
||||
<ResourcePickerCard
|
||||
description="场景仍来自 mock 数据,但通过 BFF 路由加载,保持页面集成方式真实。"
|
||||
disabled={isSubmitting}
|
||||
isLoading={isLoadingResources}
|
||||
items={scenes}
|
||||
label="场景资源"
|
||||
title="场景"
|
||||
value={value.sceneId}
|
||||
onChange={onSceneChange}
|
||||
/>
|
||||
<ResourcePickerCard
|
||||
description="服装选择沿用当前资源库占位素材,为订单创建页提供稳定联调入口。"
|
||||
disabled={isSubmitting}
|
||||
isLoading={isLoadingResources}
|
||||
items={garments}
|
||||
label="服装资源"
|
||||
title="服装"
|
||||
value={value.garmentId}
|
||||
onChange={onGarmentChange}
|
||||
/>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<ResourcePickerCard
|
||||
description="使用资源库素材挑选模特,提交时会映射到真实后端资源 ID。"
|
||||
disabled={isSubmitting}
|
||||
isLoading={isLoadingResources}
|
||||
label="模特资源"
|
||||
selectedItem={selectedModel}
|
||||
title="模特"
|
||||
onOpenPicker={() => setActivePicker("models")}
|
||||
/>
|
||||
<ResourcePickerCard
|
||||
description="场景仍来自 mock 数据,但通过 BFF 路由加载,保持页面集成方式真实。"
|
||||
disabled={isSubmitting}
|
||||
isLoading={isLoadingResources}
|
||||
label="场景资源"
|
||||
selectedItem={selectedScene}
|
||||
title="场景"
|
||||
onOpenPicker={() => setActivePicker("scenes")}
|
||||
/>
|
||||
<ResourcePickerCard
|
||||
description="服装选择沿用当前资源库占位素材,为订单创建页提供稳定联调入口。"
|
||||
disabled={isSubmitting}
|
||||
isLoading={isLoadingResources}
|
||||
label="服装资源"
|
||||
selectedItem={selectedGarment}
|
||||
title="服装"
|
||||
onOpenPicker={() => setActivePicker("garments")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button disabled={isLoadingResources || isSubmitting} type="submit">
|
||||
{isSubmitting ? "提交中..." : "提交订单"}
|
||||
</Button>
|
||||
<p className="text-sm text-[var(--ink-muted)]">
|
||||
提交只负责创建订单,不承载审核或流程追踪行为。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button disabled={isLoadingResources || isSubmitting} type="submit">
|
||||
{isSubmitting ? "提交中..." : "提交订单"}
|
||||
</Button>
|
||||
<p className="text-sm text-[var(--ink-muted)]">
|
||||
提交只负责创建订单,不承载审核或流程追踪行为。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<OrderSummaryCard
|
||||
customerLevel={value.customerLevel}
|
||||
garment={selectedGarment}
|
||||
model={selectedModel}
|
||||
scene={selectedScene}
|
||||
serviceMode={value.serviceMode}
|
||||
submitError={submitError}
|
||||
submissionSuccess={submissionSuccess}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<OrderSummaryCard
|
||||
customerLevel={value.customerLevel}
|
||||
garment={selectedGarment}
|
||||
model={selectedModel}
|
||||
scene={selectedScene}
|
||||
serviceMode={value.serviceMode}
|
||||
submitError={submitError}
|
||||
submissionSuccess={submissionSuccess}
|
||||
/>
|
||||
</form>
|
||||
{activePicker && activePickerConfig ? (
|
||||
<ResourcePickerModal
|
||||
isLoading={isLoadingResources}
|
||||
items={activePickerConfig.items}
|
||||
label={activePickerConfig.label}
|
||||
libraryType={activePicker}
|
||||
open
|
||||
selectedId={activePickerConfig.selectedId}
|
||||
onClose={() => setActivePicker(null)}
|
||||
onSelect={activePickerConfig.onSelect}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { RotateCcw, ZoomIn } from "lucide-react";
|
||||
import { useMemo, useState, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from "react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogBody, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import type { AssetViewModel, OrderDetailVM } from "@/lib/types/view-models";
|
||||
|
||||
@@ -6,7 +10,156 @@ type OrderAssetsPanelProps = {
|
||||
viewModel: OrderDetailVM;
|
||||
};
|
||||
|
||||
function renderAssetCard(asset: AssetViewModel) {
|
||||
type AssetInputSnapshot = {
|
||||
label: string;
|
||||
name: string;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
type PreviewPayload = {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getInputSnapshot(metadata: AssetViewModel["metadata"], key: string) {
|
||||
if (!metadata || !isObjectRecord(metadata[key])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return metadata[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getAssetInputSnapshots(asset: AssetViewModel): AssetInputSnapshot[] {
|
||||
const inputConfigs = [
|
||||
{ key: "model_input", label: "模特图" },
|
||||
{ key: "garment_input", label: "服装图" },
|
||||
{ key: "scene_input", label: "场景图" },
|
||||
{ key: "pose_input", label: "姿势图" },
|
||||
];
|
||||
|
||||
return inputConfigs
|
||||
.map(({ key, label }) => {
|
||||
const snapshot = getInputSnapshot(asset.metadata, key);
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resourceName =
|
||||
typeof snapshot.resource_name === "string"
|
||||
? snapshot.resource_name
|
||||
: typeof snapshot.pose_id === "number"
|
||||
? `姿势 #${snapshot.pose_id}`
|
||||
: "未命名素材";
|
||||
const originalUrl = typeof snapshot.original_url === "string" ? snapshot.original_url : null;
|
||||
|
||||
return {
|
||||
label,
|
||||
name: resourceName,
|
||||
url: originalUrl,
|
||||
};
|
||||
})
|
||||
.filter((item): item is AssetInputSnapshot => item !== null);
|
||||
}
|
||||
|
||||
function renderInputSnapshots(asset: AssetViewModel, onOpenPreview: (payload: PreviewPayload) => void) {
|
||||
const inputSnapshots = getAssetInputSnapshots(asset);
|
||||
if (!inputSnapshots.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--ink-faint)]">输入素材</p>
|
||||
<p className="text-xs text-[var(--ink-muted)]">展示这一步使用的上游素材,方便快速核对输入来源。</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{inputSnapshots.map((snapshot) => (
|
||||
<div
|
||||
key={`${asset.id}-${snapshot.label}`}
|
||||
className="overflow-hidden rounded-[20px] border border-[var(--border-soft)] bg-[var(--surface)]"
|
||||
>
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-[rgba(74,64,53,0.06)] p-3">
|
||||
{snapshot.url ? (
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-full w-full items-center justify-center outline-none"
|
||||
onClick={() =>
|
||||
onOpenPreview({
|
||||
title: `${snapshot.label}预览`,
|
||||
description: snapshot.name,
|
||||
url: snapshot.url!,
|
||||
})
|
||||
}
|
||||
aria-label={`查看${snapshot.name}大图`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={snapshot.url}
|
||||
alt={snapshot.name}
|
||||
className="block max-h-full max-w-full object-contain transition duration-200 group-hover:scale-[1.02]"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-xs text-[var(--ink-muted)]">
|
||||
暂无预览图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 px-3 py-3">
|
||||
<p className="text-xs font-semibold text-[var(--ink-strong)]">{snapshot.label}</p>
|
||||
<p className="line-clamp-2 text-xs text-[var(--ink-muted)]">{snapshot.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAssetPreview(asset: AssetViewModel, onOpenPreview: (payload: PreviewPayload) => void) {
|
||||
return (
|
||||
<div className="mt-4 flex aspect-[4/3] items-center justify-center overflow-hidden rounded-[20px] border border-[var(--border-soft)] bg-[rgba(74,64,53,0.06)] p-3">
|
||||
{asset.isMock ? (
|
||||
<div className="text-center text-xs text-[var(--ink-muted)]">
|
||||
<p className="font-semibold text-[var(--ink-strong)]">Mock 预览</p>
|
||||
<p className="mt-1">当前资产没有真实图片内容</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-full w-full items-center justify-center outline-none"
|
||||
onClick={() =>
|
||||
onOpenPreview({
|
||||
title: `${asset.label}预览`,
|
||||
description: `${asset.stepLabel}阶段产物`,
|
||||
url: asset.uri,
|
||||
})
|
||||
}
|
||||
aria-label={`查看${asset.label}大图`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={asset.uri}
|
||||
alt={`${asset.label}预览`}
|
||||
className="block max-h-full max-w-full object-contain transition duration-200 group-hover:scale-[1.02]"
|
||||
/>
|
||||
<span className="pointer-events-none absolute hidden rounded-full bg-[rgba(52,39,27,0.78)] px-3 py-1.5 text-xs font-medium text-white group-hover:inline-flex group-hover:items-center group-hover:gap-1">
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
放大查看
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAssetCard(asset: AssetViewModel, onOpenPreview: (payload: PreviewPayload) => void) {
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
@@ -23,14 +176,87 @@ function renderAssetCard(asset: AssetViewModel) {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<code className="mt-4 block rounded-[20px] bg-[rgba(74,64,53,0.08)] px-3 py-3 text-xs leading-6 text-[var(--ink-muted)]">
|
||||
{renderAssetPreview(asset, onOpenPreview)}
|
||||
{/* <code className="mt-4 block rounded-[20px] bg-[rgba(74,64,53,0.08)] px-3 py-3 text-xs leading-6 text-[var(--ink-muted)]">
|
||||
{asset.uri}
|
||||
</code>
|
||||
</code> */}
|
||||
{renderInputSnapshots(asset, onOpenPreview)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) {
|
||||
const [preview, setPreview] = useState<PreviewPayload | null>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [dragOrigin, setDragOrigin] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
function resetPreviewState() {
|
||||
setScale(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
setDragging(false);
|
||||
setDragOrigin(null);
|
||||
}
|
||||
|
||||
function openPreview(payload: PreviewPayload) {
|
||||
setPreview(payload);
|
||||
resetPreviewState();
|
||||
}
|
||||
|
||||
function closePreview(open: boolean) {
|
||||
if (!open) {
|
||||
setPreview(null);
|
||||
resetPreviewState();
|
||||
}
|
||||
}
|
||||
|
||||
function handleWheel(event: ReactWheelEvent<HTMLImageElement>) {
|
||||
event.preventDefault();
|
||||
const nextScale = Number(
|
||||
Math.min(3.5, Math.max(1, scale + (event.deltaY < 0 ? 0.12 : -0.12))).toFixed(2),
|
||||
);
|
||||
setScale(nextScale);
|
||||
if (nextScale === 1) {
|
||||
setOffset({ x: 0, y: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown(event: ReactPointerEvent<HTMLImageElement>) {
|
||||
if (scale <= 1) {
|
||||
return;
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDragging(true);
|
||||
setDragOrigin({
|
||||
x: event.clientX - offset.x,
|
||||
y: event.clientY - offset.y,
|
||||
});
|
||||
}
|
||||
|
||||
function handlePointerMove(event: ReactPointerEvent<HTMLImageElement>) {
|
||||
if (!dragging || !dragOrigin || scale <= 1) {
|
||||
return;
|
||||
}
|
||||
setOffset({
|
||||
x: event.clientX - dragOrigin.x,
|
||||
y: event.clientY - dragOrigin.y,
|
||||
});
|
||||
}
|
||||
|
||||
function handlePointerUp(event: ReactPointerEvent<HTMLImageElement>) {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
setDragging(false);
|
||||
setDragOrigin(null);
|
||||
}
|
||||
|
||||
const previewTransform = useMemo(
|
||||
() => `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
||||
[offset.x, offset.y, scale],
|
||||
);
|
||||
|
||||
const finalAssetTitle =
|
||||
viewModel.finalAssetState.kind === "business-empty"
|
||||
? viewModel.finalAssetState.title
|
||||
@@ -76,7 +302,7 @@ export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) {
|
||||
</p>
|
||||
</div>
|
||||
{viewModel.finalAsset ? (
|
||||
renderAssetCard(viewModel.finalAsset)
|
||||
renderAssetCard(viewModel.finalAsset, openPreview)
|
||||
) : (
|
||||
<EmptyState
|
||||
eyebrow="Final asset empty"
|
||||
@@ -94,7 +320,9 @@ export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) {
|
||||
</p>
|
||||
</div>
|
||||
{viewModel.assets.length ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">{viewModel.assets.map(renderAssetCard)}</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{viewModel.assets.map((asset) => renderAssetCard(asset, openPreview))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
eyebrow="Gallery empty"
|
||||
@@ -103,6 +331,53 @@ export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={preview !== null} onOpenChange={closePreview}>
|
||||
<DialogContent className="w-[min(96vw,88rem)]">
|
||||
{preview ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{preview.title}</DialogTitle>
|
||||
<DialogDescription>{preview.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3 rounded-[20px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-3 text-sm text-[var(--ink-muted)]">
|
||||
<span>缩放 {Math.round(scale * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--border-soft)] bg-[var(--surface)] px-3 py-1.5 text-sm font-medium text-[var(--ink-strong)] transition hover:bg-[var(--surface-muted)]"
|
||||
onClick={resetPreviewState}
|
||||
aria-label="重置预览"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex h-[72vh] items-center justify-center overflow-hidden rounded-[28px] border border-[var(--border-soft)] bg-[rgba(52,39,27,0.06)] p-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={preview.url}
|
||||
alt={`${preview.title.replace(/预览$/, "")}大图`}
|
||||
className="max-h-full max-w-full select-none object-contain"
|
||||
style={{
|
||||
transform: previewTransform,
|
||||
transition: dragging ? "none" : "transform 120ms ease-out",
|
||||
cursor: scale > 1 ? (dragging ? "grabbing" : "grab") : "zoom-in",
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onDoubleClick={resetPreviewState}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -6,59 +6,67 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ResourcePickerOption } from "@/features/orders/resource-picker-options";
|
||||
|
||||
type ResourcePickerCardProps = {
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
items: ResourcePickerOption[];
|
||||
label: string;
|
||||
selectedItem: ResourcePickerOption | null;
|
||||
title: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onOpenPicker: () => void;
|
||||
};
|
||||
|
||||
export function ResourcePickerCard({
|
||||
description,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
items,
|
||||
label,
|
||||
selectedItem,
|
||||
title,
|
||||
value,
|
||||
onChange,
|
||||
onOpenPicker,
|
||||
}: ResourcePickerCardProps) {
|
||||
const selectedItem = items.find((item) => item.id === value) ?? null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardEyebrow>Mock backed selector</CardEyebrow>
|
||||
<CardEyebrow>Resource manager</CardEyebrow>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
||||
<div className="space-y-2 text-sm text-[var(--ink-strong)]">
|
||||
<span className="font-medium">{label}</span>
|
||||
<Select
|
||||
aria-label={label}
|
||||
className="min-h-12 rounded-[18px] border-[var(--border-strong)] bg-[var(--surface-muted)] px-4"
|
||||
<Button
|
||||
aria-label={selectedItem ? `更换${label}` : `选择${label}`}
|
||||
className="min-h-12 w-full justify-between rounded-[18px] border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-left text-[var(--ink-strong)] shadow-none hover:bg-[var(--surface)]"
|
||||
disabled={disabled || isLoading}
|
||||
options={items.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
}))}
|
||||
placeholder={isLoading ? "正在加载占位资源..." : "请选择一个资源"}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
/>
|
||||
</label>
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
onClick={onOpenPicker}
|
||||
>
|
||||
{isLoading
|
||||
? "正在加载资源..."
|
||||
: selectedItem
|
||||
? `更换${label}`
|
||||
: `选择${label}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedItem ? (
|
||||
<div className="rounded-[20px] border border-[var(--border-soft)] bg-[var(--surface-muted)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<div className="h-20 w-16 overflow-hidden rounded-[14px] bg-[rgba(74,64,53,0.08)]">
|
||||
{selectedItem.previewUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt={`${selectedItem.name} 预览图`}
|
||||
className="h-full w-full object-cover"
|
||||
src={selectedItem.previewUri}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
||||
{selectedItem.name}
|
||||
@@ -67,17 +75,21 @@ export function ResourcePickerCard({
|
||||
{selectedItem.description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-[var(--accent-soft)] px-3 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--accent-ink)]">
|
||||
{selectedItem.isMock ? "mock" : "live"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 font-[var(--font-mono)] text-xs text-[var(--ink-faint)]">
|
||||
{selectedItem.previewUri}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selectedItem.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-[rgba(110,98,84,0.08)] px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm leading-6 text-[var(--ink-muted)]">
|
||||
选择后会在摘要卡中同步显示资源名称与提交 ID。
|
||||
通过资源管理器弹窗直接点选图片,选择后会在摘要卡中同步显示资源名称与提交 ID。
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
132
src/features/orders/components/resource-picker-modal.tsx
Normal file
132
src/features/orders/components/resource-picker-modal.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { CardEyebrow } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { ResourcePickerOption } from "@/features/orders/resource-picker-options";
|
||||
import type { LibraryType } from "@/lib/types/view-models";
|
||||
|
||||
type ResourcePickerModalProps = {
|
||||
items: ResourcePickerOption[];
|
||||
isLoading?: boolean;
|
||||
label: string;
|
||||
libraryType: LibraryType;
|
||||
open: boolean;
|
||||
selectedId: string;
|
||||
onClose: () => void;
|
||||
onSelect: (value: string) => void;
|
||||
};
|
||||
|
||||
const EYE_BROW_BY_LIBRARY_TYPE: Record<LibraryType, string> = {
|
||||
models: "Model library",
|
||||
scenes: "Scene library",
|
||||
garments: "Garment library",
|
||||
};
|
||||
|
||||
export function ResourcePickerModal({
|
||||
items,
|
||||
isLoading = false,
|
||||
label,
|
||||
libraryType,
|
||||
open,
|
||||
selectedId,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: ResourcePickerModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => (!nextOpen ? onClose() : null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<CardEyebrow>{EYE_BROW_BY_LIBRARY_TYPE[libraryType]}</CardEyebrow>
|
||||
<DialogTitle>{`选择${label}`}</DialogTitle>
|
||||
<DialogDescription>
|
||||
从当前资源库里直接点选图片。选中后会立即回填到提单工作台。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
{isLoading ? (
|
||||
<div className="rounded-[24px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-10 text-sm text-[var(--ink-muted)]">
|
||||
正在加载资源...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading ? (
|
||||
<div
|
||||
data-testid="resource-picker-masonry"
|
||||
className="columns-1 gap-5 md:columns-2 xl:columns-3 2xl:columns-4"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isSelected = item.id === selectedId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
aria-label={item.name}
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
className="mb-5 block w-full break-inside-avoid overflow-hidden rounded-[28px] border border-[var(--border-soft)] bg-[var(--surface)] text-left shadow-[var(--shadow-card)] transition hover:-translate-y-0.5 hover:shadow-[0_20px_50px_rgba(52,39,27,0.14)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]"
|
||||
onClick={() => {
|
||||
onSelect(item.id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isSelected
|
||||
? "border-b border-[rgba(110,127,82,0.22)] bg-[rgba(225,232,214,0.7)] p-4"
|
||||
: "border-b border-[rgba(74,64,53,0.08)] bg-[linear-gradient(160deg,rgba(255,249,240,0.95),rgba(230,217,199,0.82))] p-4"
|
||||
}
|
||||
>
|
||||
<div className="relative aspect-[4/5] overflow-hidden rounded-[18px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.88),rgba(218,197,170,0.48))]">
|
||||
{item.previewUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt={item.name}
|
||||
className="h-full w-full object-cover"
|
||||
src={item.previewUri}
|
||||
/>
|
||||
) : null}
|
||||
{isSelected ? (
|
||||
<div className="absolute right-3 top-3 rounded-full bg-[var(--accent-primary)] px-3 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--accent-ink)]">
|
||||
已选中
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="text-sm leading-6 text-[var(--ink-muted)]">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
{item.tags.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-[rgba(110,98,84,0.08)] px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -176,8 +176,8 @@ export function SubmitWorkbench() {
|
||||
(item) => item.id === formValues.garmentId,
|
||||
);
|
||||
|
||||
if (!selectedModel || !selectedScene || !selectedGarment) {
|
||||
setSubmitError("请先完成模特、场景和服装资源选择。");
|
||||
if (!selectedModel || !selectedGarment) {
|
||||
setSubmitError("请先完成模特和服装资源选择。");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -196,7 +196,9 @@ export function SubmitWorkbench() {
|
||||
service_mode: formValues.serviceMode,
|
||||
model_id: selectedModel.backendId,
|
||||
garment_asset_id: selectedGarment.backendId,
|
||||
scene_ref_asset_id: selectedScene.backendId,
|
||||
...(selectedScene
|
||||
? { scene_ref_asset_id: selectedScene.backendId }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -34,16 +34,16 @@ export function WorkflowTimeline({ viewModel }: WorkflowTimelineProps) {
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="space-y-4">
|
||||
{viewModel.hasMockAssets ? (
|
||||
<div className="rounded-[24px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-5 py-4 text-sm text-[#7a5323]">
|
||||
当前流程包含 mock 资产
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{viewModel.steps.length ? (
|
||||
<ol className="space-y-3">
|
||||
{viewModel.steps.map((step) => (
|
||||
{viewModel.steps.length ? (
|
||||
<ol className="space-y-3">
|
||||
{viewModel.steps.map((step) => (
|
||||
<li
|
||||
key={step.id}
|
||||
className={joinClasses(
|
||||
@@ -77,14 +77,33 @@ export function WorkflowTimeline({ viewModel }: WorkflowTimelineProps) {
|
||||
<StatusBadge variant="workflowStep" status={step.name} />
|
||||
</div>
|
||||
|
||||
{step.errorMessage ? (
|
||||
<p className="mt-4 rounded-[18px] bg-[rgba(255,255,255,0.5)] px-3 py-3 text-sm leading-6 text-[#7f3f38]">
|
||||
{step.errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{step.errorMessage ? (
|
||||
<p className="mt-4 rounded-[18px] bg-[rgba(255,255,255,0.5)] px-3 py-3 text-sm leading-6 text-[#7f3f38]">
|
||||
{step.errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{step.mockAssetUris.length ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
{step.previewUri ? (
|
||||
<div className="mt-4 rounded-[20px] border border-[var(--border-soft)] bg-[rgba(74,64,53,0.06)] p-3">
|
||||
{step.previewUri.startsWith("mock://") ? (
|
||||
<div className="flex aspect-[4/3] items-center justify-center rounded-[16px] border border-dashed border-[rgba(145,104,46,0.24)] bg-[rgba(202,164,97,0.1)] px-4 py-4 text-center text-xs leading-5 text-[#7a5323]">
|
||||
当前步骤只有 mock 预览
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center rounded-[16px] bg-[rgba(255,255,255,0.55)] p-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={step.previewUri}
|
||||
alt={`${step.label}预览`}
|
||||
className="block max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step.mockAssetUris.length ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
{step.mockAssetUris.map((uri) => (
|
||||
<code
|
||||
key={uri}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/lib/types/view-models";
|
||||
|
||||
type WorkflowAssetUriField =
|
||||
| "uri"
|
||||
| "asset_uri"
|
||||
| "candidate_uri"
|
||||
| "preview_uri"
|
||||
@@ -53,6 +54,7 @@ type WorkflowLookupSource =
|
||||
>;
|
||||
|
||||
const WORKFLOW_ASSET_URI_FIELDS = new Set<WorkflowAssetUriField>([
|
||||
"uri",
|
||||
"asset_uri",
|
||||
"candidate_uri",
|
||||
"preview_uri",
|
||||
@@ -92,8 +94,47 @@ function collectKnownAssetUris(
|
||||
return results;
|
||||
}
|
||||
|
||||
function findFirstKnownAssetUri(value: JsonValue | undefined): string | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const nested = findFirstKnownAssetUri(item);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
if (
|
||||
WORKFLOW_ASSET_URI_FIELDS.has(key as WorkflowAssetUriField) &&
|
||||
typeof nestedValue === "string"
|
||||
) {
|
||||
return nestedValue;
|
||||
}
|
||||
|
||||
const nested = findFirstKnownAssetUri(nestedValue);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function uniqueMockUris(...payloads: Array<JsonObject | null>): string[] {
|
||||
return [...new Set(payloads.flatMap((payload) => collectKnownAssetUris(payload)))];
|
||||
return [
|
||||
...new Set(
|
||||
payloads.flatMap((payload) =>
|
||||
collectKnownAssetUris(payload).filter((uri) => uri.startsWith("mock://")),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function adaptWorkflowStep(
|
||||
@@ -117,6 +158,7 @@ function adaptWorkflowStep(
|
||||
endedAt: step.ended_at,
|
||||
containsMockAssets: mockAssetUris.length > 0,
|
||||
mockAssetUris,
|
||||
previewUri: findFirstKnownAssetUri(step.output_json) ?? findFirstKnownAssetUri(step.input_json),
|
||||
isCurrent: currentStep === step.step_name,
|
||||
isFailed: step.step_status === "failed",
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ export type CreateOrderRequestDto = {
|
||||
model_id: number;
|
||||
pose_id?: number;
|
||||
garment_asset_id: number;
|
||||
scene_ref_asset_id: number;
|
||||
scene_ref_asset_id?: number;
|
||||
};
|
||||
|
||||
export type CreateOrderResponseDto = {
|
||||
@@ -94,7 +94,7 @@ export type OrderDetailResponseDto = {
|
||||
model_id: number;
|
||||
pose_id: number | null;
|
||||
garment_asset_id: number;
|
||||
scene_ref_asset_id: number;
|
||||
scene_ref_asset_id: number | null;
|
||||
final_asset_id: number | null;
|
||||
workflow_id: string | null;
|
||||
current_step: WorkflowStepName | null;
|
||||
|
||||
@@ -67,7 +67,7 @@ export type OrderDetailVM = {
|
||||
modelId: number;
|
||||
poseId: number | null;
|
||||
garmentAssetId: number;
|
||||
sceneRefAssetId: number;
|
||||
sceneRefAssetId: number | null;
|
||||
currentRevisionAssetId: number | null;
|
||||
currentRevisionVersion: number | null;
|
||||
latestRevisionAssetId: number | null;
|
||||
@@ -162,6 +162,7 @@ export type WorkflowStepVM = {
|
||||
endedAt: string | null;
|
||||
containsMockAssets: boolean;
|
||||
mockAssetUris: string[];
|
||||
previewUri: string | null;
|
||||
isCurrent: boolean;
|
||||
isFailed: boolean;
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export const createOrderSchema = z
|
||||
model_id: z.number().int().positive(),
|
||||
pose_id: z.number().int().positive().optional(),
|
||||
garment_asset_id: z.number().int().positive(),
|
||||
scene_ref_asset_id: z.number().int().positive(),
|
||||
scene_ref_asset_id: z.number().int().positive().optional(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const validServiceMode =
|
||||
|
||||
@@ -158,3 +158,63 @@ test("normalizes upstream validation errors from the backend", async () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts order creation payloads without a scene asset id", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
order_id: 88,
|
||||
workflow_id: "wf-88",
|
||||
status: "created",
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/orders", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
customer_level: "low",
|
||||
service_mode: "auto_basic",
|
||||
model_id: 101,
|
||||
garment_asset_id: 303,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 88,
|
||||
workflowId: "wf-88",
|
||||
status: "created",
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://backend.test/api/v1/orders",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
customer_level: "low",
|
||||
service_mode: "auto_basic",
|
||||
model_id: 101,
|
||||
garment_asset_id: 303,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { test, expect } from "vitest";
|
||||
|
||||
import { OrderDetail } from "@/features/orders/order-detail";
|
||||
@@ -97,3 +97,140 @@ test("renders the business-empty final-result state when no final asset exists",
|
||||
expect(screen.getByText("最终图暂未生成")).toBeInTheDocument();
|
||||
expect(screen.getByText("当前订单还没有可展示的最终结果。")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders prepared-model input snapshots when asset metadata contains resource inputs", () => {
|
||||
render(
|
||||
<OrderDetail
|
||||
viewModel={{
|
||||
...BASE_ORDER_DETAIL,
|
||||
assets: [
|
||||
{
|
||||
id: 66,
|
||||
orderId: 101,
|
||||
type: "prepared_model",
|
||||
stepName: "prepare_model",
|
||||
parentAssetId: null,
|
||||
rootAssetId: null,
|
||||
versionNo: 0,
|
||||
isCurrentVersion: false,
|
||||
stepLabel: "模型准备",
|
||||
label: "模型准备产物",
|
||||
uri: "https://images.example.com/prepared-model.jpg",
|
||||
metadata: {
|
||||
model_input: {
|
||||
resource_id: 3,
|
||||
resource_name: "主模特",
|
||||
original_url: "https://images.example.com/model.jpg",
|
||||
},
|
||||
garment_input: {
|
||||
resource_id: 4,
|
||||
resource_name: "上衣",
|
||||
original_url: "https://images.example.com/garment.jpg",
|
||||
},
|
||||
scene_input: {
|
||||
resource_id: 5,
|
||||
resource_name: "白棚",
|
||||
original_url: "https://images.example.com/scene.jpg",
|
||||
},
|
||||
},
|
||||
createdAt: "2026-03-27T00:08:00Z",
|
||||
isMock: false,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("输入素材")).toBeInTheDocument();
|
||||
expect(screen.getByText("模特图")).toBeInTheDocument();
|
||||
expect(screen.getByText("服装图")).toBeInTheDocument();
|
||||
expect(screen.getByText("场景图")).toBeInTheDocument();
|
||||
expect(screen.getByText("主模特")).toBeInTheDocument();
|
||||
expect(screen.getByText("上衣")).toBeInTheDocument();
|
||||
expect(screen.getByText("白棚")).toBeInTheDocument();
|
||||
expect(screen.getByAltText("模型准备产物预览")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders image previews for non-mock final and process assets", () => {
|
||||
render(
|
||||
<OrderDetail
|
||||
viewModel={{
|
||||
...BASE_ORDER_DETAIL,
|
||||
hasMockAssets: false,
|
||||
finalAsset: {
|
||||
...BASE_ORDER_DETAIL.finalAsset!,
|
||||
uri: "https://images.example.com/final.jpg",
|
||||
isMock: false,
|
||||
},
|
||||
assets: [
|
||||
{
|
||||
id: 78,
|
||||
orderId: 101,
|
||||
type: "tryon",
|
||||
stepName: "tryon",
|
||||
parentAssetId: null,
|
||||
rootAssetId: null,
|
||||
versionNo: 0,
|
||||
isCurrentVersion: false,
|
||||
stepLabel: "试穿生成",
|
||||
label: "试穿生成产物",
|
||||
uri: "https://images.example.com/tryon.jpg",
|
||||
metadata: null,
|
||||
createdAt: "2026-03-27T00:09:00Z",
|
||||
isMock: false,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByAltText("最终图预览")).toBeInTheDocument();
|
||||
expect(screen.getByAltText("试穿生成产物预览")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens an asset preview dialog for real images and resets zoom state", () => {
|
||||
render(
|
||||
<OrderDetail
|
||||
viewModel={{
|
||||
...BASE_ORDER_DETAIL,
|
||||
hasMockAssets: false,
|
||||
finalAsset: {
|
||||
...BASE_ORDER_DETAIL.finalAsset!,
|
||||
uri: "https://images.example.com/final.jpg",
|
||||
isMock: false,
|
||||
},
|
||||
assets: [
|
||||
{
|
||||
id: 78,
|
||||
orderId: 101,
|
||||
type: "scene",
|
||||
stepName: "scene",
|
||||
parentAssetId: null,
|
||||
rootAssetId: null,
|
||||
versionNo: 0,
|
||||
isCurrentVersion: false,
|
||||
stepLabel: "场景处理",
|
||||
label: "场景处理产物",
|
||||
uri: "https://images.example.com/scene.jpg",
|
||||
metadata: null,
|
||||
createdAt: "2026-03-27T00:09:00Z",
|
||||
isMock: false,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "查看场景处理产物大图" }));
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "场景处理产物预览" })).toBeInTheDocument();
|
||||
|
||||
const zoomableImage = screen.getByAltText("场景处理产物大图");
|
||||
fireEvent.wheel(zoomableImage, { deltaY: -120 });
|
||||
|
||||
expect(zoomableImage).toHaveStyle({ transform: "translate(0px, 0px) scale(1.12)" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "重置预览" }));
|
||||
|
||||
expect(zoomableImage).toHaveStyle({ transform: "translate(0px, 0px) scale(1)" });
|
||||
});
|
||||
|
||||
@@ -84,6 +84,19 @@ async function chooseSelectOption(label: string, optionName: string) {
|
||||
fireEvent.click(await screen.findByRole("option", { name: optionName }));
|
||||
}
|
||||
|
||||
async function chooseLibraryResource(label: string, resourceName: string) {
|
||||
fireEvent.click(screen.getByRole("button", { name: `选择${label}` }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: `选择${label}` });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: resourceName }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: `选择${label}` }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pushMock.mockReset();
|
||||
});
|
||||
@@ -97,7 +110,7 @@ test("forces low customers to use auto_basic and mid customers to use semi_pro",
|
||||
|
||||
render(<SubmitWorkbench />);
|
||||
|
||||
await screen.findByText("Ava / Studio");
|
||||
await screen.findByRole("button", { name: "选择模特资源" });
|
||||
|
||||
await chooseSelectOption("客户层级", "低客单 low");
|
||||
|
||||
@@ -142,12 +155,12 @@ test("preserves selected values when order submission fails", async () => {
|
||||
|
||||
render(<SubmitWorkbench />);
|
||||
|
||||
await screen.findByText("Ava / Studio");
|
||||
await screen.findByRole("button", { name: "选择模特资源" });
|
||||
|
||||
await chooseSelectOption("客户层级", "低客单 low");
|
||||
await chooseSelectOption("模特资源", "Ava / Studio");
|
||||
await chooseSelectOption("场景资源", "Loft Window");
|
||||
await chooseSelectOption("服装资源", "Structured Coat 01");
|
||||
await chooseLibraryResource("模特资源", "Ava / Studio");
|
||||
await chooseLibraryResource("场景资源", "Loft Window");
|
||||
await chooseLibraryResource("服装资源", "Structured Coat 01");
|
||||
|
||||
const summaryCard = screen.getByRole("region", { name: "提单摘要" });
|
||||
expect(within(summaryCard).getByText("Ava / Studio")).toBeInTheDocument();
|
||||
@@ -165,15 +178,9 @@ test("preserves selected values when order submission fails", async () => {
|
||||
expect(screen.getByRole("combobox", { name: "服务模式" })).toHaveTextContent(
|
||||
"自动基础处理 auto_basic",
|
||||
);
|
||||
expect(screen.getByRole("combobox", { name: "模特资源" })).toHaveTextContent(
|
||||
"Ava / Studio",
|
||||
);
|
||||
expect(screen.getByRole("combobox", { name: "场景资源" })).toHaveTextContent(
|
||||
"Loft Window",
|
||||
);
|
||||
expect(screen.getByRole("combobox", { name: "服装资源" })).toHaveTextContent(
|
||||
"Structured Coat 01",
|
||||
);
|
||||
expect(screen.getAllByText("Ava / Studio").length).toBeGreaterThan(1);
|
||||
expect(screen.getAllByText("Loft Window").length).toBeGreaterThan(1);
|
||||
expect(screen.getAllByText("Structured Coat 01").length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test("submits the selected resources, surfaces returned ids, and redirects to the order detail page", async () => {
|
||||
@@ -198,12 +205,12 @@ test("submits the selected resources, surfaces returned ids, and redirects to th
|
||||
|
||||
render(<SubmitWorkbench />);
|
||||
|
||||
await screen.findByText("Ava / Studio");
|
||||
await screen.findByRole("button", { name: "选择模特资源" });
|
||||
|
||||
await chooseSelectOption("客户层级", "低客单 low");
|
||||
await chooseSelectOption("模特资源", "Ava / Studio");
|
||||
await chooseSelectOption("场景资源", "Loft Window");
|
||||
await chooseSelectOption("服装资源", "Structured Coat 01");
|
||||
await chooseLibraryResource("模特资源", "Ava / Studio");
|
||||
await chooseLibraryResource("场景资源", "Loft Window");
|
||||
await chooseLibraryResource("服装资源", "Structured Coat 01");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "提交订单" }));
|
||||
|
||||
@@ -232,3 +239,78 @@ test("submits the selected resources, surfaces returned ids, and redirects to th
|
||||
expect(pushMock).toHaveBeenCalledWith("/orders/77");
|
||||
});
|
||||
});
|
||||
|
||||
test("allows submitting without selecting a scene resource", async () => {
|
||||
const fetchMock = createFetchMock({
|
||||
orderResponse: new Response(
|
||||
JSON.stringify({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 88,
|
||||
workflowId: "wf-88",
|
||||
status: "created",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<SubmitWorkbench />);
|
||||
|
||||
await screen.findByRole("button", { name: "选择模特资源" });
|
||||
|
||||
await chooseSelectOption("客户层级", "低客单 low");
|
||||
await chooseLibraryResource("模特资源", "Ava / Studio");
|
||||
await chooseLibraryResource("服装资源", "Structured Coat 01");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "提交订单" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/orders",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
customer_level: "low",
|
||||
service_mode: "auto_basic",
|
||||
model_id: 101,
|
||||
garment_asset_id: 303,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("opens a shared resource manager modal and picks a resource card for each library type", async () => {
|
||||
vi.stubGlobal("fetch", createFetchMock());
|
||||
|
||||
render(<SubmitWorkbench />);
|
||||
|
||||
await screen.findByRole("button", { name: "选择模特资源" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "选择模特资源" }));
|
||||
|
||||
const modelDialog = await screen.findByRole("dialog", { name: "选择模特资源" });
|
||||
expect(within(modelDialog).getByTestId("resource-picker-masonry").className).toContain(
|
||||
"columns-1",
|
||||
);
|
||||
expect(
|
||||
within(modelDialog).getByRole("button", { name: "Ava / Studio" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(modelDialog).getByRole("button", { name: "Ava / Studio" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "选择模特资源" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const summaryCard = screen.getByRole("region", { name: "提单摘要" });
|
||||
expect(within(summaryCard).getByText("Ava / Studio")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ const BASE_WORKFLOW_DETAIL: WorkflowDetailVM = {
|
||||
endedAt: "2026-03-27T00:07:00Z",
|
||||
containsMockAssets: false,
|
||||
mockAssetUris: [],
|
||||
previewUri: null,
|
||||
isCurrent: false,
|
||||
isFailed: true,
|
||||
},
|
||||
@@ -64,6 +65,7 @@ const BASE_WORKFLOW_DETAIL: WorkflowDetailVM = {
|
||||
endedAt: null,
|
||||
containsMockAssets: true,
|
||||
mockAssetUris: ["mock://fusion-preview"],
|
||||
previewUri: "mock://fusion-preview",
|
||||
isCurrent: true,
|
||||
isFailed: false,
|
||||
},
|
||||
@@ -82,3 +84,41 @@ test("highlights failed steps and mock asset hints in the workflow timeline", ()
|
||||
expect(screen.getByText("Temporal activity timed out.")).toBeInTheDocument();
|
||||
expect(screen.getByText("当前流程包含 mock 资产")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders image previews for real workflow step outputs", () => {
|
||||
render(
|
||||
<WorkflowDetail
|
||||
viewModel={{
|
||||
...BASE_WORKFLOW_DETAIL,
|
||||
hasMockAssets: false,
|
||||
steps: [
|
||||
{
|
||||
id: 21,
|
||||
workflowRunId: 9001,
|
||||
name: "scene",
|
||||
label: "场景处理",
|
||||
status: "succeeded",
|
||||
statusMeta: {
|
||||
label: "已完成",
|
||||
tone: "success",
|
||||
},
|
||||
input: null,
|
||||
output: {
|
||||
uri: "https://images.example.com/orders/101/scene/generated.jpg",
|
||||
},
|
||||
errorMessage: null,
|
||||
startedAt: "2026-03-27T00:06:00Z",
|
||||
endedAt: "2026-03-27T00:07:00Z",
|
||||
containsMockAssets: false,
|
||||
mockAssetUris: [],
|
||||
previewUri: "https://images.example.com/orders/101/scene/generated.jpg",
|
||||
isCurrent: false,
|
||||
isFailed: false,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByAltText("场景处理预览")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -69,6 +69,33 @@ test("tags nested mock asset uris found in workflow step payloads", () => {
|
||||
expect(viewModel.failureCount).toBe(1);
|
||||
});
|
||||
|
||||
test("extracts a step preview uri from standard output uri fields", () => {
|
||||
const viewModel = adaptWorkflowDetail({
|
||||
...WORKFLOW_BASE,
|
||||
current_step: "scene",
|
||||
steps: [
|
||||
{
|
||||
id: 2,
|
||||
workflow_run_id: 9001,
|
||||
step_name: "scene",
|
||||
step_status: "succeeded",
|
||||
input_json: null,
|
||||
output_json: {
|
||||
uri: "https://images.example.com/orders/101/scene/generated.jpg",
|
||||
},
|
||||
error_message: null,
|
||||
started_at: "2026-03-27T00:08:00Z",
|
||||
ended_at: "2026-03-27T00:09:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(viewModel.steps[0].previewUri).toBe(
|
||||
"https://images.example.com/orders/101/scene/generated.jpg",
|
||||
);
|
||||
expect(viewModel.steps[0].containsMockAssets).toBe(false);
|
||||
});
|
||||
|
||||
test("maps workflow lookup status and current step labels", () => {
|
||||
const viewModel = adaptWorkflowLookupItem(WORKFLOW_BASE);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user