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,94 @@
import { adaptLibraryItem, type BackendLibraryResource } from "@/lib/adapters/libraries";
import { backendRequest } from "@/lib/http/backend-client";
import {
RouteError,
jsonSuccess,
parseJsonBody,
withErrorHandling,
} from "@/lib/http/response";
import type { LibraryType } from "@/lib/types/view-models";
import { parseUpdateLibraryResourcePayload } from "@/lib/validation/library-resource";
type RouteContext = {
params: Promise<{
libraryType: string;
resourceId: string;
}>;
};
const BACKEND_LIBRARY_TYPE_MAP: Record<LibraryType, "model" | "scene" | "garment"> = {
models: "model",
scenes: "scene",
garments: "garment",
};
function isLibraryType(value: string): value is LibraryType {
return Object.hasOwn(BACKEND_LIBRARY_TYPE_MAP, value);
}
function parseResourceId(value: string) {
const resourceId = Number(value);
if (!Number.isInteger(resourceId) || resourceId <= 0) {
throw new RouteError(400, "VALIDATION_ERROR", "资源 ID 不合法。");
}
return resourceId;
}
export async function PATCH(request: Request, context: RouteContext) {
return withErrorHandling(async () => {
const { libraryType, resourceId: rawResourceId } = await context.params;
if (!isLibraryType(libraryType)) {
throw new RouteError(404, "NOT_FOUND", "不支持的资源库类型。");
}
const resourceId = parseResourceId(rawResourceId);
const rawPayload = await parseJsonBody(request);
const payload = parseUpdateLibraryResourcePayload(rawPayload);
const response = await backendRequest<BackendLibraryResource>(
`/library/resources/${resourceId}`,
{
method: "PATCH",
body: JSON.stringify(payload),
},
);
return jsonSuccess(
{
item: adaptLibraryItem(response.data),
},
{
status: response.status,
message: "资源更新成功。",
},
);
});
}
export async function DELETE(_request: Request, context: RouteContext) {
return withErrorHandling(async () => {
const { libraryType, resourceId: rawResourceId } = await context.params;
if (!isLibraryType(libraryType)) {
throw new RouteError(404, "NOT_FOUND", "不支持的资源库类型。");
}
const resourceId = parseResourceId(rawResourceId);
const response = await backendRequest<{ id: number }>(
`/library/resources/${resourceId}`,
{
method: "DELETE",
},
);
return jsonSuccess(
{
id: String(response.data.id),
},
{
status: response.status,
message: "资源已移入归档。",
},
);
});
}

View File

@@ -1,10 +1,17 @@
import {
GARMENT_LIBRARY_FIXTURES,
MODEL_LIBRARY_FIXTURES,
SCENE_LIBRARY_FIXTURES,
} from "@/lib/mock/libraries";
import { RouteError, jsonSuccess, withErrorHandling } from "@/lib/http/response";
import type { LibraryItemVM, LibraryType } from "@/lib/types/view-models";
adaptLibraryItem,
type BackendLibraryListResponse,
type BackendLibraryResource,
} from "@/lib/adapters/libraries";
import { backendRequest } from "@/lib/http/backend-client";
import {
RouteError,
jsonSuccess,
parseJsonBody,
withErrorHandling,
} from "@/lib/http/response";
import type { LibraryType } from "@/lib/types/view-models";
import { parseCreateLibraryResourcePayload } from "@/lib/validation/library-resource";
type RouteContext = {
params: Promise<{
@@ -12,16 +19,16 @@ type RouteContext = {
}>;
};
const LIBRARY_FIXTURE_MAP: Record<LibraryType, LibraryItemVM[]> = {
models: MODEL_LIBRARY_FIXTURES,
scenes: SCENE_LIBRARY_FIXTURES,
garments: GARMENT_LIBRARY_FIXTURES,
const BACKEND_LIBRARY_TYPE_MAP: Record<LibraryType, "model" | "scene" | "garment"> = {
models: "model",
scenes: "scene",
garments: "garment",
};
const MESSAGE = "资源库当前使用占位数据,真实后端接口尚未提供。";
const MESSAGE = "资源库当前显示真实后端数据。";
function isLibraryType(value: string): value is LibraryType {
return Object.hasOwn(LIBRARY_FIXTURE_MAP, value);
return Object.hasOwn(BACKEND_LIBRARY_TYPE_MAP, value);
}
export async function GET(_request: Request, context: RouteContext) {
@@ -32,14 +39,48 @@ export async function GET(_request: Request, context: RouteContext) {
throw new RouteError(404, "NOT_FOUND", "不支持的资源库类型。");
}
const backendLibraryType = BACKEND_LIBRARY_TYPE_MAP[libraryType];
const response = await backendRequest<BackendLibraryListResponse>(
`/library/resources?resource_type=${backendLibraryType}&limit=100`,
);
return jsonSuccess(
{
items: LIBRARY_FIXTURE_MAP[libraryType],
items: response.data.items.map(adaptLibraryItem),
},
{
mode: "placeholder",
message: MESSAGE,
},
);
});
}
export async function POST(request: Request, context: RouteContext) {
return withErrorHandling(async () => {
const { libraryType } = await context.params;
if (!isLibraryType(libraryType)) {
throw new RouteError(404, "NOT_FOUND", "不支持的资源库类型。");
}
const rawPayload = await parseJsonBody(request);
const payload = parseCreateLibraryResourcePayload(
rawPayload,
BACKEND_LIBRARY_TYPE_MAP[libraryType],
);
const response = await backendRequest<BackendLibraryResource>("/library/resources", {
method: "POST",
body: JSON.stringify(payload),
});
return jsonSuccess(
{
item: adaptLibraryItem(response.data),
},
{
status: response.status,
message: "资源创建成功。",
},
);
});
}

View File

@@ -0,0 +1,37 @@
import { backendRequest } from "@/lib/http/backend-client";
import {
jsonSuccess,
parseJsonBody,
withErrorHandling,
} from "@/lib/http/response";
import { parseLibraryUploadPresignPayload } from "@/lib/validation/library-resource";
type PresignUploadResponseDto = {
method: string;
upload_url: string;
headers: Record<string, string>;
storage_key: string;
public_url: string;
};
export async function POST(request: Request) {
return withErrorHandling(async () => {
const rawPayload = await parseJsonBody(request);
const payload = parseLibraryUploadPresignPayload(rawPayload);
const response = await backendRequest<PresignUploadResponseDto>(
"/library/uploads/presign",
{
method: "POST",
body: JSON.stringify(payload),
},
);
return jsonSuccess({
method: response.data.method,
uploadUrl: response.data.upload_url,
headers: response.data.headers,
storageKey: response.data.storage_key,
publicUrl: response.data.public_url,
});
});
}

View File

@@ -0,0 +1,89 @@
# Resource Library Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a real backend resource library with S3 direct-upload preparation and switch the frontend library BFF from mock data to real backend data.
**Architecture:** Add a dedicated resource-library domain in the FastAPI backend with separate resource and resource-file tables. Expose presign, create, and list APIs, then proxy the list API through the Next.js BFF so the existing resource pages and submit-workbench selectors continue to work without a UI rewrite.
**Tech Stack:** FastAPI, SQLAlchemy async ORM, Alembic, boto3 presigning, pytest/httpx integration tests, Next.js route handlers, Vitest
---
### Task 1: Backend Resource-Library Schema
**Files:**
- Create: `/Volumes/DockCase/codes/auto-virtual-tryon/app/infra/db/models/library_resource.py`
- Create: `/Volumes/DockCase/codes/auto-virtual-tryon/app/infra/db/models/library_resource_file.py`
- Modify: `/Volumes/DockCase/codes/auto-virtual-tryon/app/domain/enums.py`
- Modify: `/Volumes/DockCase/codes/auto-virtual-tryon/app/infra/db/session.py`
- Create: `/Volumes/DockCase/codes/auto-virtual-tryon/alembic/versions/20260328_0003_resource_library.py`
- Test: `/Volumes/DockCase/codes/auto-virtual-tryon/tests/test_api.py`
- [ ] **Step 1: Write the failing backend API test for creating and listing library resources**
- [ ] **Step 2: Run the targeted backend test to verify it fails**
- [ ] **Step 3: Add enums and ORM models for resources and resource files**
- [ ] **Step 4: Add an Alembic migration for the new tables and indexes**
- [ ] **Step 5: Ensure test database initialization imports the new models**
- [ ] **Step 6: Run the targeted backend test again and confirm the failure moved forward from missing schema to missing API implementation**
### Task 2: Backend Presign and Resource APIs
**Files:**
- Modify: `/Volumes/DockCase/codes/auto-virtual-tryon/app/config/settings.py`
- Create: `/Volumes/DockCase/codes/auto-virtual-tryon/app/api/schemas/library.py`
- Create: `/Volumes/DockCase/codes/auto-virtual-tryon/app/application/services/library_service.py`
- Create: `/Volumes/DockCase/codes/auto-virtual-tryon/app/infra/storage/s3.py`
- Create: `/Volumes/DockCase/codes/auto-virtual-tryon/app/api/routers/library.py`
- Modify: `/Volumes/DockCase/codes/auto-virtual-tryon/app/main.py`
- Modify: `/Volumes/DockCase/codes/auto-virtual-tryon/pyproject.toml`
- Test: `/Volumes/DockCase/codes/auto-virtual-tryon/tests/test_api.py`
- [ ] **Step 1: Write the failing backend test for presign generation**
- [ ] **Step 2: Run the targeted backend presign test and verify it fails**
- [ ] **Step 3: Add S3 settings and a presign helper that derives CDN public URLs**
- [ ] **Step 4: Add request/response schemas for presign, create, and list APIs**
- [ ] **Step 5: Implement the library service and router with one-shot create plus filtered list**
- [ ] **Step 6: Run the targeted backend tests and confirm they pass**
### Task 3: Frontend BFF Proxy and Mapping
**Files:**
- Modify: `/Volumes/DockCase/codes/auto-virtual-tryon-frontend/app/api/libraries/[libraryType]/route.ts`
- Modify: `/Volumes/DockCase/codes/auto-virtual-tryon-frontend/src/lib/types/view-models.ts`
- Create: `/Volumes/DockCase/codes/auto-virtual-tryon-frontend/src/lib/adapters/libraries.ts`
- Modify: `/Volumes/DockCase/codes/auto-virtual-tryon-frontend/tests/app/api/libraries.route.test.ts`
- [ ] **Step 1: Write the failing frontend route test for backend-proxied library data**
- [ ] **Step 2: Run the targeted frontend test and verify it fails**
- [ ] **Step 3: Add a backend-to-view-model adapter for library resources**
- [ ] **Step 4: Replace the mock route implementation with backend proxying while preserving normalized errors**
- [ ] **Step 5: Run the targeted frontend tests and confirm they pass**
### Task 4: Verification
**Files:**
- Test: `/Volumes/DockCase/codes/auto-virtual-tryon/tests/test_api.py`
- Test: `/Volumes/DockCase/codes/auto-virtual-tryon-frontend/tests/app/api/libraries.route.test.ts`
- [ ] **Step 1: Run the backend targeted API tests for presign/create/list**
- [ ] **Step 2: Run the frontend targeted route tests**
- [ ] **Step 3: Run backend lint-equivalent checks if available, otherwise run the relevant pytest command set**
- [ ] **Step 4: Run frontend `npm run typecheck` and any targeted tests affected by the library route change**

