+ {step.previewUri ? (
+
+ {step.previewUri.startsWith("mock://") ? (
+
+ 当前步骤只有 mock 预览
+
+ ) : (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+ )}
+
+ ) : null}
+
+ {step.mockAssetUris.length ? (
+
{step.mockAssetUris.map((uri) => (
;
const WORKFLOW_ASSET_URI_FIELDS = new Set([
+ "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): 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",
};
diff --git a/src/lib/types/backend.ts b/src/lib/types/backend.ts
index 3f0be45..a960a9d 100644
--- a/src/lib/types/backend.ts
+++ b/src/lib/types/backend.ts
@@ -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;
diff --git a/src/lib/types/view-models.ts b/src/lib/types/view-models.ts
index ccc5628..382e756 100644
--- a/src/lib/types/view-models.ts
+++ b/src/lib/types/view-models.ts
@@ -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;
};
diff --git a/src/lib/validation/create-order.ts b/src/lib/validation/create-order.ts
index 3820fd6..7861cfa 100644
--- a/src/lib/validation/create-order.ts
+++ b/src/lib/validation/create-order.ts
@@ -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 =
diff --git a/tests/app/api/orders-create.route.test.ts b/tests/app/api/orders-create.route.test.ts
index 01c135a..8fb429b 100644
--- a/tests/app/api/orders-create.route.test.ts
+++ b/tests/app/api/orders-create.route.test.ts
@@ -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,
+ }),
+ }),
+ );
+});
diff --git a/tests/features/orders/order-detail.test.tsx b/tests/features/orders/order-detail.test.tsx
index 46cd1bf..7455c80 100644
--- a/tests/features/orders/order-detail.test.tsx
+++ b/tests/features/orders/order-detail.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(screen.getByAltText("最终图预览")).toBeInTheDocument();
+ expect(screen.getByAltText("试穿生成产物预览")).toBeInTheDocument();
+});
+
+test("opens an asset preview dialog for real images and resets zoom state", () => {
+ render(
+ ,
+ );
+
+ 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)" });
+});
diff --git a/tests/features/orders/submit-workbench.test.tsx b/tests/features/orders/submit-workbench.test.tsx
index ea4425f..cc0f4d6 100644
--- a/tests/features/orders/submit-workbench.test.tsx
+++ b/tests/features/orders/submit-workbench.test.tsx
@@ -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();
- 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();
- 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();
- 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();
+
+ 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();
+
+ 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();
+});
diff --git a/tests/features/workflows/workflow-detail.test.tsx b/tests/features/workflows/workflow-detail.test.tsx
index a56a9ba..b209c24 100644
--- a/tests/features/workflows/workflow-detail.test.tsx
+++ b/tests/features/workflows/workflow-detail.test.tsx
@@ -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(
+ ,
+ );
+
+ expect(screen.getByAltText("场景处理预览")).toBeInTheDocument();
+});
diff --git a/tests/lib/adapters/workflows.test.ts b/tests/lib/adapters/workflows.test.ts
index a008f47..7552818 100644
--- a/tests/lib/adapters/workflows.test.ts
+++ b/tests/lib/adapters/workflows.test.ts
@@ -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);