feat: connect resource library workflows

This commit is contained in:
afei A
2026-03-28 13:42:22 +08:00
parent c604e6ace1
commit 162d3e12d2
42 changed files with 4709 additions and 305 deletions

View File

@@ -0,0 +1,71 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, expect, test, vi } from "vitest";
import { LibraryEditModal } from "@/features/libraries/components/library-edit-modal";
const { updateLibraryResourceMock } = vi.hoisted(() => ({
updateLibraryResourceMock: vi.fn(),
}));
vi.mock("@/features/libraries/manage-resource", () => ({
updateLibraryResource: updateLibraryResourceMock,
}));
beforeEach(() => {
updateLibraryResourceMock.mockReset();
updateLibraryResourceMock.mockResolvedValue({
id: "12",
backendId: 12,
libraryType: "models",
name: "Ava / Studio",
description: "中性棚拍模特占位数据,用于提交页联调。",
previewUri: "mock://libraries/models/ava",
tags: ["女装", "半身", "mock"],
files: [
{
id: 101,
role: "thumbnail",
url: "mock://libraries/models/ava",
},
],
isMock: false,
});
});
test("uses backendId when saving resource metadata edits", async () => {
render(
<LibraryEditModal
item={{
id: "local-model-12",
backendId: 12,
libraryType: "models",
name: "Ava / Studio",
description: "中性棚拍模特占位数据,用于提交页联调。",
previewUri: "mock://libraries/models/ava",
tags: ["女装", "半身", "mock"],
files: [
{
id: 101,
role: "thumbnail",
url: "mock://libraries/models/ava",
},
],
isMock: false,
}}
libraryType="models"
open
onClose={() => {}}
onSaved={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "保存修改" }));
await waitFor(() => {
expect(updateLibraryResourceMock).toHaveBeenCalledWith(
expect.objectContaining({
resourceId: "12",
}),
);
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
import { LibraryPage } from "@/features/libraries/library-page";
@@ -6,19 +6,112 @@ import type { LibraryItemVM } from "@/lib/types/view-models";
const MODEL_ITEMS: LibraryItemVM[] = [
{
id: "model-ava",
id: "12",
libraryType: "models",
name: "Ava / Studio",
description: "中性棚拍模特占位数据,用于提交页联调。",
previewUri: "mock://libraries/models/ava",
tags: ["女装", "半身", "mock"],
isMock: true,
isMock: false,
backendId: 12,
files: [
{
id: 101,
role: "thumbnail",
url: "mock://libraries/models/ava",
},
{
id: 102,
role: "gallery",
url: "mock://libraries/models/ava-side",
},
],
},
];
test("states that the resource library is still backed by mock data", () => {
test("surfaces the current resource library data-source message", () => {
render(<LibraryPage libraryType="models" items={MODEL_ITEMS} />);
expect(screen.getByText("当前资源库仍使用 mock 数据")).toBeInTheDocument();
expect(screen.getByText("资源库当前显示真实后端数据")).toBeInTheDocument();
expect(screen.getByText("Ava / Studio")).toBeInTheDocument();
});
test("renders in-page tabs plus an upload slot ahead of the masonry cards", () => {
render(<LibraryPage libraryType="models" items={MODEL_ITEMS} />);
expect(
screen.getByRole("navigation", { name: "Library sections" }),
).toBeInTheDocument();
expect(screen.getByRole("link", { name: "模特" })).toHaveAttribute(
"aria-current",
"page",
);
expect(screen.getByRole("link", { name: "场景" })).toHaveAttribute(
"href",
"/libraries/scenes",
);
expect(screen.getByRole("link", { name: "服装" })).toHaveAttribute(
"href",
"/libraries/garments",
);
expect(screen.getByText("上传模特资源")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "打开上传弹窗" })).toBeInTheDocument();
expect(screen.getByTestId("library-masonry").className).toContain("columns-1");
expect(screen.getByTestId("library-masonry").className).toContain("2xl:columns-4");
expect(screen.getByText("Ava / Studio")).toBeInTheDocument();
});
test("renders direct management actions inside each resource card", () => {
render(<LibraryPage libraryType="models" items={MODEL_ITEMS} />);
expect(screen.getByRole("img", { name: "Ava / Studio 预览图" })).toHaveAttribute(
"src",
"mock://libraries/models/ava",
);
expect(screen.getByRole("button", { name: "编辑" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "删除" })).toBeInTheDocument();
});
test("opens a model-specific upload dialog from the first masonry card", () => {
render(<LibraryPage libraryType="models" items={MODEL_ITEMS} />);
fireEvent.click(screen.getByRole("button", { name: "打开上传弹窗" }));
expect(screen.getByRole("dialog", { name: "上传模特资源" })).toBeInTheDocument();
expect(screen.getByLabelText("资源名称")).toBeInTheDocument();
expect(screen.getByLabelText("模特性别")).toBeInTheDocument();
expect(screen.getByLabelText("年龄段")).toBeInTheDocument();
expect(screen.getByLabelText("原图")).toBeInTheDocument();
expect(screen.getByLabelText("附加图库")).toBeInTheDocument();
expect(screen.getByText("缩略图将根据原图自动生成。")).toBeInTheDocument();
});
test("opens an edit dialog that lets operators inspect multiple images and choose a cover", () => {
render(<LibraryPage libraryType="models" items={MODEL_ITEMS} />);
fireEvent.click(screen.getByRole("button", { name: "编辑" }));
expect(screen.getByRole("dialog", { name: "编辑模特资源" })).toBeInTheDocument();
expect(screen.getByDisplayValue("Ava / Studio")).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Ava / Studio 图片 1" })).toHaveAttribute(
"src",
"mock://libraries/models/ava",
);
expect(screen.getByRole("img", { name: "Ava / Studio 图片 2" })).toHaveAttribute(
"src",
"mock://libraries/models/ava-side",
);
expect(screen.getByRole("button", { name: "设为封面 2" })).toBeInTheDocument();
});
test("opens an alert dialog before archiving a resource", () => {
render(<LibraryPage libraryType="models" items={MODEL_ITEMS} />);
fireEvent.click(screen.getByRole("button", { name: "删除" }));
expect(
screen.getByRole("alertdialog", { name: "确认删除模特资源" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "确认删除" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "取消" })).toBeInTheDocument();
});

View File

@@ -0,0 +1,96 @@
import { expect, test, vi } from "vitest";
import {
archiveLibraryResource,
updateLibraryResource,
} from "@/features/libraries/manage-resource";
test("updates resource metadata and cover through the library item route", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
data: {
item: {
id: "12",
libraryType: "models",
name: "Ava Studio Updated",
description: "新的描述",
previewUri: "https://images.marcusd.me/library/models/ava/gallery-1.png",
originalUri: "https://images.marcusd.me/library/models/ava/original.png",
tags: ["女装", "更新"],
files: [
{
id: 101,
role: "thumbnail",
url: "https://images.marcusd.me/library/models/ava/thumb.png",
},
{
id: 102,
role: "gallery",
url: "https://images.marcusd.me/library/models/ava/gallery-1.png",
},
],
isMock: false,
backendId: 12,
poseId: null,
},
},
}),
{ status: 200 },
),
);
const updated = await updateLibraryResource({
fetchFn: fetchMock,
libraryType: "models",
resourceId: "12",
values: {
name: "Ava Studio Updated",
description: "新的描述",
tags: ["女装", "更新"],
coverFileId: 102,
},
});
expect(updated).toMatchObject({
id: "12",
name: "Ava Studio Updated",
previewUri: "https://images.marcusd.me/library/models/ava/gallery-1.png",
});
expect(fetchMock).toHaveBeenCalledWith("/api/libraries/models/12", {
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
name: "Ava Studio Updated",
description: "新的描述",
tags: ["女装", "更新"],
coverFileId: 102,
}),
});
});
test("archives a resource through the library item route", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
data: {
id: "12",
},
}),
{ status: 200 },
),
);
const archivedId = await archiveLibraryResource({
fetchFn: fetchMock,
libraryType: "models",
resourceId: "12",
});
expect(archivedId).toBe("12");
expect(fetchMock).toHaveBeenCalledWith("/api/libraries/models/12", {
method: "DELETE",
});
});

