diff --git a/package-lock.json b/package-lock.json index 6376073..70ebb06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c63c899..4bee186 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..f8907ec --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -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, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + + + +)); + +DialogContent.displayName = DialogPrimitive.Content.displayName; + +function DialogHeader({ + className, + ...props +}: ComponentPropsWithoutRef<"div">) { + return
; +} + +function DialogBody({ + className, + ...props +}: ComponentPropsWithoutRef<"div">) { + return
; +} + +const DialogTitle = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +}; diff --git a/src/features/orders/components/create-order-form.tsx b/src/features/orders/components/create-order-form.tsx index 40eec2f..986db16 100644 --- a/src/features/orders/components/create-order-form.tsx +++ b/src/features/orders/components/create-order-form.tsx @@ -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(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 ( -
{ - event.preventDefault(); - onSubmit(); - }} - > -
- - - Business inputs - 订单参数 - - 先确定客户层级,再由表单自动约束允许的服务模式。 - - - - - + + -
- - - +
+ setActivePicker("models")} + /> + setActivePicker("scenes")} + /> + setActivePicker("garments")} + /> +
+ +
+ +

+ 提交只负责创建订单,不承载审核或流程追踪行为。 +

+
-
- -

- 提交只负责创建订单,不承载审核或流程追踪行为。 -

-
-
+ + - - + {activePicker && activePickerConfig ? ( + setActivePicker(null)} + onSelect={activePickerConfig.onSelect} + /> + ) : null} + ); } diff --git a/src/features/orders/components/order-assets-panel.tsx b/src/features/orders/components/order-assets-panel.tsx index b1b46f5..49d9a40 100644 --- a/src/features/orders/components/order-assets-panel.tsx +++ b/src/features/orders/components/order-assets-panel.tsx @@ -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 { + 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; +} + +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 ( +
+
+

输入素材

+

展示这一步使用的上游素材,方便快速核对输入来源。

+
+
+ {inputSnapshots.map((snapshot) => ( +
+
+ {snapshot.url ? ( + + ) : ( +
+ 暂无预览图 +
+ )} +
+
+

{snapshot.label}

+

{snapshot.name}

+
+
+ ))} +
+
+ ); +} + +function renderAssetPreview(asset: AssetViewModel, onOpenPreview: (payload: PreviewPayload) => void) { + return ( +
+ {asset.isMock ? ( +
+

Mock 预览

+

当前资产没有真实图片内容

+
+ ) : ( + + )} +
+ ); +} + +function renderAssetCard(asset: AssetViewModel, onOpenPreview: (payload: PreviewPayload) => void) { return (
) : null}
- + {renderAssetPreview(asset, onOpenPreview)} + {/* {asset.uri} - + */} + {renderInputSnapshots(asset, onOpenPreview)}
); } export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) { + const [preview, setPreview] = useState(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) { + 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) { + 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) { + if (!dragging || !dragOrigin || scale <= 1) { + return; + } + setOffset({ + x: event.clientX - dragOrigin.x, + y: event.clientY - dragOrigin.y, + }); + } + + function handlePointerUp(event: ReactPointerEvent) { + 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) {

{viewModel.finalAsset ? ( - renderAssetCard(viewModel.finalAsset) + renderAssetCard(viewModel.finalAsset, openPreview) ) : ( {viewModel.assets.length ? ( -
{viewModel.assets.map(renderAssetCard)}
+
+ {viewModel.assets.map((asset) => renderAssetCard(asset, openPreview))} +
) : ( )} + + + + {preview ? ( + <> + + {preview.title} + {preview.description} + + +
+ 缩放 {Math.round(scale * 100)}% + +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`${preview.title.replace(/预览$/, 1 ? (dragging ? "grabbing" : "grab") : "zoom-in", + }} + onWheel={handleWheel} + onDoubleClick={resetPreviewState} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + draggable={false} + /> +
+
+ + ) : null} +
+
); diff --git a/src/features/orders/components/resource-picker-card.tsx b/src/features/orders/components/resource-picker-card.tsx index f00fdf6..cdb312f 100644 --- a/src/features/orders/components/resource-picker-card.tsx +++ b/src/features/orders/components/resource-picker-card.tsx @@ -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 ( - Mock backed selector + Resource manager {title} {description} -