feat: connect resource library workflows
This commit is contained in:
94
app/api/libraries/[libraryType]/[resourceId]/route.ts
Normal file
94
app/api/libraries/[libraryType]/[resourceId]/route.ts
Normal 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: "资源已移入归档。",
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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: "资源创建成功。",
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
37
app/api/libraries/uploads/presign/route.ts
Normal file
37
app/api/libraries/uploads/presign/route.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
89
docs/superpowers/plans/2026-03-28-resource-library.md
Normal file
89
docs/superpowers/plans/2026-03-28-resource-library.md
Normal 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**
|
||||
49
docs/superpowers/specs/2026-03-28-resource-library-design.md
Normal file
49
docs/superpowers/specs/2026-03-28-resource-library-design.md
Normal 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
2
next-env.d.ts
vendored
@@ -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
1602
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
136
src/components/ui/alert-dialog.tsx
Normal file
136
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
249
src/features/libraries/components/library-edit-modal.tsx
Normal file
249
src/features/libraries/components/library-edit-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
341
src/features/libraries/components/library-upload-modal.tsx
Normal file
341
src/features/libraries/components/library-upload-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
101
src/features/libraries/manage-resource.ts
Normal file
101
src/features/libraries/manage-resource.ts
Normal 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;
|
||||
}
|
||||
230
src/features/libraries/upload-resource.ts
Normal file
230
src/features/libraries/upload-resource.ts
Normal 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 };
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`
|
||||
: "完成选择后显示"
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
69
src/lib/adapters/libraries.ts
Normal file
69
src/lib/adapters/libraries.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
6
src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
150
src/lib/validation/library-resource.ts
Normal file
150
src/lib/validation/library-resource.ts
Normal 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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
117
tests/app/api/library-resource-item.route.test.ts
Normal file
117
tests/app/api/library-resource-item.route.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
94
tests/app/api/library-uploads-presign.route.test.ts
Normal file
94
tests/app/api/library-uploads-presign.route.test.ts
Normal 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();
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
71
tests/features/libraries/library-edit-modal.test.tsx
Normal file
71
tests/features/libraries/library-edit-modal.test.tsx
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
96
tests/features/libraries/manage-resource.test.ts
Normal file
96
tests/features/libraries/manage-resource.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
173
tests/features/libraries/upload-resource.test.ts
Normal file
173
tests/features/libraries/upload-resource.test.ts
Normal 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);
|
||||
});
|
||||
48
tests/features/orders/resource-picker-options.test.ts
Normal file
48
tests/features/orders/resource-picker-options.test.ts
Normal 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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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
32
tests/ui/select.test.tsx
Normal 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");
|
||||
});
|
||||
@@ -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 = () => {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user