View File

@@ -0,0 +1,49 @@
# Resource Library Design
**Goal:** Build a real resource-library backend for models, scenes, and garments with S3 direct-upload preparation, persisted resource metadata, and list APIs that the existing frontend library pages can consume.
**Scope:** Replace mock resource-library data with a dedicated backend resource domain. The first slice supports public-read assets, presigned upload preparation, one-shot resource creation, and real list retrieval. Upload UI is out of scope; API tests and existing frontend readers are the verification path.
**Decisions**
- Resource-library records are independent from order-generated assets.
- Files are stored in S3 and read publicly through the configured CDN hostname.
- Uploads use a two-step direct-upload flow:
1. Backend issues presigned upload parameters and storage metadata.
2. Client uploads to S3 and then creates the resource record with uploaded file metadata.
- A resource can include:
- one original file
- one thumbnail file
- multiple gallery files
- Type-specific fields in the first version:
- model: `gender`, `age_group`
- scene: `environment`
- garment: `category`
- Garment category remains a plain string for now. Config management can come later.
**Backend Shape**
- Add a dedicated `library_resources` table for business metadata.
- Add a dedicated `library_resource_files` table for uploaded file references.
- Add enums for resource type, file role, and optional status/environment metadata.
- Add API routes for:
- upload presign
- resource create
- resource list
- Keep the existing order asset model untouched.
**Frontend Shape**
- Keep the existing frontend library pages and submit-workbench readers.
- Replace the current mock BFF implementation with a proxy that calls the new backend list endpoint and maps backend payloads into the existing `LibraryItemVM`.
- Upload UI is deferred. No new client-side upload forms are required in this slice.
**Verification**
- Backend integration tests cover:
- presign response generation
- resource creation with original/thumbnail/gallery files
- filtered resource listing by type and type-specific fields
- Frontend route tests cover:
- successful proxying and mapping from backend payload to existing view-model shape
- unsupported library-type rejection remains normalized

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

1602
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,13 +16,15 @@
"verify": "npm run test && npm run lint && npm run typecheck:clean && npm run build"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"clsx": "^2.1.1",
"lucide-react": "^1.7.0",
"next": "^16.2.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zod": "^4.3.6",
"clsx": "^2.1.1",
"tailwind-merge": "^3.5.0",
"lucide-react": "^1.7.0"
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",

View File

@@ -0,0 +1,136 @@
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
import { cn } from "@/lib/utils";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = forwardRef<
ElementRef<typeof AlertDialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-[rgba(52,39,27,0.45)] backdrop-blur-[2px]",
className,
)}
{...props}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = forwardRef<
ElementRef<typeof AlertDialogPrimitive.Content>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 w-[min(92vw,28rem)] -translate-x-1/2 -translate-y-1/2 rounded-[28px] border border-[var(--border-soft)] bg-[var(--surface)] p-6 shadow-[var(--shadow-dialog)]",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
function AlertDialogHeader({
className,
...props
}: ComponentPropsWithoutRef<"div">) {
return <div className={cn("space-y-2", className)} {...props} />;
}
function AlertDialogFooter({
className,
...props
}: ComponentPropsWithoutRef<"div">) {
return (
<div
className={cn("mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
}
const AlertDialogTitle = forwardRef<
ElementRef<typeof AlertDialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]",
className,
)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = forwardRef<
ElementRef<typeof AlertDialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm leading-6 text-[var(--ink-muted)]", className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = forwardRef<
ElementRef<typeof AlertDialogPrimitive.Action>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md border border-transparent bg-[#8c4a43] px-4 text-sm font-medium tracking-[0.01em] text-[#fff8f5] shadow-[0_12px_30px_rgba(140,74,67,0.16)] transition duration-150 hover:bg-[#7a3d37] 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)] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = forwardRef<
ElementRef<typeof AlertDialogPrimitive.Cancel>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md border border-[var(--border-strong)] bg-[var(--surface)] px-4 text-sm font-medium tracking-[0.01em] text-[var(--ink-strong)] transition duration-150 hover:bg-[var(--surface-muted)] 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)] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
};

View File