View File

@@ -0,0 +1,173 @@
import { expect, test, vi } from "vitest";
import { uploadLibraryResource } from "@/features/libraries/upload-resource";
test("uploads original thumbnail and gallery files before creating the resource record", async () => {
const originalFile = new File(["original"], "ava-original.png", {
type: "image/png",
});
const thumbnailFile = new File(["thumbnail"], "ava-thumb.png", {
type: "image/png",
});
const galleryFile = new File(["gallery"], "ava-gallery.png", {
type: "image/png",
});
const thumbnailGenerator = vi.fn().mockResolvedValue(thumbnailFile);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
method: "PUT",
uploadUrl: "https://upload.test/original",
headers: { "content-type": "image/png" },
storageKey: "library/models/2026/ava-original.png",
publicUrl: "https://images.test/library/models/2026/ava-original.png",
},
}),
),
)
.mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
method: "PUT",
uploadUrl: "https://upload.test/thumbnail",
headers: { "content-type": "image/png" },
storageKey: "library/models/2026/ava-thumb.png",
publicUrl: "https://images.test/library/models/2026/ava-thumb.png",
},
}),
),
)
.mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
method: "PUT",
uploadUrl: "https://upload.test/gallery",
headers: { "content-type": "image/png" },
storageKey: "library/models/2026/ava-gallery.png",
publicUrl: "https://images.test/library/models/2026/ava-gallery.png",
},
}),
),
)
.mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
item: {
id: "12",
libraryType: "models",
name: "Ava Studio",
description: "棚拍女模特",
previewUri: "https://images.test/library/models/2026/ava-thumb.png",
tags: ["女装", "棚拍"],
isMock: false,
backendId: 12,
},
},
}),
{ status: 201 },
),
);
const created = await uploadLibraryResource({
fetchFn: fetchMock,
thumbnailGenerator,
libraryType: "models",
values: {
name: "Ava Studio",
description: "棚拍女模特",
tags: ["女装", "棚拍"],
gender: "female",
ageGroup: "adult",
environment: "",
category: "",
},
files: {
original: originalFile,
gallery: [galleryFile],
},
});
expect(created).toEqual({
id: "12",
libraryType: "models",
name: "Ava Studio",
description: "棚拍女模特",
previewUri: "https://images.test/library/models/2026/ava-thumb.png",
tags: ["女装", "棚拍"],
isMock: false,
backendId: 12,
});
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"/api/libraries/uploads/presign",
expect.objectContaining({
method: "POST",
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://upload.test/original",
expect.objectContaining({
method: "PUT",
body: originalFile,
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
7,
"/api/libraries/models",
expect.objectContaining({
method: "POST",
}),
);
expect(fetchMock).toHaveBeenLastCalledWith(
"/api/libraries/models",
expect.objectContaining({
body: JSON.stringify({
name: "Ava Studio",
description: "棚拍女模特",
tags: ["女装", "棚拍"],
gender: "female",
ageGroup: "adult",
environment: undefined,
category: undefined,
files: [
{
fileRole: "original",
storageKey: "library/models/2026/ava-original.png",
publicUrl: "https://images.test/library/models/2026/ava-original.png",
mimeType: "image/png",
sizeBytes: 8,
sortOrder: 0,
},
{
fileRole: "thumbnail",
storageKey: "library/models/2026/ava-thumb.png",
publicUrl: "https://images.test/library/models/2026/ava-thumb.png",
mimeType: "image/png",
sizeBytes: 9,
sortOrder: 0,
},
{
fileRole: "gallery",
storageKey: "library/models/2026/ava-gallery.png",
publicUrl: "https://images.test/library/models/2026/ava-gallery.png",
mimeType: "image/png",
sizeBytes: 7,
sortOrder: 0,
},
],
}),
}),
);
expect(thumbnailGenerator).toHaveBeenCalledWith(originalFile);
});

View File

@@ -0,0 +1,48 @@
import { expect, test } from "vitest";
import { mapModelOptions, mapResourceOptions } from "@/features/orders/resource-picker-options";
import type { LibraryItemVM } from "@/lib/types/view-models";
test("prefers backend-backed ids for models when the resource library provides them even without pose", () => {
const items: LibraryItemVM[] = [
{
id: "12",
libraryType: "models",
name: "Ava Studio",
description: "棚拍女模特",
previewUri: "https://images.marcusd.me/library/models/ava/thumb.png",
tags: ["女装"],
isMock: false,
backendId: 12,
},
];
expect(mapModelOptions(items)).toEqual([
expect.objectContaining({
id: "12",
backendId: 12,
}),
]);
});
test("prefers backend-backed ids for scene and garment resources when provided", () => {
const items: LibraryItemVM[] = [
{
id: "21",
libraryType: "scenes",
name: "Loft Window",
description: "暖调室内场景",
previewUri: "https://images.marcusd.me/library/scenes/loft/thumb.png",
tags: ["室内"],
isMock: false,
backendId: 21,
},
];
expect(mapResourceOptions(items)).toEqual([
expect.objectContaining({
id: "21",
backendId: 21,
}),
]);
});

View File

@@ -79,6 +79,11 @@ function createFetchMock({
});
}
async function chooseSelectOption(label: string, optionName: string) {
fireEvent.click(screen.getByRole("combobox", { name: label }));
fireEvent.click(await screen.findByRole("option", { name: optionName }));
}
beforeEach(() => {
pushMock.mockReset();
});
@@ -92,26 +97,30 @@ test("forces low customers to use auto_basic and mid customers to use semi_pro",
render(<SubmitWorkbench />);
const customerLevelSelect = await screen.findByLabelText("客户层级");
const serviceModeSelect = screen.getByLabelText("服务模式");
await screen.findByText("Ava / Studio");
fireEvent.change(customerLevelSelect, {
target: { value: "low" },
});
await chooseSelectOption("客户层级", "低客单 low");
expect(serviceModeSelect).toHaveValue("auto_basic");
expect(screen.getByRole("combobox", { name: "服务模式" })).toHaveTextContent(
"自动基础处理 auto_basic",
);
fireEvent.click(screen.getByRole("combobox", { name: "服务模式" }));
expect(
screen.getByRole("option", { name: "半人工专业处理 semi_pro" }),
).toBeDisabled();
fireEvent.change(customerLevelSelect, {
target: { value: "mid" },
).toHaveAttribute("data-disabled");
fireEvent.keyDown(document.activeElement ?? document.body, {
key: "Escape",
});
expect(serviceModeSelect).toHaveValue("semi_pro");
await chooseSelectOption("客户层级", "中客单 mid");
expect(screen.getByRole("combobox", { name: "服务模式" })).toHaveTextContent(
"半人工专业处理 semi_pro",
);
fireEvent.click(screen.getByRole("combobox", { name: "服务模式" }));
expect(
screen.getByRole("option", { name: "自动基础处理 auto_basic" }),
).toBeDisabled();
).toHaveAttribute("data-disabled");
});
test("preserves selected values when order submission fails", async () => {
@@ -135,18 +144,10 @@ test("preserves selected values when order submission fails", async () => {
await screen.findByText("Ava / Studio");
fireEvent.change(screen.getByLabelText("客户层级"), {
target: { value: "low" },
});
fireEvent.change(screen.getByLabelText("模特资源"), {
target: { value: "model-ava" },
});
fireEvent.change(screen.getByLabelText("场景资源"), {
target: { value: "scene-loft" },
});
fireEvent.change(screen.getByLabelText("服装资源"), {
target: { value: "garment-coat-01" },
});
await chooseSelectOption("客户层级", "低客单 low");
await chooseSelectOption("模特资源", "Ava / Studio");
await chooseSelectOption("场景资源", "Loft Window");
await chooseSelectOption("服装资源", "Structured Coat 01");
const summaryCard = screen.getByRole("region", { name: "提单摘要" });
expect(within(summaryCard).getByText("Ava / Studio")).toBeInTheDocument();
@@ -158,11 +159,21 @@ test("preserves selected values when order submission fails", async () => {
expect(
await screen.findByText("后端暂时不可用,请稍后重试。"),
).toBeInTheDocument();
expect(screen.getByLabelText("客户层级")).toHaveValue("low");
expect(screen.getByLabelText("服务模式")).toHaveValue("auto_basic");
expect(screen.getByLabelText("模特资源")).toHaveValue("model-ava");
expect(screen.getByLabelText("场景资源")).toHaveValue("scene-loft");
expect(screen.getByLabelText("服装资源")).toHaveValue("garment-coat-01");
expect(screen.getByRole("combobox", { name: "客户层级" })).toHaveTextContent(
"低客单 low",
);
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",
);
});
test("submits the selected resources, surfaces returned ids, and redirects to the order detail page", async () => {
@@ -189,18 +200,10 @@ test("submits the selected resources, surfaces returned ids, and redirects to th
await screen.findByText("Ava / Studio");
fireEvent.change(screen.getByLabelText("客户层级"), {
target: { value: "low" },
});
fireEvent.change(screen.getByLabelText("模特资源"), {
target: { value: "model-ava" },
});
fireEvent.change(screen.getByLabelText("场景资源"), {
target: { value: "scene-loft" },
});
fireEvent.change(screen.getByLabelText("服装资源"), {
target: { value: "garment-coat-01" },
});
await chooseSelectOption("客户层级", "低客单 low");
await chooseSelectOption("模特资源", "Ava / Studio");
await chooseSelectOption("场景资源", "Loft Window");
await chooseSelectOption("服装资源", "Structured Coat 01");
fireEvent.click(screen.getByRole("button", { name: "提交订单" }));
@@ -213,7 +216,6 @@ test("submits the selected resources, surfaces returned ids, and redirects to th
customer_level: "low",
service_mode: "auto_basic",
model_id: 101,
pose_id: 202,
garment_asset_id: 303,
scene_ref_asset_id: 404,
}),