@@ -1,27 +1,98 @@
import { forwardRef, type SelectHTMLAttributes } from "react";
"use client";
type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
import { cn } from "@/lib/utils";
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, children, ...props }, ref) => {
return (
<select
ref={ref}
className={joinClasses(
"h-9 rounded-md border border-[var(--border-strong)] bg-[var(--surface)] px-3 text-sm text-[var(--ink-strong)] outline-none transition",
"focus:border-[var(--accent-primary)] focus:ring-2 focus:ring-[var(--accent-ring)]",
export type SelectOption = {
disabled?: boolean;
label: string;
value: string;
};
type SelectProps = {
"aria-label"?: string;
className?: string;
contentClassName?: string;
defaultValue?: string;
disabled?: boolean;
name?: string;
options: SelectOption[];
placeholder?: string;
required?: boolean;
value?: string;
onValueChange?: (value: string) => void;
};
export function Select({
"aria-label": ariaLabel,
className,
contentClassName,
defaultValue,
disabled,
name,
onValueChange,
options,
placeholder,
required,
value,
}: SelectProps) {
return (
<SelectPrimitive.Root
defaultValue={defaultValue}
disabled={disabled}
name={name}
required={required}
value={value}
onValueChange={onValueChange}
>
<SelectPrimitive.Trigger
aria-label={ariaLabel}
className={cn(
"inline-flex h-9 w-full min-w-[180px] items-center justify-between gap-2 rounded-md border border-[var(--border-strong)] bg-[var(--surface)] px-3 text-left text-sm text-[var(--ink-strong)] outline-none transition [&>span]:line-clamp-1",
"focus:ring-2 focus:ring-[var(--accent-ring)] data-[placeholder]:text-[var(--ink-faint)]",
"disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
{...props}
>
{children}
</select>
);
},
);
Select.displayName = "Select";
<SelectPrimitive.Value placeholder={placeholder} />
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 shrink-0 text-[var(--ink-faint)]" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
position="popper"
sideOffset={8}
className={cn(
"z-50 min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-2xl border border-[var(--border-soft)] bg-[var(--surface)] shadow-[0_18px_48px_rgba(39,31,24,0.16)]",
contentClassName,
)}
>
<SelectPrimitive.Viewport className="p-2">
{options.map((option) => (
<SelectPrimitive.Item
key={option.value}
value={option.value}
disabled={option.disabled}
className={cn(
"relative flex min-h-10 cursor-pointer select-none items-center rounded-xl py-2 pl-9 pr-3 text-sm text-[var(--ink-strong)] outline-none transition",
"focus:bg-[var(--surface-muted)] data-[state=checked]:bg-[var(--accent-soft)] data-[state=checked]:text-[var(--accent-ink)]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-45",
)}
>
<span className="absolute left-3 inline-flex h-4 w-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{option.label}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { updateLibraryResource } from "@/features/libraries/manage-resource";
import type { LibraryFileVM, LibraryItemVM, LibraryType } from "@/lib/types/view-models";
type LibraryEditModalProps = {
item: LibraryItemVM | null;
libraryType: LibraryType;
open: boolean;
onClose: () => void;
onSaved: (item: LibraryItemVM) => void;
};
const TITLE_BY_TYPE: Record<LibraryType, string> = {
models: "编辑模特资源",
scenes: "编辑场景资源",
garments: "编辑服装资源",
};
function joinTags(tagsValue: string) {
return tagsValue
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean);
}
function getEditableFiles(item: LibraryItemVM | null): LibraryFileVM[] {
if (!item) {
return [];
}
if ((item.files ?? []).length > 0) {
return item.files ?? [];
}
return item.previewUri
? [
{
id: 0,
role: "thumbnail",
url: item.previewUri,
},
]
: [];
}
function getResourceRequestId(item: LibraryItemVM) {
return typeof item.backendId === "number" ? String(item.backendId) : item.id;
}
export function LibraryEditModal({
item,
libraryType,
open,
onClose,
onSaved,
}: LibraryEditModalProps) {
const editingItem = item;
const files = useMemo(() => getEditableFiles(item), [item]);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [tagsValue, setTagsValue] = useState("");
const [coverFileId, setCoverFileId] = useState<number | undefined>(undefined);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !editingItem) {
return;
}
setName(editingItem.name);
setDescription(editingItem.description);
setTagsValue(editingItem.tags.join(", "));
const currentCoverFileId = (editingItem.files ?? []).find(
(file) => file.url === editingItem.previewUri,
)?.id;
setCoverFileId(currentCoverFileId);
setIsSubmitting(false);
setError(null);
}, [editingItem, open]);
if (!open || !editingItem) {
return null;
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
const activeItem = editingItem;
if (!activeItem) {
return;
}
try {
setIsSubmitting(true);
const updated = await updateLibraryResource({
libraryType,
resourceId: getResourceRequestId(activeItem),
values: {
name,
description,
tags: joinTags(tagsValue),
coverFileId,
},
});
onSaved(updated);
} catch (submissionError) {
setError(
submissionError instanceof Error
? submissionError.message
: "资源更新失败,请稍后重试。",
);
} finally {
setIsSubmitting(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(30,24,18,0.42)] px-4 py-8">
<div
aria-labelledby="library-edit-title"
aria-modal="true"
role="dialog"
className="w-full max-w-5xl rounded-[32px] border border-[var(--border-soft)] bg-[var(--surface)] shadow-[0_24px_80px_rgba(39,31,24,0.18)]"
>
<form className="space-y-6 p-6" onSubmit={handleSubmit}>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-[var(--ink-faint)]">
Resource editor
</p>
<h2
id="library-edit-title"
className="text-2xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]"
>
{TITLE_BY_TYPE[libraryType]}
</h2>
<p className="text-sm leading-6 text-[var(--ink-muted)]">
</p>
</div>
<Button onClick={onClose} size="sm" type="button" variant="ghost">
</Button>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
<div className="space-y-4">
<label className="space-y-2">
<span className="text-sm font-medium text-[var(--ink-strong)]">
</span>
<Input
required
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-[var(--ink-strong)]">
</span>
<Input
placeholder="女装, 棚拍, 春夏"
value={tagsValue}
onChange={(event) => setTagsValue(event.target.value)}
/>
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-[var(--ink-strong)]">
</span>
<textarea
className="min-h-28 w-full rounded-md border border-[var(--border-strong)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--ink-strong)] outline-none transition focus:border-[var(--accent-primary)] focus:ring-2 focus:ring-[var(--accent-ring)]"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</label>
</div>
<div className="space-y-3">
<p className="text-sm font-medium text-[var(--ink-strong)]">
</p>
<div className="grid gap-3 sm:grid-cols-2">
{files.map((file, index) => {
const isCurrentCover = file.id === coverFileId;
return (
<div
key={`${file.id}-${file.url}`}
className="space-y-2 rounded-[22px] border border-[var(--border-soft)] bg-[var(--surface-muted)] p-3"
>
<div className="relative aspect-[4/5] overflow-hidden rounded-[16px] bg-[rgba(74,64,53,0.08)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt={`${editingItem.name} 图片 ${index + 1}`}
className="h-full w-full object-cover"
src={file.url}
/>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-[var(--ink-muted)]">
{file.role}
</span>
<Button
disabled={file.id <= 0 || isCurrentCover}
size="sm"
type="button"
variant={isCurrentCover ? "secondary" : "ghost"}
onClick={() => setCoverFileId(file.id)}
>
{isCurrentCover ? "当前封面" : `设为封面 ${index + 1}`}
</Button>
</div>
</div>
);
})}
</div>
</div>
</div>
{error ? (
<div className="rounded-[20px] border border-[#b88472] bg-[#f8ece5] px-4 py-3 text-sm text-[#7f4b3b]">
{error}
</div>
) : null}
<div className="flex justify-end gap-3">
<Button disabled={isSubmitting} onClick={onClose} type="button" variant="ghost">
</Button>
<Button disabled={isSubmitting} type="submit">
{isSubmitting ? "保存中…" : "保存修改"}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,341 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import {
uploadLibraryResource,
type UploadFormValues,
} from "@/features/libraries/upload-resource";
import type { LibraryItemVM, LibraryType } from "@/lib/types/view-models";
type LibraryUploadModalProps = {
libraryType: LibraryType;
open: boolean;
onClose: () => void;
onUploaded: (item: LibraryItemVM) => void;
};
type LibraryMeta = {
title: string;
genderLabel?: string;
extraLabel?: string;
};
const LIBRARY_META: Record<LibraryType, LibraryMeta> = {
models: {
title: "上传模特资源",
genderLabel: "模特性别",
},
scenes: {
title: "上传场景资源",
extraLabel: "场景类型",
},
garments: {
title: "上传服装资源",
extraLabel: "服装品类",
},
};
const EMPTY_VALUES: UploadFormValues = {
name: "",
description: "",
tags: [],
gender: "",
ageGroup: "",
environment: "",
category: "",
};
type ModalFiles = {
original: File | null;
gallery: File[];
};
const EMPTY_FILES: ModalFiles = {
original: null,
gallery: [],
};
function splitTags(rawValue: string) {
return rawValue
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean);
}
export function LibraryUploadModal({
libraryType,
open,
onClose,
onUploaded,
}: LibraryUploadModalProps) {
const meta = LIBRARY_META[libraryType];
const [values, setValues] = useState<UploadFormValues>(EMPTY_VALUES);
const [tagsValue, setTagsValue] = useState("");
const [files, setFiles] = useState<ModalFiles>(EMPTY_FILES);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) {
return;
}
setValues(EMPTY_VALUES);
setTagsValue("");
setFiles(EMPTY_FILES);
setError(null);
setIsSubmitting(false);
}, [libraryType, open]);
if (!open) {
return null;
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!files.original) {
setError("请先上传原图。");
return;
}
try {
setIsSubmitting(true);
const item = await uploadLibraryResource({
libraryType,
values: {
...values,
tags: splitTags(tagsValue),
},
files: {
original: files.original,
gallery: files.gallery,
},
});
onUploaded(item);
} catch (submissionError) {
setError(
submissionError instanceof Error
? submissionError.message
: "上传失败,请稍后重试。",
);
} finally {
setIsSubmitting(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(30,24,18,0.42)] px-4 py-8">
<div
aria-labelledby="library-upload-title"
aria-modal="true"
role="dialog"
className="w-full max-w-3xl rounded-[32px] border border-[var(--border-soft)] bg-[var(--surface)] shadow-[0_24px_80px_rgba(39,31,24,0.18)]"
>
<form className="space-y-6 p-6" onSubmit={handleSubmit}>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-[var(--ink-faint)]">
Resource upload
</p>
<h2
id="library-upload-title"
className="text-2xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]"
>
{meta.title}
</h2>
<p className="text-sm leading-6 text-[var(--ink-muted)]">
</p>
</div>
<Button onClick={onClose} size="sm" type="button" variant="ghost">
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-2">
<span className="text-sm font-medium text-[var(--ink-strong)]"></span>
<Input
name="name"
required
value={values.name}
onChange={(event) =>
setValues((current) => ({ ...current, name: event.target.value }))
}
/>
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-[var(--ink-strong)]"></span>
<Input
name="tags"
placeholder="女装, 棚拍, 春夏"
value={tagsValue}
onChange={(event) => setTagsValue(event.target.value)}
/>
</label>
<label className="space-y-2 md:col-span-2">
<span className="text-sm font-medium text-[var(--ink-strong)]"></span>
<textarea
className="min-h-28 w-full rounded-md border border-[var(--border-strong)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--ink-strong)] outline-none transition focus:border-[var(--accent-primary)] focus:ring-2 focus:ring-[var(--accent-ring)]"
name="description"
value={values.description}
onChange={(event) =>
setValues((current) => ({
...current,
description: event.target.value,
}))
}
/>
</label>
{libraryType === "models" ? (
<>
<label className="space-y-2">
<span className="text-sm font-medium text-[var(--ink-strong)]"></span>
<Select
aria-label="模特性别"
options={[
{ value: "female", label: "女" },
{ value: "male", label: "男" },
]}
placeholder="请选择"
required
value={values.gender}
onValueChange={(nextValue) =>
setValues((current) => ({
...current,
gender: nextValue,
}))
}
/>
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-[var(--ink-strong)]"></span>
<Select
aria-label="年龄段"
options={[
{ value: "teen", label: "青少年" },
{ value: "adult", label: "成年" },
{ value: "mature", label: "成熟" },
]}
placeholder="请选择"
required
value={values.ageGroup}
onValueChange={(nextValue) =>
setValues((current) => ({
...current,
ageGroup: nextValue,
}))
}
/>
</label>
</>
) : null}
{libraryType === "scenes" ? (
<label className="space-y-2">
<span className="text-sm font-medium text-[var(--ink-strong)]"></span>
<Select
aria-label="场景类型"
options={[
{ value: "indoor", label: "室内" },
{ value: "outdoor", label: "室外" },
]}
placeholder="请选择"
required
value={values.environment}
onValueChange={(nextValue) =>
setValues((current) => ({
...current,
environment: nextValue,
}))
}
/>
</label>
) : null}
{libraryType === "garments" ? (
<label className="space-y-2">
<span className="text-sm font-medium text-[var(--ink-strong)]"></span>
<Input
aria-label="服装品类"
required
value={values.category}
onChange={(event) =>
setValues((current) => ({
...current,
category: event.target.value,
}))
}
/>
</label>
) : null}
<label className="space-y-2">
<span className="text-sm font-medium text-[var(--ink-strong)]"></span>
<Input
aria-label="原图"
accept="image/*"
required
type="file"
onChange={(event) =>
setFiles((current) => ({
...current,
original: event.target.files?.[0] ?? null,
}))
}
/>
</label>
<div className="space-y-2 rounded-[18px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 py-3">
<span className="text-sm font-medium text-[var(--ink-strong)]"></span>
<p className="text-sm leading-6 text-[var(--ink-muted)]">
</p>
</div>
<label className="space-y-2 md:col-span-2">
<span className="text-sm font-medium text-[var(--ink-strong)]"></span>
<Input
aria-label="附加图库"
accept="image/*"
multiple
type="file"
onChange={(event) =>
setFiles((current) => ({
...current,
gallery: event.target.files ? Array.from(event.target.files) : [],
}))
}
/>
</label>
</div>
{error ? (
<div className="rounded-[20px] border border-[#b88472] bg-[#f8ece5] px-4 py-3 text-sm text-[#7f4b3b]">
{error}
</div>
) : null}
<div className="flex justify-end gap-3">
<Button disabled={isSubmitting} onClick={onClose} type="button" variant="ghost">
</Button>
<Button disabled={isSubmitting} type="submit">
{isSubmitting ? "上传中…" : "开始上传"}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,10 +1,25 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { CardEyebrow } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { LibraryEditModal } from "@/features/libraries/components/library-edit-modal";
import { LibraryUploadModal } from "@/features/libraries/components/library-upload-modal";
import { archiveLibraryResource } from "@/features/libraries/manage-resource";
import type { LibraryItemVM, LibraryType } from "@/lib/types/view-models";
type LibraryPageProps = {
@@ -21,29 +36,50 @@ type LibraryEnvelope = {
message?: string;
};
const LIBRARY_META: Record<
LibraryType,
{ title: string; description: string; eyebrow: string }
> = {
type LibraryMeta = {
description: string;
singularLabel: string;
tabLabel: string;
};
const LIBRARY_META: Record<LibraryType, LibraryMeta> = {
models: {
title: "模特",
description: "继续复用提单页所需的 mock 模特数据,同时保留正式资源库的结构和标签体系。",
eyebrow: "Model library",
description: "模特资源管理上传、封面和提单联动素材。",
singularLabel: "模特",
tabLabel: "模特",
},
scenes: {
title: "场景",
description: "场景库先用 mock 占位数据承接,等真实后台资源列表可用后再切换数据源。",
eyebrow: "Scene library",
description: "场景资源管理上传、封面和提单联动素材。",
singularLabel: "场景",
tabLabel: "场景",
},
garments: {
title: "服装",
description: "服装资源暂时继续走 mock 资产,但页面本身已经按正式资源库组织。",
eyebrow: "Garment library",
description: "服装资源管理上传、封面和提单联动素材。",
singularLabel: "服装",
tabLabel: "服装",
},
};
const TITLE_MESSAGE = "当前资源库仍使用 mock 数据";
const DEFAULT_MESSAGE = "当前只保留页面结构和 mock 资源项,真实资源查询能力将在后端支持后接入。";
const LIBRARY_SECTIONS: Array<{
href: string;
label: string;
libraryType: LibraryType;
}> = [
{ href: "/libraries/models", label: "模特", libraryType: "models" },
{ href: "/libraries/scenes", label: "场景", libraryType: "scenes" },
{ href: "/libraries/garments", label: "服装", libraryType: "garments" },
];
const STATE_TITLE = "资源库状态";
const DEFAULT_MESSAGE = "资源库当前显示真实后端数据。";
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
function getResourceRequestId(item: LibraryItemVM) {
return typeof item.backendId === "number" ? String(item.backendId) : item.id;
}
export function LibraryPage({
isLoading = false,
@@ -52,95 +88,282 @@ export function LibraryPage({
message = DEFAULT_MESSAGE,
}: LibraryPageProps) {
const meta = LIBRARY_META[libraryType];
const [archivingItem, setArchivingItem] = useState<LibraryItemVM | null>(null);
const [editingItem, setEditingItem] = useState<LibraryItemVM | null>(null);
const [isArchiving, setIsArchiving] = useState(false);
const [isUploadOpen, setIsUploadOpen] = useState(false);
const [visibleItems, setVisibleItems] = useState(items);
const [statusMessage, setStatusMessage] = useState(message);
useEffect(() => {
setVisibleItems(items);
}, [items]);
useEffect(() => {
setStatusMessage(message);
}, [message]);
function handleUploaded(item: LibraryItemVM) {
setVisibleItems((current) => [
item,
...current.filter((candidate) => candidate.id !== item.id),
]);
setStatusMessage("资源上传成功,已写入正式资源库。");
setIsUploadOpen(false);
}
function handleSaved(item: LibraryItemVM) {
setVisibleItems((current) =>
current.map((candidate) => (candidate.id === item.id ? item : candidate)),
);
setStatusMessage("资源更新成功,封面与元数据已同步。");
setEditingItem(null);
}
async function handleArchive(item: LibraryItemVM) {
setIsArchiving(true);
try {
const archivedId = await archiveLibraryResource({
libraryType,
resourceId: getResourceRequestId(item),
});
setVisibleItems((current) =>
current.filter((candidate) => candidate.id !== archivedId),
);
setStatusMessage(`资源「${item.name}」已移入归档。`);
setArchivingItem(null);
} catch (archiveError) {
setStatusMessage(
archiveError instanceof Error
? archiveError.message
: "资源删除失败,请稍后重试。",
);
} finally {
setIsArchiving(false);
}
}
return (
<section className="space-y-8">
<PageHeader
eyebrow={meta.eyebrow}
title={meta.title}
eyebrow="Resource library"
title="资源库"
description={meta.description}
meta="正式占位模块"
meta={`${meta.tabLabel}管理视图`}
/>
<nav
aria-label="Library sections"
className="flex flex-wrap gap-3 rounded-[28px] border border-[var(--border-soft)] bg-[rgba(255,250,242,0.88)] p-2 shadow-[var(--shadow-card)]"
>
{LIBRARY_SECTIONS.map((section) => {
const isCurrent = section.libraryType === libraryType;
return (
<Link
key={section.libraryType}
href={section.href}
aria-current={isCurrent ? "page" : undefined}
className={joinClasses(
"inline-flex min-h-11 items-center rounded-[20px] px-4 py-2 text-sm font-medium transition",
isCurrent
? "bg-[var(--accent-primary)] text-[var(--accent-ink)] shadow-[0_12px_30px_rgba(110,127,82,0.22)]"
: "text-[var(--ink-muted)] hover:bg-[var(--surface-muted)] hover:text-[var(--ink-strong)]",
)}
>
{section.label}
</Link>
);
})}
</nav>
<div className="rounded-[28px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-6 py-5">
<p className="text-lg font-semibold tracking-[-0.02em] text-[#7a5323]">
{TITLE_MESSAGE}
{STATE_TITLE}
</p>
<p className="mt-2 text-sm leading-7 text-[#7a5323]">{message}</p>
<p className="mt-2 text-sm leading-7 text-[#7a5323]">{statusMessage}</p>
</div>
<Card>
<CardHeader>
<div className="space-y-2">
<CardEyebrow>Library inventory</CardEyebrow>
<div className="space-y-1">
<CardTitle>{meta.title}</CardTitle>
<CardDescription>
BFF
</CardDescription>
<div
data-testid="library-masonry"
className="columns-1 gap-5 md:columns-2 xl:columns-3 2xl:columns-4"
>
<div className="mb-5 break-inside-avoid">
<div className="rounded-[28px] border border-dashed border-[var(--border-strong)] bg-[linear-gradient(180deg,rgba(255,250,242,0.96),rgba(246,237,224,0.92))] p-5 shadow-[var(--shadow-card)]">
<div className="space-y-4">
<div className="space-y-2">
<CardEyebrow>{meta.tabLabel} upload</CardEyebrow>
<div className="space-y-2">
<h2 className="text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
{meta.singularLabel}
</h2>
<p className="text-sm leading-6 text-[var(--ink-muted)]">
</p>
</div>
</div>
<Button
className="min-h-11 w-full justify-center"
size="lg"
onClick={() => setIsUploadOpen(true)}
>
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="rounded-[24px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
</div>
) : null}
</div>
{isLoading ? (
<div className="mb-5 break-inside-avoid rounded-[28px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
</div>
) : null}
{!isLoading && !visibleItems.length ? (
<div className="mb-5 break-inside-avoid rounded-[28px] border border-[var(--border-soft)] bg-[var(--surface)] p-5 shadow-[var(--shadow-card)]">
<EmptyState
eyebrow="Library empty"
title={`暂无${meta.singularLabel}资源`}
description="当前库还没有资源条目,先从首卡入口上传第一批素材。"
/>
</div>
) : null}
{!isLoading
? visibleItems.map((item) => (
<article
key={item.id}
className="mb-5 break-inside-avoid rounded-[28px] border border-[var(--border-soft)] bg-[var(--surface)] p-4 shadow-[var(--shadow-card)]"
>
<div className="space-y-4">
<div className="rounded-[22px] border 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 ? (
// previewUri may come from mock:// fixtures during tests, so keep a plain img here.
// eslint-disable-next-line @next/next/no-img-element
<img
alt={`${item.name} 预览图`}
className="h-full w-full object-cover"
src={item.previewUri}
/>
) : null}
{/* <div className="absolute inset-x-0 bottom-0 flex items-end p-4">
<div className="rounded-[18px] bg-[rgba(74,64,53,0.72)] px-3 py-2 text-sm font-medium text-[#fffaf5]">
{meta.singularLabel}预览
</div>
</div> */}
</div>
</div>
{!isLoading && items.length ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<div
key={item.id}
className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4"
>
<div className="space-y-3">
<div>
<p className="text-sm font-semibold text-[var(--ink-strong)]">
<div className="space-y-1">
<p className="text-base font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
{item.name}
</p>
<p className="mt-1 text-sm leading-6 text-[var(--ink-muted)]">
<p className="text-sm leading-6 text-[var(--ink-muted)]">
{item.description}
</p>
</div>
<code className="block rounded-[18px] bg-[rgba(74,64,53,0.08)] px-3 py-3 text-xs leading-6 text-[var(--ink-muted)]">
{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}
{/* <code className="block rounded-[18px] bg-[rgba(74,64,53,0.08)] px-3 py-3 text-xs leading-6 text-[var(--ink-muted)]">
{item.previewUri}
</code>
<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>
))}
</code> */}
<div className="grid gap-2 sm:grid-cols-2">
<Button
size="sm"
variant="secondary"
onClick={() => setEditingItem(item)}
>
</Button>
<Button
size="sm"
variant="danger"
onClick={() => {
setArchivingItem(item);
}}
>
</Button>
</div>
</div>
</div>
))}
</div>
) : null}
</article>
))
: null}
</div>
{!isLoading && !items.length ? (
<EmptyState
eyebrow="Library empty"
title="暂无资源条目"
description="当前库还没有占位数据,等后端或运营资源列表准备好后再补齐。"
/>
) : null}
</CardContent>
</Card>
<LibraryUploadModal
libraryType={libraryType}
open={isUploadOpen}
onClose={() => setIsUploadOpen(false)}
onUploaded={handleUploaded}
/>
<LibraryEditModal
item={editingItem}
libraryType={libraryType}
open={editingItem !== null}
onClose={() => setEditingItem(null)}
onSaved={handleSaved}
/>
<AlertDialog
open={archivingItem !== null}
onOpenChange={(open) => {
if (!open && !isArchiving) {
setArchivingItem(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{`确认删除${meta.singularLabel}资源`}</AlertDialogTitle>
<AlertDialogDescription>
{archivingItem
? `删除后,这条资源会被移入归档,不再出现在当前列表中。当前资源:${archivingItem.name}`
: "删除后,这条资源会被移入归档,不再出现在当前列表中。"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isArchiving}></AlertDialogCancel>
<AlertDialogAction
disabled={!archivingItem || isArchiving}
onClick={(event) => {
event.preventDefault();
if (!archivingItem || isArchiving) {
return;
}
void handleArchive(archivingItem);
}}
>
{isArchiving ? "删除中..." : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
export function LibraryPageScreen({ libraryType }: { libraryType: LibraryType }) {
const [items, setItems] = useState<LibraryItemVM[]>([]);
const [message, setMessage] = useState(
"当前资源库仍使用 mock 数据,真实资源查询能力将在后端支持后接入。",
);
const [message, setMessage] = useState(DEFAULT_MESSAGE);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
@@ -158,10 +381,7 @@ export function LibraryPageScreen({ libraryType }: { libraryType: LibraryType })
}
setItems(payload.data?.items ?? []);
setMessage(
payload.message ??
"当前资源库仍使用 mock 数据,真实资源查询能力将在后端支持后接入。",
);
setMessage(payload.message ?? DEFAULT_MESSAGE);
} catch {
if (!active) {
return;

View File

@@ -0,0 +1,101 @@
import type { LibraryItemVM, LibraryType } from "@/lib/types/view-models";
type ManageFetch = typeof fetch;
type UpdateLibraryResourceArgs = {
fetchFn?: ManageFetch;
libraryType: LibraryType;
resourceId: string;
values: {
coverFileId?: number;
description: string;
name: string;
tags: string[];
};
};
type ArchiveLibraryResourceArgs = {
fetchFn?: ManageFetch;
libraryType: LibraryType;
resourceId: string;
};
type UpdateResourceResponse = {
item: LibraryItemVM;
};
type ArchiveResourceResponse = {
id: string;
};
async function parseJsonResponse<T>(
response: Response,
fallbackMessage: string,
): Promise<T> {
const rawText = await response.text();
const payload = rawText.length > 0 ? (JSON.parse(rawText) as unknown) : {};
if (!response.ok) {
const message =
typeof payload === "object" &&
payload !== null &&
"message" in payload &&
typeof payload.message === "string"
? payload.message
: fallbackMessage;
throw new Error(message);
}
if (typeof payload === "object" && payload !== null && "data" in payload) {
return payload.data as T;
}
throw new Error(fallbackMessage);
}
export async function updateLibraryResource({
fetchFn = fetch,
libraryType,
resourceId,
values,
}: UpdateLibraryResourceArgs): Promise<LibraryItemVM> {
const response = await fetchFn(`/api/libraries/${libraryType}/${resourceId}`, {
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
name: values.name.trim(),
description: values.description.trim(),
tags: values.tags,
...(typeof values.coverFileId === "number"
? { coverFileId: values.coverFileId }
: {}),
}),
});
const updated = await parseJsonResponse<UpdateResourceResponse>(
response,
"资源更新失败,请稍后重试。",
);
return updated.item;
}
export async function archiveLibraryResource({
fetchFn = fetch,
libraryType,
resourceId,
}: ArchiveLibraryResourceArgs): Promise<string> {
const response = await fetchFn(`/api/libraries/${libraryType}/${resourceId}`, {
method: "DELETE",
});
const archived = await parseJsonResponse<ArchiveResourceResponse>(
response,
"资源删除失败,请稍后重试。",
);
return archived.id;
}

View File

@@ -0,0 +1,230 @@
import type { LibraryItemVM, LibraryType } from "@/lib/types/view-models";
type UploadFetch = typeof fetch;
type UploadFormValues = {
name: string;
description: string;
tags: string[];
gender: string;
ageGroup: string;
environment: string;
category: string;
};
type UploadFormFiles = {
original: File;
gallery: File[];
};
type UploadLibraryResourceArgs = {
fetchFn?: UploadFetch;
libraryType: LibraryType;
thumbnailGenerator?: (file: File) => Promise<File>;
values: UploadFormValues;
files: UploadFormFiles;
};
type PresignPayload = {
method: string;
uploadUrl: string;
headers: Record<string, string>;
storageKey: string;
publicUrl: string;
};
type CreateResourceResponse = {
item: LibraryItemVM;
};
const BACKEND_TYPE_BY_LIBRARY_TYPE: Record<LibraryType, "model" | "scene" | "garment"> = {
models: "model",
scenes: "scene",
garments: "garment",
};
async function parseJsonResponse<T>(
response: Response,
fallbackMessage: string,
): Promise<T> {
const rawText = await response.text();
const payload = rawText.length > 0 ? (JSON.parse(rawText) as unknown) : {};
if (!response.ok) {
const message =
typeof payload === "object" &&
payload !== null &&
"message" in payload &&
typeof payload.message === "string"
? payload.message
: fallbackMessage;
throw new Error(message);
}
if (
typeof payload === "object" &&
payload !== null &&
"data" in payload
) {
return payload.data as T;
}
throw new Error(fallbackMessage);
}
async function requestPresign(
fetchFn: UploadFetch,
libraryType: LibraryType,
fileRole: "original" | "thumbnail" | "gallery",
file: File,
) {
const response = await fetchFn("/api/libraries/uploads/presign", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
resourceType: BACKEND_TYPE_BY_LIBRARY_TYPE[libraryType],
fileRole,
fileName: file.name,
contentType: file.type || "application/octet-stream",
}),
});
return parseJsonResponse<PresignPayload>(response, "上传签名申请失败。");
}
async function uploadFile(
fetchFn: UploadFetch,
libraryType: LibraryType,
fileRole: "original" | "thumbnail" | "gallery",
file: File,
sortOrder: number,
) {
const presign = await requestPresign(fetchFn, libraryType, fileRole, file);
const uploadResponse = await fetchFn(presign.uploadUrl, {
method: presign.method,
headers: presign.headers,
body: file,
});
if (!uploadResponse.ok) {
throw new Error("文件上传失败,请稍后重试。");
}
return {
fileRole,
storageKey: presign.storageKey,
publicUrl: presign.publicUrl,
mimeType: file.type || "application/octet-stream",
sizeBytes: file.size,
sortOrder,
};
}
function buildThumbnailFileName(file: File) {
const dotIndex = file.name.lastIndexOf(".");
const stem = dotIndex >= 0 ? file.name.slice(0, dotIndex) : file.name;
const extension = dotIndex >= 0 ? file.name.slice(dotIndex) : ".png";
return `${stem}-thumbnail${extension}`;
}
async function defaultThumbnailGenerator(file: File): Promise<File> {
if (typeof document === "undefined") {
throw new Error("当前环境无法自动生成缩略图。");
}
return new Promise((resolve, reject) => {
const image = new Image();
const objectUrl = URL.createObjectURL(file);
image.onload = () => {
const longestEdge = 480;
const scale = Math.min(1, longestEdge / Math.max(image.width, image.height));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(image.width * scale));
canvas.height = Math.max(1, Math.round(image.height * scale));
const context = canvas.getContext("2d");
if (!context) {
URL.revokeObjectURL(objectUrl);
reject(new Error("当前浏览器无法生成缩略图。"));
return;
}
context.drawImage(image, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
(blob) => {
URL.revokeObjectURL(objectUrl);
if (!blob) {
reject(new Error("缩略图生成失败,请稍后重试。"));
return;
}
resolve(
new File([blob], buildThumbnailFileName(file), {
type: blob.type || file.type || "image/png",
}),
);
},
file.type || "image/png",
0.86,
);
};
image.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error("原图无法读取,不能生成缩略图。"));
};
image.src = objectUrl;
});
}
export async function uploadLibraryResource({
fetchFn = fetch,
libraryType,
thumbnailGenerator = defaultThumbnailGenerator,
values,
files,
}: UploadLibraryResourceArgs): Promise<LibraryItemVM> {
const thumbnailFile = await thumbnailGenerator(files.original);
const uploadedFiles = [
await uploadFile(fetchFn, libraryType, "original", files.original, 0),
await uploadFile(fetchFn, libraryType, "thumbnail", thumbnailFile, 0),
...(await Promise.all(
files.gallery.map((file, index) =>
uploadFile(fetchFn, libraryType, "gallery", file, index),
),
)),
];
const response = await fetchFn(`/api/libraries/${libraryType}`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
name: values.name.trim(),
description: values.description.trim(),
tags: values.tags,
gender: values.gender || undefined,
ageGroup: values.ageGroup || undefined,
environment: values.environment || undefined,
category: values.category || undefined,
files: uploadedFiles,
}),
});
const created = await parseJsonResponse<CreateResourceResponse>(
response,
"资源创建失败,请稍后重试。",
);
return created.item;
}
export type { UploadFormFiles, UploadFormValues };

View File

@@ -7,6 +7,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
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 {
@@ -50,10 +51,6 @@ type CreateOrderFormProps = {
onSubmit: () => void;
};
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
export function CreateOrderForm({
allowedServiceMode,
garments,
@@ -96,57 +93,51 @@ export function CreateOrderForm({
<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
<Select
aria-label="客户层级"
className={joinClasses(
"min-h-12 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)]",
"focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]",
)}
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}
onChange={(event) =>
onCustomerLevelChange(event.target.value as CustomerLevel)
onValueChange={(nextValue) =>
onCustomerLevelChange(nextValue as CustomerLevel)
}
>
<option value="low"> low</option>
<option value="mid"> mid</option>
</select>
/>
</label>
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
<span className="font-medium"></span>
<select
<Select
aria-label="服务模式"
className={joinClasses(
"min-h-12 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)]",
"focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]",
)}
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}
onChange={(event) =>
onServiceModeChange(event.target.value as ServiceMode)
onValueChange={(nextValue) =>
onServiceModeChange(nextValue as ServiceMode)
}
>
<option
disabled={allowedServiceMode !== "auto_basic"}
value="auto_basic"
>
{SERVICE_MODE_LABELS.auto_basic} auto_basic
</option>
<option
disabled={allowedServiceMode !== "semi_pro"}
value="semi_pro"
>
{SERVICE_MODE_LABELS.semi_pro} semi_pro
</option>
</select>
/>
</label>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-3">
<ResourcePickerCard
description="使用资源库占位数据挑选模特,提交时会映射到真实后端 ID 与 pose_id。"
description="使用资源库素材挑选模特,提交时会映射到真实后端资源 ID。"
disabled={isSubmitting}
isLoading={isLoadingResources}
items={models}

View File

@@ -96,7 +96,7 @@ export function OrderSummaryCard({
label="提交映射"
value={
model && scene && garment
? `model ${model.backendId} / pose ${model.poseId} / scene ${scene.backendId} / garment ${garment.backendId}`
? `model ${model.backendId} / scene ${scene.backendId} / garment ${garment.backendId}`
: "完成选择后显示"
}
/>

View File

@@ -48,29 +48,28 @@ export function OrdersToolbar({
<Button onClick={() => onQuerySubmit?.(query.trim())}></Button>
<Select
aria-label="订单状态筛选"
options={[
{ value: "all", label: "全部状态" },
...Object.entries(ORDER_STATUS_META).map(([value, meta]) => ({
value,
label: meta.label,
})),
]}
value={status}
onChange={(event) =>
onStatusChange?.(event.target.value as OrderFilterStatus)
}
>
<option value="all"></option>
{Object.entries(ORDER_STATUS_META).map(([value, meta]) => (
<option key={value} value={value}>
{meta.label}
</option>
))}
</Select>
onValueChange={(value) => onStatusChange?.(value as OrderFilterStatus)}
/>
<Select
aria-label="服务模式筛选"
options={[
{ value: "all", label: "全部服务模式" },
{ value: "auto_basic", label: "auto_basic" },
{ value: "semi_pro", label: "semi_pro" },
]}
value={serviceMode}
onChange={(event) =>
onServiceModeChange(event.target.value as OrderFilterServiceMode)
onValueChange={(value) =>
onServiceModeChange(value as OrderFilterServiceMode)
}
>
<option value="all"></option>
<option value="auto_basic">auto_basic</option>
<option value="semi_pro">semi_pro</option>
</Select>
/>
</div>
<div className="flex items-center gap-3">

View File

@@ -6,6 +6,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Select } from "@/components/ui/select";
import type { ResourcePickerOption } from "@/features/orders/resource-picker-options";
type ResourcePickerCardProps = {
@@ -19,10 +20,6 @@ type ResourcePickerCardProps = {
onChange: (value: string) => void;
};
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
export function ResourcePickerCard({
description,
disabled = false,
@@ -45,25 +42,18 @@ export function ResourcePickerCard({
<CardContent className="space-y-4">
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
<span className="font-medium">{label}</span>
<select
<Select
aria-label={label}
className={joinClasses(
"min-h-12 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)]",
"focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]",
)}
className="min-h-12 rounded-[18px] border-[var(--border-strong)] bg-[var(--surface-muted)] px-4"
disabled={disabled || isLoading}
options={items.map((item) => ({
value: item.id,
label: item.name,
}))}
placeholder={isLoading ? "正在加载占位资源..." : "请选择一个资源"}
value={value}
onChange={(event) => onChange(event.target.value)}
>
<option value="">
{isLoading ? "正在加载占位资源..." : "请选择一个资源"}
</option>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
onValueChange={onChange}
/>
</label>
{selectedItem ? (

View File

@@ -9,7 +9,7 @@ export type ResourcePickerOption = LibraryItemVM & {
};
export type ModelPickerOption = ResourcePickerOption & {
poseId: number;
poseId?: number | null;
};
type ResourceBinding = {
@@ -68,9 +68,19 @@ export function getServiceModeForCustomerLevel(
export function mapModelOptions(items: LibraryItemVM[]): ModelPickerOption[] {
return items.flatMap((item) => {
if (typeof item.backendId === "number") {
return [
{
...item,
backendId: item.backendId,
poseId: item.poseId ?? null,
},
];
}
const binding = getResourceBinding(item.id);
if (!binding?.poseId) {
if (!binding) {
return [];
}
@@ -78,7 +88,7 @@ export function mapModelOptions(items: LibraryItemVM[]): ModelPickerOption[] {
{
...item,
backendId: binding.backendId,
poseId: binding.poseId,
poseId: binding.poseId ?? null,
},
];
});
@@ -88,6 +98,15 @@ export function mapResourceOptions(
items: LibraryItemVM[],
): ResourcePickerOption[] {
return items.flatMap((item) => {
if (typeof item.backendId === "number") {
return [
{
...item,
backendId: item.backendId,
},
];
}
const binding = getResourceBinding(item.id);
if (!binding) {

View File

@@ -195,7 +195,6 @@ export function SubmitWorkbench() {
customer_level: formValues.customerLevel,
service_mode: formValues.serviceMode,
model_id: selectedModel.backendId,
pose_id: selectedModel.poseId,
garment_asset_id: selectedGarment.backendId,
scene_ref_asset_id: selectedScene.backendId,
}),

View File

@@ -58,26 +58,26 @@ export function ReviewFilters({
/>
<Select
aria-label="审核状态筛选"
options={[
{ value: "all", label: "全部状态" },
{ value: "waiting_review", label: "待审核" },
]}
value={statusFilter}
onChange={(event) =>
onStatusFilterChange(event.target.value as ReviewStatusFilter)
}
>
<option value="all"></option>
<option value="waiting_review"></option>
</Select>
onValueChange={(value) => onStatusFilterChange(value as ReviewStatusFilter)}
/>
<Select
aria-label="修订状态筛选"
options={[
{ value: "all", label: "全部修订状态" },
{ value: "none", label: "无修订稿" },
{ value: "revision_uploaded", label: "已上传修订稿" },
{ value: "pending_manual_confirm", label: "修订待确认" },
]}
value={revisionFilter}
onChange={(event) =>
onRevisionFilterChange(event.target.value as ReviewRevisionFilter)
onValueChange={(value) =>
onRevisionFilterChange(value as ReviewRevisionFilter)
}
>
<option value="all"></option>
<option value="none">稿</option>
<option value="revision_uploaded">稿</option>
<option value="pending_manual_confirm"></option>
</Select>
/>
<Button variant="secondary" onClick={onRefresh}>
</Button>

View File

@@ -43,18 +43,16 @@ export function WorkflowToolbar({
<Button onClick={() => onQuerySubmit?.(query.trim())}></Button>
<Select
aria-label="流程状态筛选"
options={[
{ value: "all", label: "全部状态" },
...Object.entries(ORDER_STATUS_META).map(([value, meta]) => ({
value,
label: meta.label,
})),
]}
value={status}
onChange={(event) =>
onStatusChange?.(event.target.value as WorkflowFilterStatus)
}
>
<option value="all"></option>
{Object.entries(ORDER_STATUS_META).map(([value, meta]) => (
<option key={value} value={value}>
{meta.label}
</option>
))}
</Select>
onValueChange={(value) => onStatusChange?.(value as WorkflowFilterStatus)}
/>
</div>
<div className="flex items-center gap-3">

View File

@@ -0,0 +1,69 @@
import type { LibraryItemVM, LibraryType } from "@/lib/types/view-models";
export type BackendLibraryResourceType = "model" | "scene" | "garment";
export type BackendLibraryResourceStatus = "active" | "archived";
export type BackendLibraryFileRole = "original" | "thumbnail" | "gallery";
export type BackendLibraryResourceFile = {
id: number;
file_role: BackendLibraryFileRole;
storage_key: string;
public_url: string;
bucket: string;
mime_type: string;
size_bytes: number;
sort_order: number;
width?: number | null;
height?: number | null;
created_at: string;
};
export type BackendLibraryResource = {
id: number;
resource_type: BackendLibraryResourceType;
name: string;
description: string | null;
tags: string[];
status: BackendLibraryResourceStatus;
gender?: string | null;
age_group?: string | null;
pose_id?: number | null;
environment?: string | null;
category?: string | null;
cover_url: string | null;
original_url: string | null;
files: BackendLibraryResourceFile[];
created_at: string;
updated_at: string;
};
export type BackendLibraryListResponse = {
total: number;
items: BackendLibraryResource[];
};
const LIBRARY_TYPE_BY_BACKEND_TYPE: Record<BackendLibraryResourceType, LibraryType> = {
model: "models",
scene: "scenes",
garment: "garments",
};
export function adaptLibraryItem(item: BackendLibraryResource): LibraryItemVM {
return {
id: String(item.id),
libraryType: LIBRARY_TYPE_BY_BACKEND_TYPE[item.resource_type],
name: item.name,
description: item.description ?? "",
previewUri: item.cover_url ?? item.original_url ?? "",
originalUri: item.original_url ?? undefined,
files: item.files.map((file) => ({
id: file.id,
role: file.file_role,
url: file.public_url,
})),
tags: item.tags,
isMock: false,
backendId: item.id,
poseId: item.pose_id ?? null,
};
}

View File

@@ -61,7 +61,7 @@ export type CreateOrderRequestDto = {
customer_level: CustomerLevel;
service_mode: ServiceMode;
model_id: number;
pose_id: number;
pose_id?: number;
garment_asset_id: number;
scene_ref_asset_id: number;
};
@@ -92,7 +92,7 @@ export type OrderDetailResponseDto = {
service_mode: ServiceMode;
status: OrderStatus;
model_id: number;
pose_id: number;
pose_id: number | null;
garment_asset_id: number;
scene_ref_asset_id: number;
final_asset_id: number | null;

View File

@@ -65,7 +65,7 @@ export type OrderDetailVM = {
currentStep: WorkflowStepName | null;
currentStepLabel: string;
modelId: number;
poseId: number;
poseId: number | null;
garmentAssetId: number;
sceneRefAssetId: number;
currentRevisionAssetId: number | null;
@@ -206,14 +206,24 @@ export type WorkflowDetailVM = {
export type LibraryType = "models" | "scenes" | "garments";
export type LibraryFileVM = {
id: number;
role: "original" | "thumbnail" | "gallery";
url: string;
};
export type LibraryItemVM = {
id: string;
libraryType: LibraryType;
name: string;
description: string;
previewUri: string;
originalUri?: string;
tags: string[];
files?: LibraryFileVM[];
isMock: boolean;
backendId?: number;
poseId?: number | null;
};
export const READY_STATE: ReadyState = {

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -8,7 +8,7 @@ export const createOrderSchema = z
customer_level: z.enum(["low", "mid"]),
service_mode: z.enum(["auto_basic", "semi_pro"]),
model_id: z.number().int().positive(),
pose_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(),
})

View File

@@ -0,0 +1,150 @@
import { z } from "zod";
import { RouteError } from "@/lib/http/response";
export const backendLibraryResourceTypeSchema = z.enum(["model", "scene", "garment"]);
export const backendLibraryFileRoleSchema = z.enum(["original", "thumbnail", "gallery"]);
const presignUploadSchema = z.object({
resourceType: backendLibraryResourceTypeSchema,
fileRole: backendLibraryFileRoleSchema,
fileName: z.string().trim().min(1),
contentType: z.string().trim().min(1),
});
const createLibraryFileSchema = z.object({
fileRole: backendLibraryFileRoleSchema,
storageKey: z.string().trim().min(1),
publicUrl: z.string().trim().min(1),
mimeType: z.string().trim().min(1),
sizeBytes: z.number().int().nonnegative(),
sortOrder: z.number().int().nonnegative().default(0),
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
});
const createLibraryResourceSchema = z.object({
name: z.string().trim().min(1),
description: z.string().trim().optional().default(""),
tags: z.array(z.string().trim()).default([]),
gender: z.string().trim().optional(),
ageGroup: z.string().trim().optional(),
poseId: z.number().int().positive().optional(),
environment: z.string().trim().optional(),
category: z.string().trim().optional(),
files: z.array(createLibraryFileSchema).min(2),
});
const updateLibraryResourceSchema = z
.object({
name: z.string().trim().min(1).optional(),
description: z.string().trim().optional(),
tags: z.array(z.string().trim()).optional(),
gender: z.string().trim().optional(),
ageGroup: z.string().trim().optional(),
poseId: z.number().int().positive().optional(),
environment: z.string().trim().optional(),
category: z.string().trim().optional(),
coverFileId: z.number().int().positive().optional(),
})
.refine((value) => Object.keys(value).length > 0, {
message: "至少提供一个可更新字段。",
});
export type CreateLibraryResourcePayload = z.infer<typeof createLibraryResourceSchema>;
export type UpdateLibraryResourcePayload = z.infer<typeof updateLibraryResourceSchema>;
export function parseLibraryUploadPresignPayload(payload: unknown) {
const result = presignUploadSchema.safeParse(payload);
if (!result.success) {
throw new RouteError(
400,
"VALIDATION_ERROR",
"上传参数不合法。",
result.error.flatten(),
);
}
return {
resource_type: result.data.resourceType,
file_role: result.data.fileRole,
file_name: result.data.fileName,
content_type: result.data.contentType,
};
}
export function parseCreateLibraryResourcePayload(
payload: unknown,
resourceType: z.infer<typeof backendLibraryResourceTypeSchema>,
) {
const result = createLibraryResourceSchema.safeParse(payload);
if (!result.success) {
throw new RouteError(
400,
"VALIDATION_ERROR",
"资源参数不合法。",
result.error.flatten(),
);
}
return {
resource_type: resourceType,
name: result.data.name,
description: result.data.description,
tags: result.data.tags.filter(Boolean),
gender: result.data.gender,
age_group: result.data.ageGroup,
pose_id: result.data.poseId,
environment: result.data.environment,
category: result.data.category,
files: result.data.files.map((file) => ({
file_role: file.fileRole,
storage_key: file.storageKey,
public_url: file.publicUrl,
mime_type: file.mimeType,
size_bytes: file.sizeBytes,
sort_order: file.sortOrder,
width: file.width,
height: file.height,
})),
};
}
export function parseUpdateLibraryResourcePayload(payload: unknown) {
const result = updateLibraryResourceSchema.safeParse(payload);
if (!result.success) {
throw new RouteError(
400,
"VALIDATION_ERROR",
"资源更新参数不合法。",
result.error.flatten(),
);
}
return {
...(result.data.name !== undefined ? { name: result.data.name } : {}),
...(result.data.description !== undefined
? { description: result.data.description }
: {}),
...(result.data.tags !== undefined
? { tags: result.data.tags.filter(Boolean) }
: {}),
...(result.data.gender !== undefined ? { gender: result.data.gender } : {}),
...(result.data.ageGroup !== undefined
? { age_group: result.data.ageGroup }
: {}),
...(result.data.poseId !== undefined ? { pose_id: result.data.poseId } : {}),
...(result.data.environment !== undefined
? { environment: result.data.environment }
: {}),
...(result.data.category !== undefined
? { category: result.data.category }
: {}),
...(result.data.coverFileId !== undefined
? { cover_file_id: result.data.coverFileId }
: {}),
};
}

View File

@@ -1,8 +1,61 @@
import { expect, test } from "vitest";
import { beforeEach, expect, test, vi } from "vitest";
import { GET } from "../../../app/api/libraries/[libraryType]/route";
import { GET, POST } from "../../../app/api/libraries/[libraryType]/route";
import { RouteError } from "@/lib/http/response";
const { backendRequestMock } = vi.hoisted(() => ({
backendRequestMock: vi.fn(),
}));
vi.mock("@/lib/http/backend-client", () => ({
backendRequest: backendRequestMock,
}));
beforeEach(() => {
backendRequestMock.mockReset();
});
test("proxies backend library resources into the existing frontend view-model shape", async () => {
backendRequestMock.mockResolvedValue({
status: 200,
data: {
total: 1,
items: [
{
id: 12,
resource_type: "model",
name: "Ava Studio",
description: "棚拍女模特",
tags: ["女装", "棚拍"],
status: "active",
gender: "female",
age_group: "adult",
environment: null,
category: null,
files: [
{
id: 1,
file_role: "thumbnail",
storage_key: "library/models/ava/thumb.png",
public_url: "https://images.marcusd.me/library/models/ava/thumb.png",
bucket: "images",
mime_type: "image/png",
size_bytes: 2345,
sort_order: 0,
width: 480,
height: 600,
created_at: "2026-03-28T10:00:00Z",
},
],
cover_url: "https://images.marcusd.me/library/models/ava/thumb.png",
original_url: "https://images.marcusd.me/library/models/ava/original.png",
created_at: "2026-03-28T10:00:00Z",
updated_at: "2026-03-28T10:00:00Z",
},
],
},
});
test("returns honest placeholder library data for unsupported backend modules", async () => {
const response = await GET(new Request("http://localhost/api/libraries/models"), {
params: Promise.resolve({ libraryType: "models" }),
});
@@ -10,17 +63,23 @@ test("returns honest placeholder library data for unsupported backend modules",
expect(response.status).toBe(200);
expect(payload).toMatchObject({
mode: "placeholder",
mode: "proxy",
data: {
items: expect.arrayContaining([
expect.objectContaining({
id: "12",
libraryType: "models",
isMock: true,
name: "Ava Studio",
previewUri: "https://images.marcusd.me/library/models/ava/thumb.png",
isMock: false,
}),
]),
},
message: "资源库当前使用占位数据,真实后端接口尚未提供。",
message: "资源库当前显示真实后端数据。",
});
expect(backendRequestMock).toHaveBeenCalledWith(
"/library/resources?resource_type=model&limit=100",
);
});
test("rejects unsupported placeholder library types with a normalized error", async () => {
@@ -48,3 +107,152 @@ test("rejects inherited object keys instead of treating them as valid library ty
message: "不支持的资源库类型。",
});
});
test("normalizes backend errors while proxying library resources", async () => {
backendRequestMock.mockRejectedValue(
new RouteError(502, "BACKEND_UNAVAILABLE", "后端暂时不可用,请稍后重试。"),
);
const response = await GET(new Request("http://localhost/api/libraries/models"), {
params: Promise.resolve({ libraryType: "models" }),
});
const payload = await response.json();
expect(response.status).toBe(502);
expect(payload).toEqual({
error: "BACKEND_UNAVAILABLE",
message: "后端暂时不可用,请稍后重试。",
});
});
test("proxies library resource creation and adapts the created item into the existing view-model shape", async () => {
backendRequestMock.mockResolvedValue({
status: 201,
data: {
id: 12,
resource_type: "model",
name: "Ava Studio",
description: "棚拍女模特",
tags: ["女装", "棚拍"],
status: "active",
gender: "female",
age_group: "adult",
pose_id: null,
environment: null,
category: null,
files: [
{
id: 1,
file_role: "thumbnail",
storage_key: "library/models/ava/thumb.png",
public_url: "https://images.marcusd.me/library/models/ava/thumb.png",
bucket: "images",
mime_type: "image/png",
size_bytes: 2345,
sort_order: 0,
width: 480,
height: 600,
created_at: "2026-03-28T10:00:00Z",
},
],
cover_url: "https://images.marcusd.me/library/models/ava/thumb.png",
original_url: "https://images.marcusd.me/library/models/ava/original.png",
created_at: "2026-03-28T10:00:00Z",
updated_at: "2026-03-28T10:00:00Z",
},
});
const response = await POST(
new Request("http://localhost/api/libraries/models", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
name: "Ava Studio",
description: "棚拍女模特",
tags: ["女装", "棚拍"],
gender: "female",
ageGroup: "adult",
files: [
{
fileRole: "original",
storageKey: "library/models/2026/ava-original.png",
publicUrl: "https://images.marcusd.me/library/models/ava/original.png",
mimeType: "image/png",
sizeBytes: 12345,
sortOrder: 0,
},
{
fileRole: "thumbnail",
storageKey: "library/models/2026/ava-thumb.png",
publicUrl: "https://images.marcusd.me/library/models/ava/thumb.png",
mimeType: "image/png",
sizeBytes: 2345,
sortOrder: 0,
},
],
}),
}),
{
params: Promise.resolve({ libraryType: "models" }),
},
);
const payload = await response.json();
expect(response.status).toBe(201);
expect(payload).toEqual({
mode: "proxy",
data: {
item: {
id: "12",
libraryType: "models",
name: "Ava Studio",
description: "棚拍女模特",
previewUri: "https://images.marcusd.me/library/models/ava/thumb.png",
originalUri: "https://images.marcusd.me/library/models/ava/original.png",
tags: ["女装", "棚拍"],
files: [
{
id: 1,
role: "thumbnail",
url: "https://images.marcusd.me/library/models/ava/thumb.png",
},
],
isMock: false,
backendId: 12,
poseId: null,
},
},
message: "资源创建成功。",
});
expect(backendRequestMock).toHaveBeenCalledWith("/library/resources", {
method: "POST",
body: JSON.stringify({
resource_type: "model",
name: "Ava Studio",
description: "棚拍女模特",
tags: ["女装", "棚拍"],
gender: "female",
age_group: "adult",
files: [
{
file_role: "original",
storage_key: "library/models/2026/ava-original.png",
public_url: "https://images.marcusd.me/library/models/ava/original.png",
mime_type: "image/png",
size_bytes: 12345,
sort_order: 0,
},
{
file_role: "thumbnail",
storage_key: "library/models/2026/ava-thumb.png",
public_url: "https://images.marcusd.me/library/models/ava/thumb.png",
mime_type: "image/png",
size_bytes: 2345,
sort_order: 0,
},
],
}),
});
});

View File

@@ -0,0 +1,117 @@
import { beforeEach, expect, test, vi } from "vitest";
import { DELETE, PATCH } from "../../../app/api/libraries/[libraryType]/[resourceId]/route";
const { backendRequestMock } = vi.hoisted(() => ({
backendRequestMock: vi.fn(),
}));
vi.mock("@/lib/http/backend-client", () => ({
backendRequest: backendRequestMock,
}));
beforeEach(() => {
backendRequestMock.mockReset();
});
test("proxies resource updates and adapts the updated item into the library view-model", async () => {
backendRequestMock.mockResolvedValue({
status: 200,
data: {
id: 12,
resource_type: "model",
name: "Ava Studio Updated",
description: "新的描述",
tags: ["女装", "更新"],
status: "active",
gender: "female",
age_group: "adult",
pose_id: null,
environment: null,
category: null,
files: [],
cover_url: "https://images.marcusd.me/library/models/ava/thumb.png",
original_url: "https://images.marcusd.me/library/models/ava/original.png",
created_at: "2026-03-28T10:00:00Z",
updated_at: "2026-03-28T11:00:00Z",
},
});
const response = await PATCH(
new Request("http://localhost/api/libraries/models/12", {
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
name: "Ava Studio Updated",
description: "新的描述",
tags: ["女装", "更新"],
}),
}),
{
params: Promise.resolve({ libraryType: "models", resourceId: "12" }),
},
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload).toEqual({
mode: "proxy",
data: {
item: {
id: "12",
libraryType: "models",
name: "Ava Studio Updated",
description: "新的描述",
previewUri: "https://images.marcusd.me/library/models/ava/thumb.png",
originalUri: "https://images.marcusd.me/library/models/ava/original.png",
tags: ["女装", "更新"],
files: [],
isMock: false,
backendId: 12,
poseId: null,
},
},
message: "资源更新成功。",
});
expect(backendRequestMock).toHaveBeenCalledWith("/library/resources/12", {
method: "PATCH",
body: JSON.stringify({
name: "Ava Studio Updated",
description: "新的描述",
tags: ["女装", "更新"],
}),
});
});
test("soft deletes a library resource through the backend proxy", async () => {
backendRequestMock.mockResolvedValue({
status: 200,
data: {
id: 12,
},
});
const response = await DELETE(
new Request("http://localhost/api/libraries/models/12", {
method: "DELETE",
}),
{
params: Promise.resolve({ libraryType: "models", resourceId: "12" }),
},
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload).toEqual({
mode: "proxy",
data: {
id: "12",
},
message: "资源已移入归档。",
});
expect(backendRequestMock).toHaveBeenCalledWith("/library/resources/12", {
method: "DELETE",
});
});

View File

@@ -0,0 +1,94 @@
import { beforeEach, expect, test, vi } from "vitest";
import { POST } from "../../../app/api/libraries/uploads/presign/route";
const { backendRequestMock } = vi.hoisted(() => ({
backendRequestMock: vi.fn(),
}));
vi.mock("@/lib/http/backend-client", () => ({
backendRequest: backendRequestMock,
}));
beforeEach(() => {
backendRequestMock.mockReset();
});
test("proxies upload presign requests and normalizes the response for the frontend", async () => {
backendRequestMock.mockResolvedValue({
status: 200,
data: {
method: "PUT",
upload_url: "https://s3.example.com/presigned-put",
headers: {
"content-type": "image/png",
},
storage_key: "library/models/2026/ava-original.png",
public_url: "https://images.example.com/library/models/2026/ava-original.png",
},
});
const response = await POST(
new Request("http://localhost/api/libraries/uploads/presign", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
resourceType: "model",
fileRole: "original",
fileName: "ava-original.png",
contentType: "image/png",
}),
}),
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload).toEqual({
mode: "proxy",
data: {
method: "PUT",
uploadUrl: "https://s3.example.com/presigned-put",
headers: {
"content-type": "image/png",
},
storageKey: "library/models/2026/ava-original.png",
publicUrl: "https://images.example.com/library/models/2026/ava-original.png",
},
});
expect(backendRequestMock).toHaveBeenCalledWith("/library/uploads/presign", {
method: "POST",
body: JSON.stringify({
resource_type: "model",
file_role: "original",
file_name: "ava-original.png",
content_type: "image/png",
}),
});
});
test("rejects invalid presign payloads before proxying", async () => {
const response = await POST(
new Request("http://localhost/api/libraries/uploads/presign", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
resourceType: "unknown",
fileRole: "original",
fileName: "",
contentType: "image/png",
}),
}),
);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload).toMatchObject({
error: "VALIDATION_ERROR",
message: "上传参数不合法。",
});
expect(backendRequestMock).not.toHaveBeenCalled();
});

View File

@@ -37,7 +37,6 @@ test("proxies order creation to the backend and returns normalized success data"
customer_level: "mid",
service_mode: "semi_pro",
model_id: 101,
pose_id: 202,
garment_asset_id: 303,
scene_ref_asset_id: 404,
}),
@@ -76,7 +75,6 @@ test("rejects invalid order creation payloads before proxying", async () => {
customer_level: "low",
service_mode: "semi_pro",
model_id: 101,
pose_id: 202,
garment_asset_id: 303,
scene_ref_asset_id: 404,
}),
@@ -143,7 +141,6 @@ test("normalizes upstream validation errors from the backend", async () => {
customer_level: "mid",
service_mode: "semi_pro",
model_id: 101,
pose_id: 202,
garment_asset_id: 303,
scene_ref_asset_id: 404,
}),

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,
}),

View File

@@ -8,12 +8,14 @@ test("renders a dense toolbar row with compact controls", () => {
render(
<PageToolbar>
<Input aria-label="search" />
<Select aria-label="status">
<option value="all"></option>
</Select>
<Select
aria-label="status"
options={[{ value: "all", label: "全部状态" }]}
value="all"
/>
</PageToolbar>,
);
expect(screen.getByLabelText("search").className).toContain("h-9");
expect(screen.getByLabelText("status").className).toContain("h-9");
expect(screen.getByRole("combobox", { name: "status" }).className).toContain("h-9");
});

32
tests/ui/select.test.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { Select } from "@/components/ui/select";
test("renders a shadcn-style popover select and emits the chosen value", () => {
const onValueChange = vi.fn();
render(
<Select
aria-label="status"
options={[
{ value: "all", label: "全部状态" },
{ value: "waiting_review", label: "待审核" },
]}
placeholder="请选择状态"
value="all"
onValueChange={onValueChange}
/>,
);
const trigger = screen.getByRole("combobox", { name: "status" });
expect(trigger.className).toContain("inline-flex");
expect(trigger.className).toContain("justify-between");
fireEvent.click(trigger);
expect(screen.getByRole("option", { name: "待审核" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("option", { name: "待审核" }));
expect(onValueChange).toHaveBeenCalledWith("waiting_review");
});

View File

@@ -1 +1,17 @@
import "@testing-library/jest-dom/vitest";
if (!HTMLElement.prototype.hasPointerCapture) {
HTMLElement.prototype.hasPointerCapture = () => false;
}
if (!HTMLElement.prototype.setPointerCapture) {
HTMLElement.prototype.setPointerCapture = () => {};
}
if (!HTMLElement.prototype.releasePointerCapture) {
HTMLElement.prototype.releasePointerCapture = () => {};
}
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = () => {};
}