Compare commits
10 Commits
24a7543e91
...
d09491cd8a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d09491cd8a | ||
|
|
162d3e12d2 | ||
|
|
c604e6ace1 | ||
|
|
59d3f4d054 | ||
|
|
ae8ab2cf9c | ||
|
|
edd03b03a7 | ||
|
|
f2deb54f3a | ||
|
|
025ae31f9f | ||
|
|
4ca3ef96b9 | ||
|
|
ded6555dbc |
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
Standalone Next.js admin frontend for the virtual try-on workflow.
|
Standalone Next.js admin frontend for the virtual try-on workflow.
|
||||||
|
|
||||||
|
## UI Direction
|
||||||
|
|
||||||
|
- Shared admin shell uses a compact dense-console layout instead of wide hero-card framing.
|
||||||
|
- `orders`, `reviews`, and `workflows` prioritize toolbar + table + detail patterns for desktop operators.
|
||||||
|
- Review detail keeps image inspection on the left and grouped decision actions on the right.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Node.js 20+
|
- Node.js 20+
|
||||||
@@ -82,8 +88,6 @@ Real integration pages:
|
|||||||
|
|
||||||
Placeholder or transitional pages:
|
Placeholder or transitional pages:
|
||||||
|
|
||||||
- `/orders`
|
|
||||||
- `/workflows`
|
|
||||||
- `/libraries/models`
|
- `/libraries/models`
|
||||||
- `/libraries/scenes`
|
- `/libraries/scenes`
|
||||||
- `/libraries/garments`
|
- `/libraries/garments`
|
||||||
|
|||||||
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 {
|
import {
|
||||||
GARMENT_LIBRARY_FIXTURES,
|
adaptLibraryItem,
|
||||||
MODEL_LIBRARY_FIXTURES,
|
type BackendLibraryListResponse,
|
||||||
SCENE_LIBRARY_FIXTURES,
|
type BackendLibraryResource,
|
||||||
} from "@/lib/mock/libraries";
|
} from "@/lib/adapters/libraries";
|
||||||
import { RouteError, jsonSuccess, withErrorHandling } from "@/lib/http/response";
|
import { backendRequest } from "@/lib/http/backend-client";
|
||||||
import type { LibraryItemVM, LibraryType } from "@/lib/types/view-models";
|
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 = {
|
type RouteContext = {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -12,16 +19,16 @@ type RouteContext = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LIBRARY_FIXTURE_MAP: Record<LibraryType, LibraryItemVM[]> = {
|
const BACKEND_LIBRARY_TYPE_MAP: Record<LibraryType, "model" | "scene" | "garment"> = {
|
||||||
models: MODEL_LIBRARY_FIXTURES,
|
models: "model",
|
||||||
scenes: SCENE_LIBRARY_FIXTURES,
|
scenes: "scene",
|
||||||
garments: GARMENT_LIBRARY_FIXTURES,
|
garments: "garment",
|
||||||
};
|
};
|
||||||
|
|
||||||
const MESSAGE = "资源库当前使用占位数据,真实后端接口尚未提供。";
|
const MESSAGE = "资源库当前显示真实后端数据。";
|
||||||
|
|
||||||
function isLibraryType(value: string): value is LibraryType {
|
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) {
|
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", "不支持的资源库类型。");
|
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(
|
return jsonSuccess(
|
||||||
{
|
{
|
||||||
items: LIBRARY_FIXTURE_MAP[libraryType],
|
items: response.data.items.map(adaptLibraryItem),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
mode: "placeholder",
|
|
||||||
message: MESSAGE,
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--app-bg: #f6f1e8;
|
||||||
--bg-canvas: #f6f1e8;
|
--bg-canvas: #f6f1e8;
|
||||||
--bg-canvas-strong: #efe5d7;
|
--bg-canvas-strong: #efe5d7;
|
||||||
--bg-elevated: rgba(255, 250, 243, 0.86);
|
--bg-elevated: rgba(255, 250, 243, 0.86);
|
||||||
@@ -23,6 +24,9 @@
|
|||||||
--border-strong: rgba(82, 71, 57, 0.24);
|
--border-strong: rgba(82, 71, 57, 0.24);
|
||||||
--shadow-shell: 0 28px 80px rgba(47, 38, 28, 0.12);
|
--shadow-shell: 0 28px 80px rgba(47, 38, 28, 0.12);
|
||||||
--shadow-card: 0 18px 40px rgba(62, 46, 27, 0.08);
|
--shadow-card: 0 18px 40px rgba(62, 46, 27, 0.08);
|
||||||
|
--page-gap: 16px;
|
||||||
|
--panel-radius: 14px;
|
||||||
|
--control-height: 38px;
|
||||||
--font-sans:
|
--font-sans:
|
||||||
"Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
|
"Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
|
||||||
sans-serif;
|
sans-serif;
|
||||||
|
|||||||
@@ -0,0 +1,459 @@
|
|||||||
|
# Auto Virtual Tryon Admin Frontend Shadcn Rewrite 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:** Rebuild the frontend admin UI around a denser shadcn-style console shell, a clearer review workflow, and real high-density list pages for orders and workflows.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing App Router routes, BFF handlers, adapters, and backend contracts intact while replacing the card-heavy presentation layer with shared shell, toolbar, and table primitives. Migrate in layers: first the dashboard shell and reusable UI primitives, then the review flow, then the orders and workflows list pages, so each step lands on top of a stable layout system instead of reworking page code twice.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js App Router, React, TypeScript, Tailwind CSS v4, Vitest, React Testing Library, jsdom, Lucide React
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Rebuild The Shared Dashboard Shell
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/layout/dashboard-shell.tsx`
|
||||||
|
- Modify: `src/components/layout/nav-config.ts`
|
||||||
|
- Modify: `src/components/ui/page-header.tsx`
|
||||||
|
- Modify: `app/globals.css`
|
||||||
|
- Test: `tests/ui/dashboard-shell.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing shell-density tests**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
test("uses a narrow rail and full-width desktop shell", () => {
|
||||||
|
render(<DashboardShell>content</DashboardShell>);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Dashboard rail").className).toContain(
|
||||||
|
"md:w-[228px]",
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Dashboard content").className).toContain(
|
||||||
|
"md:overflow-y-auto",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the shell test to confirm it fails**
|
||||||
|
|
||||||
|
Run: `npm run test -- tests/ui/dashboard-shell.test.tsx`
|
||||||
|
Expected: FAIL because the existing shell still uses the wide `280px` rail, large radii, and old container classes
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite the shell layout to use a thinner rail and full-width main area**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="min-h-screen bg-[var(--app-bg)] text-[var(--ink)] md:h-screen md:overflow-hidden">
|
||||||
|
<div className="grid h-full md:grid-cols-[228px_minmax(0,1fr)]">
|
||||||
|
<aside className="border-r border-[var(--shell-border)] bg-[var(--shell)] md:h-full" />
|
||||||
|
<main className="min-h-0 md:h-full md:overflow-y-auto" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Tighten the shared header spacing and typography**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<header className="flex items-start justify-between gap-4 border-b border-[var(--border-soft)] pb-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em]">
|
||||||
|
{eyebrow}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-[-0.03em]">{title}</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update shell variables and density tokens in global CSS**
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--page-gap: 16px;
|
||||||
|
--panel-radius: 14px;
|
||||||
|
--control-height: 38px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Re-run the shell test**
|
||||||
|
|
||||||
|
Run: `npm run test -- tests/ui/dashboard-shell.test.tsx`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit the shell rewrite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/layout/dashboard-shell.tsx src/components/layout/nav-config.ts src/components/ui/page-header.tsx app/globals.css tests/ui/dashboard-shell.test.tsx
|
||||||
|
git commit -m "feat: tighten dashboard shell density"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Add Shared Dense Console Primitives
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/ui/input.tsx`
|
||||||
|
- Create: `src/components/ui/select.tsx`
|
||||||
|
- Create: `src/components/ui/table.tsx`
|
||||||
|
- Create: `src/components/ui/separator.tsx`
|
||||||
|
- Create: `src/components/ui/page-toolbar.tsx`
|
||||||
|
- Create: `src/components/ui/metric-chip.tsx`
|
||||||
|
- Modify: `src/components/ui/button.tsx`
|
||||||
|
- Modify: `src/components/ui/card.tsx`
|
||||||
|
- Modify: `src/components/ui/status-badge.tsx`
|
||||||
|
- Test: `tests/ui/status-badge.test.tsx`
|
||||||
|
- Create: `tests/ui/page-toolbar.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for compact toolbar and compact badge behavior**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
test("renders a dense toolbar row with compact controls", () => {
|
||||||
|
render(
|
||||||
|
<PageToolbar>
|
||||||
|
<Input aria-label="search" />
|
||||||
|
<Select aria-label="status" />
|
||||||
|
</PageToolbar>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("search").className).toContain("h-9");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the toolbar and badge tests to confirm they fail**
|
||||||
|
|
||||||
|
Run: `npm run test -- tests/ui/page-toolbar.test.tsx tests/ui/status-badge.test.tsx`
|
||||||
|
Expected: FAIL because the new primitives do not exist yet and current badge sizing is larger than the dense target
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the shared compact primitives**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function PageToolbar({ children }: PropsWithChildren) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-b border-[var(--border-soft)] pb-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Normalize button, card, and status badge sizes to the new density**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const buttonVariants = {
|
||||||
|
default: "h-9 rounded-md px-3 text-sm",
|
||||||
|
secondary: "h-9 rounded-md px-3 text-sm",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-run the toolbar and badge tests**
|
||||||
|
|
||||||
|
Run: `npm run test -- tests/ui/page-toolbar.test.tsx tests/ui/status-badge.test.tsx`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the shared primitives**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/ui tests/ui
|
||||||
|
git commit -m "feat: add dense console ui primitives"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Rewrite The Review Queue Page As A High-Density Table
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/features/reviews/review-workbench-list.tsx`
|
||||||
|
- Modify: `src/features/reviews/components/review-queue.tsx`
|
||||||
|
- Create: `src/features/reviews/components/review-filters.tsx`
|
||||||
|
- Modify: `app/(dashboard)/reviews/workbench/page.tsx`
|
||||||
|
- Test: `tests/features/reviews/review-workbench-list.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the current list-page expectations with queue-table expectations**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
test("renders a compact review queue table with triage columns", async () => {
|
||||||
|
render(<ReviewWorkbenchListScreen />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("columnheader", { name: "订单号" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "修订状态" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "刷新队列" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the review queue test to confirm it fails**
|
||||||
|
|
||||||
|
Run: `npm run test -- tests/features/reviews/review-workbench-list.test.tsx`
|
||||||
|
Expected: FAIL because the current page still renders the old prose-heavy layout instead of the table and compact toolbar
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add a compact filter row for query, status, revision state, and refresh**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PageToolbar>
|
||||||
|
<Input aria-label="审核关键词搜索" />
|
||||||
|
<Select aria-label="审核状态筛选" />
|
||||||
|
<Select aria-label="修订状态筛选" />
|
||||||
|
<Button variant="secondary">刷新队列</Button>
|
||||||
|
</PageToolbar>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Rebuild the queue body as a compact table**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>订单号</TableHead>
|
||||||
|
<TableHead>workflowId</TableHead>
|
||||||
|
<TableHead>当前状态</TableHead>
|
||||||
|
<TableHead>当前步骤</TableHead>
|
||||||
|
<TableHead>修订状态</TableHead>
|
||||||
|
<TableHead>失败次数</TableHead>
|
||||||
|
<TableHead>更新时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
</Table>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-run the review queue test**
|
||||||
|
|
||||||
|
Run: `npm run test -- tests/features/reviews/review-workbench-list.test.tsx`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the queue rewrite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/features/reviews app/\(dashboard\)/reviews/workbench/page.tsx tests/features/reviews/review-workbench-list.test.tsx
|
||||||
|
git commit -m "feat: rewrite review queue as dense table"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Rewrite The Review Detail Page As A Decision Surface
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/features/reviews/review-workbench-detail.tsx`
|
||||||
|
- Modify: `src/features/reviews/components/review-action-panel.tsx`
|
||||||
|
- Modify: `src/features/reviews/components/review-image-panel.tsx`
|
||||||
|
- Modify: `src/features/reviews/components/review-revision-panel.tsx`
|
||||||
|
- Modify: `src/features/reviews/components/review-workflow-summary.tsx`
|
||||||
|
- Modify: `app/(dashboard)/reviews/workbench/[orderId]/page.tsx`
|
||||||
|
- Test: `tests/features/reviews/review-workbench-detail.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite the detail-page tests around sticky summary and two-column decision layout**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
test("keeps the decision actions visible next to the image workspace", async () => {
|
||||||
|
render(<ReviewWorkbenchDetailScreen orderId={101} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("返回审核列表")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("审核动作")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("人工修订")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the review detail test to confirm it fails**
|
||||||
|
|
||||||
|
Run: `npm run test -- tests/features/reviews/review-workbench-detail.test.tsx`
|
||||||
|
Expected: FAIL because the current detail page still renders the old header and stacked module layout
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the sticky summary bar and two-column page frame**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="sticky top-0 z-10 border-b border-[var(--border-soft)] bg-[var(--surface)]/95 backdrop-blur">
|
||||||
|
<div className="flex items-center justify-between gap-4 py-3">
|
||||||
|
<Button variant="secondary" asChild>
|
||||||
|
<Link href="/reviews/workbench">返回审核列表</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Collapse workflow timeline content into a compact summary block and keep primary actions grouped**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tabs defaultValue="audit">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="audit">审核动作</TabsTrigger>
|
||||||
|
<TabsTrigger value="revision">人工修订</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-run the review detail test**
|
||||||
|
|
||||||
|
Run: `npm run test -- tests/features/reviews/review-workbench-detail.test.tsx`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the review detail rewrite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/features/reviews app/\(dashboard\)/reviews/workbench/\[orderId\]/page.tsx tests/features/reviews/review-workbench-detail.test.tsx
|
||||||
|
git commit -m "feat: rebuild review detail decision surface"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Rewrite Orders As A Real Dense List Page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/features/orders/orders-home.tsx`
|
||||||
|
- Create: `src/features/orders/components/orders-toolbar.tsx`
|
||||||
|
- Create: `src/features/orders/components/orders-table.tsx`
|
||||||
|
- Modify: `app/(dashboard)/orders/page.tsx`
|
||||||
|
- Test: `tests/features/orders/orders-home.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the current orders-home expectations with dense-table expectations**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
test("renders orders as a high-density table with shared toolbar controls", async () => {
|
||||||
|
render(<OrdersHome recentOrders={[order]} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("columnheader", { name: "订单号" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "服务模式" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("订单状态筛选")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the orders-home test to confirm it fails**
|
||||||
|
|
||||||
|
Run: `npm run test -- tests/features/orders/orders-home.test.tsx`
|
||||||
|
Expected: FAIL because the current page still renders direct-lookup and recent-visits cards rather than a dense list table
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extract the compact toolbar with keyword, status, service mode, and pagination controls**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<OrdersToolbar
|
||||||
|
query={selectedQuery}
|
||||||
|
status={selectedStatus}
|
||||||
|
onQuerySubmit={onQuerySubmit}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace the card list with a real orders table and compact row actions**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<OrdersTable
|
||||||
|
items={recentOrders}
|
||||||
|
onOpenOrder={onOpenOrder}
|
||||||
|
onOpenWorkflow={onOpenWorkflow}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-run the orders-home test**
|
||||||
|
|
||||||
|
Run: `npm run test -- tests/features/orders/orders-home.test.tsx`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the orders page rewrite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/features/orders app/\(dashboard\)/orders/page.tsx tests/features/orders/orders-home.test.tsx
|
||||||
|
git commit -m "feat: rewrite orders page as dense list"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Rewrite Workflows As A Real Dense List Page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/features/workflows/workflow-lookup.tsx`
|
||||||
|
- Create: `src/features/workflows/components/workflow-toolbar.tsx`
|
||||||
|
- Create: `src/features/workflows/components/workflow-table.tsx`
|
||||||
|
- Modify: `app/(dashboard)/workflows/page.tsx`
|
||||||
|
- Test: `tests/features/workflows/workflow-lookup.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the current workflow-page expectations with dense-table expectations**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
test("renders workflows as a high-density table with shared toolbar controls", async () => {
|
||||||
|
render(<WorkflowLookup items={[workflow]} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("columnheader", { name: "流程类型" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "失败次数" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("流程状态筛选")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the workflow-page test to confirm it fails**
|
||||||
|
|
||||||
|
Run: `npm run test -- tests/features/workflows/workflow-lookup.test.tsx`
|
||||||
|
Expected: FAIL because the current page still renders direct-lookup and placeholder index cards rather than a dense table
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extract the compact workflow toolbar**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<WorkflowToolbar
|
||||||
|
query={selectedQuery}
|
||||||
|
status={selectedStatus}
|
||||||
|
onQuerySubmit={onQuerySubmit}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace the workflow card list with a diagnostic table**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<WorkflowTable items={items} onOpenWorkflow={onOpenWorkflow} />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-run the workflow-page test**
|
||||||
|
|
||||||
|
Run: `npm run test -- tests/features/workflows/workflow-lookup.test.tsx`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the workflows page rewrite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/features/workflows app/\(dashboard\)/workflows/page.tsx tests/features/workflows/workflow-lookup.test.tsx
|
||||||
|
git commit -m "feat: rewrite workflows page as dense list"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: Clean Up Old Oversized Patterns And Verify The Whole Rewrite
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/features/orders/components/order-summary-card.tsx`
|
||||||
|
- Modify: `src/features/orders/components/order-workflow-card.tsx`
|
||||||
|
- Modify: `src/features/workflows/components/workflow-status-card.tsx`
|
||||||
|
- Modify: `src/features/workflows/components/workflow-timeline.tsx`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Test: `tests/features/orders/order-detail.test.tsx`
|
||||||
|
- Test: `tests/features/workflows/workflow-detail.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write or adjust any failing detail-page tests caused by the density-system changes**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
test("keeps order detail summaries compact under the new density rules", () => {
|
||||||
|
render(<OrderDetailScreen orderId={101} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("订单摘要")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the affected detail tests to confirm what regressed**
|
||||||
|
|
||||||
|
Run: `npm run test -- tests/features/orders/order-detail.test.tsx tests/features/workflows/workflow-detail.test.tsx`
|
||||||
|
Expected: FAIL only if the shared primitive changes require updated structure or copy assertions
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove any leftover oversized radii, padding, and stacked-card patterns from detail support components**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<section className="rounded-[14px] border border-[var(--border-soft)] bg-[var(--surface)] p-4">
|
||||||
|
...
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update the README to mention the shadcn-style dense console direction**
|
||||||
|
|
||||||
|
```md
|
||||||
|
- shared admin shell uses compact toolbar and table patterns
|
||||||
|
- review, orders, and workflows pages are optimized for desktop operator density
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full verification**
|
||||||
|
|
||||||
|
Run: `npm run verify`
|
||||||
|
Expected: PASS with all Vitest suites green, ESLint green, TypeScript green, and `next build` green
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run a browser smoke test on the three main surfaces**
|
||||||
|
|
||||||
|
Run: `npm run dev`
|
||||||
|
Expected: desktop manual check confirms wider shell, denser lists, and clearer review flow on `/orders`, `/reviews/workbench`, `/workflows`
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit the cleanup and verification pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/features/orders/components src/features/workflows/components README.md
|
||||||
|
git commit -m "refactor: align detail views with dense console ui"
|
||||||
|
```
|
||||||
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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
1603
package-lock.json
generated
1603
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,13 +16,16 @@
|
|||||||
"verify": "npm run test && npm run lint && npm run typecheck:clean && npm run build"
|
"verify": "npm run test && npm run lint && npm run typecheck:clean && npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
"next": "^16.2.1",
|
"next": "^16.2.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"zod": "^4.3.6",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"lucide-react": "^1.7.0"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
|
|||||||
@@ -9,38 +9,38 @@ type DashboardShellProps = {
|
|||||||
|
|
||||||
export function DashboardShell({ children }: DashboardShellProps) {
|
export function DashboardShell({ children }: DashboardShellProps) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-transparent px-4 py-5 text-[var(--ink)] md:h-screen md:overflow-hidden md:px-6 md:py-6">
|
<div className="min-h-screen bg-transparent px-3 py-3 text-[var(--ink)] md:h-screen md:overflow-hidden md:px-4 md:py-4">
|
||||||
<div className="mx-auto grid max-w-7xl gap-4 rounded-[32px] border border-[var(--border-soft)] bg-[var(--bg-elevated)] p-3 shadow-[var(--shadow-shell)] backdrop-blur md:h-full md:grid-cols-[280px_minmax(0,1fr)] md:p-4">
|
<div className="grid min-h-[calc(100vh-1.5rem)] gap-3 rounded-[18px] border border-[var(--border-soft)] bg-[var(--bg-elevated)] p-2 shadow-[var(--shadow-shell)] backdrop-blur md:h-full md:min-h-0 md:grid-cols-[228px_minmax(0,1fr)] md:p-3">
|
||||||
<aside
|
<aside
|
||||||
aria-label="Dashboard rail"
|
aria-label="Dashboard rail"
|
||||||
className="flex rounded-[28px] border border-[var(--shell-border)] bg-[var(--shell)] p-6 text-white md:h-full"
|
className="flex rounded-[16px] border border-[var(--shell-border)] bg-[var(--shell)] p-4 text-white md:h-full md:w-[228px]"
|
||||||
>
|
>
|
||||||
<div className="flex min-h-full w-full flex-col">
|
<div className="flex min-h-full w-full flex-col">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.3em] text-white/48">
|
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.3em] text-white/48">
|
||||||
Auto Tryon Ops
|
Auto Tryon Ops
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-4 text-2xl font-semibold tracking-[-0.03em] text-white">
|
<h1 className="mt-3 text-xl font-semibold tracking-[-0.03em] text-white">
|
||||||
运营控制台
|
运营控制台
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 max-w-[18rem] text-sm leading-6 text-white/66">
|
<p className="mt-2 max-w-[15rem] text-sm leading-6 text-white/66">
|
||||||
保持订单、审核与流程追踪在同一套高密度暖色界面里完成。
|
保持订单、审核与流程追踪在同一套高密度暖色界面里完成。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="mt-8 space-y-1" aria-label="Primary Navigation">
|
<nav className="mt-6 space-y-1" aria-label="Primary Navigation">
|
||||||
{primaryNavItems.map((item) => (
|
{primaryNavItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="flex items-center justify-between rounded-[18px] border border-transparent px-3.5 py-3 text-sm text-white/80 transition hover:border-white/8 hover:bg-white/7 hover:text-white"
|
className="flex items-center justify-between rounded-[12px] border border-transparent px-3 py-2.5 text-sm text-white/80 transition hover:border-white/8 hover:bg-white/7 hover:text-white"
|
||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mt-auto rounded-[24px] border border-white/8 bg-white/6 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]">
|
<div className="mt-auto rounded-[14px] border border-white/8 bg-white/6 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]">
|
||||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-white/44">
|
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-white/44">
|
||||||
Shared shell
|
Shared shell
|
||||||
</p>
|
</p>
|
||||||
@@ -52,9 +52,9 @@ export function DashboardShell({ children }: DashboardShellProps) {
|
|||||||
</aside>
|
</aside>
|
||||||
<main
|
<main
|
||||||
aria-label="Dashboard content"
|
aria-label="Dashboard content"
|
||||||
className="overflow-hidden rounded-[28px] border border-[var(--border-soft)] bg-[var(--surface)] shadow-[inset_0_1px_0_rgba(255,255,255,0.32)] md:h-full md:min-h-0 md:overflow-y-auto"
|
className="overflow-hidden rounded-[16px] border border-[var(--border-soft)] bg-[var(--surface)] shadow-[inset_0_1px_0_rgba(255,255,255,0.32)] md:h-full md:min-h-0 md:overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div className="px-5 py-6 md:px-8 md:py-8">{children}</div>
|
<div className="px-4 py-5 md:px-6 md:py-6">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
@@ -26,9 +26,9 @@ const VARIANT_STYLES: Record<ButtonVariant, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SIZE_STYLES: Record<ButtonSize, string> = {
|
const SIZE_STYLES: Record<ButtonSize, string> = {
|
||||||
sm: "min-h-9 rounded-full px-3.5 text-sm",
|
sm: "h-8 rounded-md px-2.5 text-xs",
|
||||||
md: "min-h-11 rounded-full px-4 text-sm",
|
md: "h-9 rounded-md px-3 text-sm",
|
||||||
lg: "min-h-12 rounded-full px-5 text-base",
|
lg: "h-10 rounded-md px-4 text-sm",
|
||||||
};
|
};
|
||||||
|
|
||||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={joinClasses(
|
className={joinClasses(
|
||||||
"rounded-[28px] border border-[var(--border-soft)] bg-[var(--surface)] shadow-[var(--shadow-card)]",
|
"rounded-[var(--panel-radius)] border border-[var(--border-soft)] bg-[var(--surface)] shadow-[var(--shadow-card)]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -36,7 +36,7 @@ export const CardHeader = forwardRef<HTMLDivElement, CardSectionProps>(
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={joinClasses(
|
className={joinClasses(
|
||||||
"flex flex-col gap-2 border-b border-[var(--border-soft)] px-6 py-5",
|
"flex flex-col gap-2 border-b border-[var(--border-soft)] px-4 py-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -53,7 +53,7 @@ export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
|
|||||||
<h3
|
<h3
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={joinClasses(
|
className={joinClasses(
|
||||||
"text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]",
|
"text-base font-semibold tracking-[-0.02em] text-[var(--ink-strong)]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -71,7 +71,7 @@ export const CardDescription = forwardRef<
|
|||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={joinClasses("text-sm leading-6 text-[var(--ink-muted)]", className)}
|
className={joinClasses("text-sm leading-5 text-[var(--ink-muted)]", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -99,7 +99,7 @@ export const CardFooter = forwardRef<HTMLDivElement, CardSectionProps>(
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={joinClasses(
|
className={joinClasses(
|
||||||
"flex items-center justify-between gap-3 border-t border-[var(--border-soft)] px-6 py-4",
|
"flex items-center justify-between gap-3 border-t border-[var(--border-soft)] px-4 py-3",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
107
src/components/ui/dialog.tsx
Normal file
107
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = forwardRef<
|
||||||
|
ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-[rgba(52,39,27,0.42)] backdrop-blur-[2px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = forwardRef<
|
||||||
|
ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-1/2 top-1/2 z-50 grid max-h-[88vh] w-[min(96vw,78rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-hidden rounded-[32px] border border-[var(--border-soft)] bg-[var(--surface)] shadow-[0_24px_80px_rgba(39,31,24,0.18)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
className="absolute right-5 top-5 inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--border-soft)] bg-[var(--surface)] text-[var(--ink-muted)] transition hover:bg-[var(--surface-muted)] hover:text-[var(--ink-strong)] 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)]"
|
||||||
|
aria-label="关闭弹窗"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
function DialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComponentPropsWithoutRef<"div">) {
|
||||||
|
return <div className={cn("space-y-2 px-6 pt-6", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogBody({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComponentPropsWithoutRef<"div">) {
|
||||||
|
return <div className={cn("overflow-y-auto px-6 pb-6", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogTitle = forwardRef<
|
||||||
|
ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"pr-14 text-2xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = forwardRef<
|
||||||
|
ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm leading-6 text-[var(--ink-muted)]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
26
src/components/ui/input.tsx
Normal file
26
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { forwardRef, type InputHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
|
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||||
|
return values.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type = "text", ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={joinClasses(
|
||||||
|
"h-9 w-full rounded-md border border-[var(--border-strong)] bg-[var(--surface)] px-3 text-sm text-[var(--ink-strong)] outline-none transition",
|
||||||
|
"placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:ring-2 focus:ring-[var(--accent-ring)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = "Input";
|
||||||
32
src/components/ui/metric-chip.tsx
Normal file
32
src/components/ui/metric-chip.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
type MetricChipProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||||
|
return values.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricChip({
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: MetricChipProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={joinClasses(
|
||||||
|
"inline-flex items-center gap-2 rounded-md border border-[var(--border-soft)] bg-[var(--surface-muted)] px-3 py-1.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em] text-[var(--ink-faint)]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-[var(--ink-strong)]">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,24 +16,24 @@ export function PageHeader({
|
|||||||
title,
|
title,
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 border-b border-[var(--border-soft)] pb-6 md:flex-row md:items-end md:justify-between">
|
<div className="flex flex-col gap-4 border-b border-[var(--border-soft)] pb-4 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.26em] text-[var(--ink-faint)]">
|
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-[var(--ink-faint)]">
|
||||||
{eyebrow}
|
{eyebrow}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]">
|
<h1 className="text-2xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
{description ? (
|
{description ? (
|
||||||
<div className="max-w-3xl text-sm leading-7 text-[var(--ink-muted)]">
|
<div className="max-w-3xl text-sm leading-6 text-[var(--ink-muted)]">
|
||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{actions || meta ? (
|
{actions || meta ? (
|
||||||
<div className="flex flex-col gap-3 md:items-end">
|
<div className="flex flex-col gap-2 md:items-end">
|
||||||
{meta ? (
|
{meta ? (
|
||||||
<div className="font-[var(--font-mono)] text-xs uppercase tracking-[0.18em] text-[var(--ink-faint)]">
|
<div className="font-[var(--font-mono)] text-xs uppercase tracking-[0.18em] text-[var(--ink-faint)]">
|
||||||
{meta}
|
{meta}
|
||||||
|
|||||||
23
src/components/ui/page-toolbar.tsx
Normal file
23
src/components/ui/page-toolbar.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { HTMLAttributes, PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||||
|
return values.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageToolbar({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<HTMLAttributes<HTMLDivElement>>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={joinClasses(
|
||||||
|
"flex flex-wrap items-center gap-3 border-b border-[var(--border-soft)] pb-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/ui/select.tsx
Normal file
98
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
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,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/ui/separator.tsx
Normal file
15
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
|
||||||
|
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||||
|
return values.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Separator({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
className={joinClasses("h-px w-full bg-[var(--border-soft)]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -104,7 +104,7 @@ export function StatusBadge({ className, ...props }: StatusBadgeProps) {
|
|||||||
<span
|
<span
|
||||||
data-tone={meta.tone}
|
data-tone={meta.tone}
|
||||||
className={joinClasses(
|
className={joinClasses(
|
||||||
"inline-flex items-center rounded-full border px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.16em]",
|
"inline-flex items-center rounded-full border px-2 py-0.5 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em]",
|
||||||
TONE_STYLES[meta.tone],
|
TONE_STYLES[meta.tone],
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
78
src/components/ui/table.tsx
Normal file
78
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ThHTMLAttributes, type TdHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||||
|
return values.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div className="overflow-hidden rounded-[var(--panel-radius)] border border-[var(--border-soft)] bg-[var(--surface)]">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={joinClasses("w-full border-collapse text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Table.displayName = "Table";
|
||||||
|
|
||||||
|
export const TableHeader = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<thead
|
||||||
|
ref={ref}
|
||||||
|
className={joinClasses("bg-[var(--surface-muted)]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
|
export const TableBody = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tbody ref={ref} className={className} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
|
export const TableRow = forwardRef<HTMLTableRowElement, HTMLAttributes<HTMLTableRowElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={joinClasses("border-b border-[var(--border-soft)] last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
|
export const TableHead = forwardRef<HTMLTableCellElement, ThHTMLAttributes<HTMLTableCellElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={joinClasses(
|
||||||
|
"px-4 py-2.5 text-left font-[var(--font-mono)] text-[11px] uppercase tracking-[0.16em] text-[var(--ink-faint)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
|
export const TableCell = forwardRef<HTMLTableCellElement, TdHTMLAttributes<HTMLTableCellElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={joinClasses("px-4 py-3 align-middle text-[var(--ink-strong)]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
TableCell.displayName = "TableCell";
|
||||||
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";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
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 { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
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";
|
import type { LibraryItemVM, LibraryType } from "@/lib/types/view-models";
|
||||||
|
|
||||||
type LibraryPageProps = {
|
type LibraryPageProps = {
|
||||||
@@ -21,29 +36,50 @@ type LibraryEnvelope = {
|
|||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LIBRARY_META: Record<
|
type LibraryMeta = {
|
||||||
LibraryType,
|
description: string;
|
||||||
{ title: string; description: string; eyebrow: string }
|
singularLabel: string;
|
||||||
> = {
|
tabLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIBRARY_META: Record<LibraryType, LibraryMeta> = {
|
||||||
models: {
|
models: {
|
||||||
title: "模特库",
|
description: "按模特资源管理上传、封面和提单联动素材。",
|
||||||
description: "继续复用提单页所需的 mock 模特数据,同时保留正式资源库的结构和标签体系。",
|
singularLabel: "模特",
|
||||||
eyebrow: "Model library",
|
tabLabel: "模特",
|
||||||
},
|
},
|
||||||
scenes: {
|
scenes: {
|
||||||
title: "场景库",
|
description: "按场景资源管理上传、封面和提单联动素材。",
|
||||||
description: "场景库先用 mock 占位数据承接,等真实后台资源列表可用后再切换数据源。",
|
singularLabel: "场景",
|
||||||
eyebrow: "Scene library",
|
tabLabel: "场景",
|
||||||
},
|
},
|
||||||
garments: {
|
garments: {
|
||||||
title: "服装库",
|
description: "按服装资源管理上传、封面和提单联动素材。",
|
||||||
description: "服装资源暂时继续走 mock 资产,但页面本身已经按正式资源库组织。",
|
singularLabel: "服装",
|
||||||
eyebrow: "Garment library",
|
tabLabel: "服装",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const TITLE_MESSAGE = "当前资源库仍使用 mock 数据";
|
const LIBRARY_SECTIONS: Array<{
|
||||||
const DEFAULT_MESSAGE = "当前只保留页面结构和 mock 资源项,真实资源查询能力将在后端支持后接入。";
|
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({
|
export function LibraryPage({
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -52,95 +88,282 @@ export function LibraryPage({
|
|||||||
message = DEFAULT_MESSAGE,
|
message = DEFAULT_MESSAGE,
|
||||||
}: LibraryPageProps) {
|
}: LibraryPageProps) {
|
||||||
const meta = LIBRARY_META[libraryType];
|
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 (
|
return (
|
||||||
<section className="space-y-8">
|
<section className="space-y-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow={meta.eyebrow}
|
eyebrow="Resource library"
|
||||||
title={meta.title}
|
title="资源库"
|
||||||
description={meta.description}
|
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">
|
<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]">
|
<p className="text-lg font-semibold tracking-[-0.02em] text-[#7a5323]">
|
||||||
{TITLE_MESSAGE}
|
{STATE_TITLE}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div
|
||||||
<CardHeader>
|
data-testid="library-masonry"
|
||||||
<div className="space-y-2">
|
className="columns-1 gap-5 md:columns-2 xl:columns-3 2xl:columns-4"
|
||||||
<CardEyebrow>Library inventory</CardEyebrow>
|
>
|
||||||
<div className="space-y-1">
|
<div className="mb-5 break-inside-avoid">
|
||||||
<CardTitle>{meta.title}占位清单</CardTitle>
|
<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)]">
|
||||||
<CardDescription>
|
<div className="space-y-4">
|
||||||
每个资源条目都保留预览地址、说明和标签,后续只需要替换 BFF 数据源。
|
<div className="space-y-2">
|
||||||
</CardDescription>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{isLoading ? (
|
{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 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>
|
</div>
|
||||||
) : null}
|
) : 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 className="space-y-3">
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
<p className="text-base font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||||
{item.name}
|
{item.name}
|
||||||
</p>
|
</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}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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}
|
{item.previewUri}
|
||||||
</code>
|
</code> */}
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{item.tags.map((tag) => (
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<span
|
<Button
|
||||||
key={tag}
|
size="sm"
|
||||||
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)]"
|
variant="secondary"
|
||||||
>
|
onClick={() => setEditingItem(item)}
|
||||||
{tag}
|
>
|
||||||
</span>
|
编辑
|
||||||
))}
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => {
|
||||||
|
setArchivingItem(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</article>
|
||||||
</div>
|
))
|
||||||
) : null}
|
: null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!isLoading && !items.length ? (
|
<LibraryUploadModal
|
||||||
<EmptyState
|
libraryType={libraryType}
|
||||||
eyebrow="Library empty"
|
open={isUploadOpen}
|
||||||
title="暂无资源条目"
|
onClose={() => setIsUploadOpen(false)}
|
||||||
description="当前库还没有占位数据,等后端或运营资源列表准备好后再补齐。"
|
onUploaded={handleUploaded}
|
||||||
/>
|
/>
|
||||||
) : null}
|
<LibraryEditModal
|
||||||
</CardContent>
|
item={editingItem}
|
||||||
</Card>
|
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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibraryPageScreen({ libraryType }: { libraryType: LibraryType }) {
|
export function LibraryPageScreen({ libraryType }: { libraryType: LibraryType }) {
|
||||||
const [items, setItems] = useState<LibraryItemVM[]>([]);
|
const [items, setItems] = useState<LibraryItemVM[]>([]);
|
||||||
const [message, setMessage] = useState(
|
const [message, setMessage] = useState(DEFAULT_MESSAGE);
|
||||||
"当前资源库仍使用 mock 数据,真实资源查询能力将在后端支持后接入。",
|
|
||||||
);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -158,10 +381,7 @@ export function LibraryPageScreen({ libraryType }: { libraryType: LibraryType })
|
|||||||
}
|
}
|
||||||
|
|
||||||
setItems(payload.data?.items ?? []);
|
setItems(payload.data?.items ?? []);
|
||||||
setMessage(
|
setMessage(payload.message ?? DEFAULT_MESSAGE);
|
||||||
payload.message ??
|
|
||||||
"当前资源库仍使用 mock 数据,真实资源查询能力将在后端支持后接入。",
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return;
|
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,8 +7,10 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { Select } from "@/components/ui/select";
|
||||||
import { OrderSummaryCard } from "@/features/orders/components/order-summary-card";
|
import { OrderSummaryCard } from "@/features/orders/components/order-summary-card";
|
||||||
import { ResourcePickerCard } from "@/features/orders/components/resource-picker-card";
|
import { ResourcePickerCard } from "@/features/orders/components/resource-picker-card";
|
||||||
|
import { ResourcePickerModal } from "@/features/orders/components/resource-picker-modal";
|
||||||
import {
|
import {
|
||||||
SERVICE_MODE_LABELS,
|
SERVICE_MODE_LABELS,
|
||||||
type ModelPickerOption,
|
type ModelPickerOption,
|
||||||
@@ -18,6 +20,8 @@ import type {
|
|||||||
CustomerLevel,
|
CustomerLevel,
|
||||||
ServiceMode,
|
ServiceMode,
|
||||||
} from "@/lib/types/backend";
|
} from "@/lib/types/backend";
|
||||||
|
import type { LibraryType } from "@/lib/types/view-models";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
type SubmissionSuccess = {
|
type SubmissionSuccess = {
|
||||||
orderId: number;
|
orderId: number;
|
||||||
@@ -50,10 +54,6 @@ type CreateOrderFormProps = {
|
|||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
|
||||||
return values.filter(Boolean).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateOrderForm({
|
export function CreateOrderForm({
|
||||||
allowedServiceMode,
|
allowedServiceMode,
|
||||||
garments,
|
garments,
|
||||||
@@ -71,131 +71,161 @@ export function CreateOrderForm({
|
|||||||
onServiceModeChange,
|
onServiceModeChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: CreateOrderFormProps) {
|
}: CreateOrderFormProps) {
|
||||||
|
const [activePicker, setActivePicker] = useState<LibraryType | null>(null);
|
||||||
const selectedModel = models.find((item) => item.id === value.modelId) ?? null;
|
const selectedModel = models.find((item) => item.id === value.modelId) ?? null;
|
||||||
const selectedScene = scenes.find((item) => item.id === value.sceneId) ?? null;
|
const selectedScene = scenes.find((item) => item.id === value.sceneId) ?? null;
|
||||||
const selectedGarment =
|
const selectedGarment =
|
||||||
garments.find((item) => item.id === value.garmentId) ?? null;
|
garments.find((item) => item.id === value.garmentId) ?? null;
|
||||||
|
|
||||||
|
const activePickerConfig = activePicker
|
||||||
|
? {
|
||||||
|
models: {
|
||||||
|
items: models,
|
||||||
|
label: "模特资源",
|
||||||
|
selectedId: value.modelId,
|
||||||
|
onSelect: onModelChange,
|
||||||
|
},
|
||||||
|
scenes: {
|
||||||
|
items: scenes,
|
||||||
|
label: "场景资源",
|
||||||
|
selectedId: value.sceneId,
|
||||||
|
onSelect: onSceneChange,
|
||||||
|
},
|
||||||
|
garments: {
|
||||||
|
items: garments,
|
||||||
|
label: "服装资源",
|
||||||
|
selectedId: value.garmentId,
|
||||||
|
onSelect: onGarmentChange,
|
||||||
|
},
|
||||||
|
}[activePicker]
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<>
|
||||||
className="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.85fr)]"
|
<form
|
||||||
onSubmit={(event) => {
|
className="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.85fr)]"
|
||||||
event.preventDefault();
|
onSubmit={(event) => {
|
||||||
onSubmit();
|
event.preventDefault();
|
||||||
}}
|
onSubmit();
|
||||||
>
|
}}
|
||||||
<div className="space-y-6">
|
>
|
||||||
<Card>
|
<div className="space-y-6">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardEyebrow>Business inputs</CardEyebrow>
|
<CardHeader>
|
||||||
<CardTitle>订单参数</CardTitle>
|
<CardEyebrow>Business inputs</CardEyebrow>
|
||||||
<CardDescription>
|
<CardTitle>订单参数</CardTitle>
|
||||||
先确定客户层级,再由表单自动约束允许的服务模式。
|
<CardDescription>
|
||||||
</CardDescription>
|
先确定客户层级,再由表单自动约束允许的服务模式。
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
</CardHeader>
|
||||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
<span className="font-medium">客户层级</span>
|
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
||||||
<select
|
<span className="font-medium">客户层级</span>
|
||||||
aria-label="客户层级"
|
<Select
|
||||||
className={joinClasses(
|
aria-label="客户层级"
|
||||||
"min-h-12 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)]",
|
className="min-h-12 rounded-[18px] border-[var(--border-strong)] bg-[var(--surface-muted)] px-4"
|
||||||
"focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]",
|
disabled={isSubmitting}
|
||||||
)}
|
options={[
|
||||||
disabled={isSubmitting}
|
{ value: "low", label: "低客单 low" },
|
||||||
value={value.customerLevel}
|
{ value: "mid", label: "中客单 mid" },
|
||||||
onChange={(event) =>
|
]}
|
||||||
onCustomerLevelChange(event.target.value as CustomerLevel)
|
value={value.customerLevel}
|
||||||
}
|
onValueChange={(nextValue) =>
|
||||||
>
|
onCustomerLevelChange(nextValue as CustomerLevel)
|
||||||
<option value="low">低客单 low</option>
|
}
|
||||||
<option value="mid">中客单 mid</option>
|
/>
|
||||||
</select>
|
</label>
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
||||||
<span className="font-medium">服务模式</span>
|
<span className="font-medium">服务模式</span>
|
||||||
<select
|
<Select
|
||||||
aria-label="服务模式"
|
aria-label="服务模式"
|
||||||
className={joinClasses(
|
className="min-h-12 rounded-[18px] border-[var(--border-strong)] bg-[var(--surface-muted)] px-4"
|
||||||
"min-h-12 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)]",
|
disabled={isSubmitting}
|
||||||
"focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]",
|
options={[
|
||||||
)}
|
{
|
||||||
disabled={isSubmitting}
|
value: "auto_basic",
|
||||||
value={value.serviceMode}
|
label: `${SERVICE_MODE_LABELS.auto_basic} auto_basic`,
|
||||||
onChange={(event) =>
|
disabled: allowedServiceMode !== "auto_basic",
|
||||||
onServiceModeChange(event.target.value as ServiceMode)
|
},
|
||||||
}
|
{
|
||||||
>
|
value: "semi_pro",
|
||||||
<option
|
label: `${SERVICE_MODE_LABELS.semi_pro} semi_pro`,
|
||||||
disabled={allowedServiceMode !== "auto_basic"}
|
disabled: allowedServiceMode !== "semi_pro",
|
||||||
value="auto_basic"
|
},
|
||||||
>
|
]}
|
||||||
{SERVICE_MODE_LABELS.auto_basic} auto_basic
|
value={value.serviceMode}
|
||||||
</option>
|
onValueChange={(nextValue) =>
|
||||||
<option
|
onServiceModeChange(nextValue as ServiceMode)
|
||||||
disabled={allowedServiceMode !== "semi_pro"}
|
}
|
||||||
value="semi_pro"
|
/>
|
||||||
>
|
</label>
|
||||||
{SERVICE_MODE_LABELS.semi_pro} semi_pro
|
</CardContent>
|
||||||
</option>
|
</Card>
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
<ResourcePickerCard
|
<ResourcePickerCard
|
||||||
description="使用资源库占位数据挑选模特,提交时会映射到真实后端 ID 与 pose_id。"
|
description="使用资源库素材挑选模特,提交时会映射到真实后端资源 ID。"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
isLoading={isLoadingResources}
|
isLoading={isLoadingResources}
|
||||||
items={models}
|
label="模特资源"
|
||||||
label="模特资源"
|
selectedItem={selectedModel}
|
||||||
title="模特"
|
title="模特"
|
||||||
value={value.modelId}
|
onOpenPicker={() => setActivePicker("models")}
|
||||||
onChange={onModelChange}
|
/>
|
||||||
/>
|
<ResourcePickerCard
|
||||||
<ResourcePickerCard
|
description="场景仍来自 mock 数据,但通过 BFF 路由加载,保持页面集成方式真实。"
|
||||||
description="场景仍来自 mock 数据,但通过 BFF 路由加载,保持页面集成方式真实。"
|
disabled={isSubmitting}
|
||||||
disabled={isSubmitting}
|
isLoading={isLoadingResources}
|
||||||
isLoading={isLoadingResources}
|
label="场景资源"
|
||||||
items={scenes}
|
selectedItem={selectedScene}
|
||||||
label="场景资源"
|
title="场景"
|
||||||
title="场景"
|
onOpenPicker={() => setActivePicker("scenes")}
|
||||||
value={value.sceneId}
|
/>
|
||||||
onChange={onSceneChange}
|
<ResourcePickerCard
|
||||||
/>
|
description="服装选择沿用当前资源库占位素材,为订单创建页提供稳定联调入口。"
|
||||||
<ResourcePickerCard
|
disabled={isSubmitting}
|
||||||
description="服装选择沿用当前资源库占位素材,为订单创建页提供稳定联调入口。"
|
isLoading={isLoadingResources}
|
||||||
disabled={isSubmitting}
|
label="服装资源"
|
||||||
isLoading={isLoadingResources}
|
selectedItem={selectedGarment}
|
||||||
items={garments}
|
title="服装"
|
||||||
label="服装资源"
|
onOpenPicker={() => setActivePicker("garments")}
|
||||||
title="服装"
|
/>
|
||||||
value={value.garmentId}
|
</div>
|
||||||
onChange={onGarmentChange}
|
|
||||||
/>
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button disabled={isLoadingResources || isSubmitting} type="submit">
|
||||||
|
{isSubmitting ? "提交中..." : "提交订单"}
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-[var(--ink-muted)]">
|
||||||
|
提交只负责创建订单,不承载审核或流程追踪行为。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<OrderSummaryCard
|
||||||
<Button disabled={isLoadingResources || isSubmitting} type="submit">
|
customerLevel={value.customerLevel}
|
||||||
{isSubmitting ? "提交中..." : "提交订单"}
|
garment={selectedGarment}
|
||||||
</Button>
|
model={selectedModel}
|
||||||
<p className="text-sm text-[var(--ink-muted)]">
|
scene={selectedScene}
|
||||||
提交只负责创建订单,不承载审核或流程追踪行为。
|
serviceMode={value.serviceMode}
|
||||||
</p>
|
submitError={submitError}
|
||||||
</div>
|
submissionSuccess={submissionSuccess}
|
||||||
</div>
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
<OrderSummaryCard
|
{activePicker && activePickerConfig ? (
|
||||||
customerLevel={value.customerLevel}
|
<ResourcePickerModal
|
||||||
garment={selectedGarment}
|
isLoading={isLoadingResources}
|
||||||
model={selectedModel}
|
items={activePickerConfig.items}
|
||||||
scene={selectedScene}
|
label={activePickerConfig.label}
|
||||||
serviceMode={value.serviceMode}
|
libraryType={activePicker}
|
||||||
submitError={submitError}
|
open
|
||||||
submissionSuccess={submissionSuccess}
|
selectedId={activePickerConfig.selectedId}
|
||||||
/>
|
onClose={() => setActivePicker(null)}
|
||||||
</form>
|
onSelect={activePickerConfig.onSelect}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import { RotateCcw, ZoomIn } from "lucide-react";
|
||||||
|
import { useMemo, useState, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from "react";
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogBody, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import type { AssetViewModel, OrderDetailVM } from "@/lib/types/view-models";
|
import type { AssetViewModel, OrderDetailVM } from "@/lib/types/view-models";
|
||||||
|
|
||||||
@@ -6,7 +10,156 @@ type OrderAssetsPanelProps = {
|
|||||||
viewModel: OrderDetailVM;
|
viewModel: OrderDetailVM;
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderAssetCard(asset: AssetViewModel) {
|
type AssetInputSnapshot = {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreviewPayload = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputSnapshot(metadata: AssetViewModel["metadata"], key: string) {
|
||||||
|
if (!metadata || !isObjectRecord(metadata[key])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata[key] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssetInputSnapshots(asset: AssetViewModel): AssetInputSnapshot[] {
|
||||||
|
const inputConfigs = [
|
||||||
|
{ key: "model_input", label: "模特图" },
|
||||||
|
{ key: "garment_input", label: "服装图" },
|
||||||
|
{ key: "scene_input", label: "场景图" },
|
||||||
|
{ key: "pose_input", label: "姿势图" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return inputConfigs
|
||||||
|
.map(({ key, label }) => {
|
||||||
|
const snapshot = getInputSnapshot(asset.metadata, key);
|
||||||
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceName =
|
||||||
|
typeof snapshot.resource_name === "string"
|
||||||
|
? snapshot.resource_name
|
||||||
|
: typeof snapshot.pose_id === "number"
|
||||||
|
? `姿势 #${snapshot.pose_id}`
|
||||||
|
: "未命名素材";
|
||||||
|
const originalUrl = typeof snapshot.original_url === "string" ? snapshot.original_url : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
name: resourceName,
|
||||||
|
url: originalUrl,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is AssetInputSnapshot => item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInputSnapshots(asset: AssetViewModel, onOpenPreview: (payload: PreviewPayload) => void) {
|
||||||
|
const inputSnapshots = getAssetInputSnapshots(asset);
|
||||||
|
if (!inputSnapshots.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--ink-faint)]">输入素材</p>
|
||||||
|
<p className="text-xs text-[var(--ink-muted)]">展示这一步使用的上游素材,方便快速核对输入来源。</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{inputSnapshots.map((snapshot) => (
|
||||||
|
<div
|
||||||
|
key={`${asset.id}-${snapshot.label}`}
|
||||||
|
className="overflow-hidden rounded-[20px] border border-[var(--border-soft)] bg-[var(--surface)]"
|
||||||
|
>
|
||||||
|
<div className="flex aspect-[4/3] items-center justify-center bg-[rgba(74,64,53,0.06)] p-3">
|
||||||
|
{snapshot.url ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group flex h-full w-full items-center justify-center outline-none"
|
||||||
|
onClick={() =>
|
||||||
|
onOpenPreview({
|
||||||
|
title: `${snapshot.label}预览`,
|
||||||
|
description: snapshot.name,
|
||||||
|
url: snapshot.url!,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={`查看${snapshot.name}大图`}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={snapshot.url}
|
||||||
|
alt={snapshot.name}
|
||||||
|
className="block max-h-full max-w-full object-contain transition duration-200 group-hover:scale-[1.02]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-[var(--ink-muted)]">
|
||||||
|
暂无预览图
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 px-3 py-3">
|
||||||
|
<p className="text-xs font-semibold text-[var(--ink-strong)]">{snapshot.label}</p>
|
||||||
|
<p className="line-clamp-2 text-xs text-[var(--ink-muted)]">{snapshot.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssetPreview(asset: AssetViewModel, onOpenPreview: (payload: PreviewPayload) => void) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex aspect-[4/3] items-center justify-center overflow-hidden rounded-[20px] border border-[var(--border-soft)] bg-[rgba(74,64,53,0.06)] p-3">
|
||||||
|
{asset.isMock ? (
|
||||||
|
<div className="text-center text-xs text-[var(--ink-muted)]">
|
||||||
|
<p className="font-semibold text-[var(--ink-strong)]">Mock 预览</p>
|
||||||
|
<p className="mt-1">当前资产没有真实图片内容</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group flex h-full w-full items-center justify-center outline-none"
|
||||||
|
onClick={() =>
|
||||||
|
onOpenPreview({
|
||||||
|
title: `${asset.label}预览`,
|
||||||
|
description: `${asset.stepLabel}阶段产物`,
|
||||||
|
url: asset.uri,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={`查看${asset.label}大图`}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={asset.uri}
|
||||||
|
alt={`${asset.label}预览`}
|
||||||
|
className="block max-h-full max-w-full object-contain transition duration-200 group-hover:scale-[1.02]"
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute hidden rounded-full bg-[rgba(52,39,27,0.78)] px-3 py-1.5 text-xs font-medium text-white group-hover:inline-flex group-hover:items-center group-hover:gap-1">
|
||||||
|
<ZoomIn className="h-3.5 w-3.5" />
|
||||||
|
放大查看
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssetCard(asset: AssetViewModel, onOpenPreview: (payload: PreviewPayload) => void) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
@@ -23,14 +176,87 @@ function renderAssetCard(asset: AssetViewModel) {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<code className="mt-4 block rounded-[20px] bg-[rgba(74,64,53,0.08)] px-3 py-3 text-xs leading-6 text-[var(--ink-muted)]">
|
{renderAssetPreview(asset, onOpenPreview)}
|
||||||
|
{/* <code className="mt-4 block rounded-[20px] bg-[rgba(74,64,53,0.08)] px-3 py-3 text-xs leading-6 text-[var(--ink-muted)]">
|
||||||
{asset.uri}
|
{asset.uri}
|
||||||
</code>
|
</code> */}
|
||||||
|
{renderInputSnapshots(asset, onOpenPreview)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) {
|
export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) {
|
||||||
|
const [preview, setPreview] = useState<PreviewPayload | null>(null);
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [dragOrigin, setDragOrigin] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
function resetPreviewState() {
|
||||||
|
setScale(1);
|
||||||
|
setOffset({ x: 0, y: 0 });
|
||||||
|
setDragging(false);
|
||||||
|
setDragOrigin(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreview(payload: PreviewPayload) {
|
||||||
|
setPreview(payload);
|
||||||
|
resetPreviewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePreview(open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
setPreview(null);
|
||||||
|
resetPreviewState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWheel(event: ReactWheelEvent<HTMLImageElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const nextScale = Number(
|
||||||
|
Math.min(3.5, Math.max(1, scale + (event.deltaY < 0 ? 0.12 : -0.12))).toFixed(2),
|
||||||
|
);
|
||||||
|
setScale(nextScale);
|
||||||
|
if (nextScale === 1) {
|
||||||
|
setOffset({ x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: ReactPointerEvent<HTMLImageElement>) {
|
||||||
|
if (scale <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
setDragging(true);
|
||||||
|
setDragOrigin({
|
||||||
|
x: event.clientX - offset.x,
|
||||||
|
y: event.clientY - offset.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: ReactPointerEvent<HTMLImageElement>) {
|
||||||
|
if (!dragging || !dragOrigin || scale <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOffset({
|
||||||
|
x: event.clientX - dragOrigin.x,
|
||||||
|
y: event.clientY - dragOrigin.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event: ReactPointerEvent<HTMLImageElement>) {
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
setDragging(false);
|
||||||
|
setDragOrigin(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewTransform = useMemo(
|
||||||
|
() => `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
||||||
|
[offset.x, offset.y, scale],
|
||||||
|
);
|
||||||
|
|
||||||
const finalAssetTitle =
|
const finalAssetTitle =
|
||||||
viewModel.finalAssetState.kind === "business-empty"
|
viewModel.finalAssetState.kind === "business-empty"
|
||||||
? viewModel.finalAssetState.title
|
? viewModel.finalAssetState.title
|
||||||
@@ -76,7 +302,7 @@ export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{viewModel.finalAsset ? (
|
{viewModel.finalAsset ? (
|
||||||
renderAssetCard(viewModel.finalAsset)
|
renderAssetCard(viewModel.finalAsset, openPreview)
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
eyebrow="Final asset empty"
|
eyebrow="Final asset empty"
|
||||||
@@ -94,7 +320,9 @@ export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{viewModel.assets.length ? (
|
{viewModel.assets.length ? (
|
||||||
<div className="grid gap-3 md:grid-cols-2">{viewModel.assets.map(renderAssetCard)}</div>
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{viewModel.assets.map((asset) => renderAssetCard(asset, openPreview))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
eyebrow="Gallery empty"
|
eyebrow="Gallery empty"
|
||||||
@@ -103,6 +331,53 @@ export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={preview !== null} onOpenChange={closePreview}>
|
||||||
|
<DialogContent className="w-[min(96vw,88rem)]">
|
||||||
|
{preview ? (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{preview.title}</DialogTitle>
|
||||||
|
<DialogDescription>{preview.description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-[20px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-3 text-sm text-[var(--ink-muted)]">
|
||||||
|
<span>缩放 {Math.round(scale * 100)}%</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-[var(--border-soft)] bg-[var(--surface)] px-3 py-1.5 text-sm font-medium text-[var(--ink-strong)] transition hover:bg-[var(--surface-muted)]"
|
||||||
|
onClick={resetPreviewState}
|
||||||
|
aria-label="重置预览"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-[72vh] items-center justify-center overflow-hidden rounded-[28px] border border-[var(--border-soft)] bg-[rgba(52,39,27,0.06)] p-4">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={preview.url}
|
||||||
|
alt={`${preview.title.replace(/预览$/, "")}大图`}
|
||||||
|
className="max-h-full max-w-full select-none object-contain"
|
||||||
|
style={{
|
||||||
|
transform: previewTransform,
|
||||||
|
transition: dragging ? "none" : "transform 120ms ease-out",
|
||||||
|
cursor: scale > 1 ? (dragging ? "grabbing" : "grab") : "zoom-in",
|
||||||
|
}}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onDoubleClick={resetPreviewState}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function OrderSummaryCard({
|
|||||||
label="提交映射"
|
label="提交映射"
|
||||||
value={
|
value={
|
||||||
model && scene && garment
|
model && scene && garment
|
||||||
? `model ${model.backendId} / pose ${model.poseId} / scene ${scene.backendId} / garment ${garment.backendId}`
|
? `model ${model.backendId} / scene ${scene.backendId} / garment ${garment.backendId}`
|
||||||
: "完成选择后显示"
|
: "完成选择后显示"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
118
src/features/orders/components/orders-table.tsx
Normal file
118
src/features/orders/components/orders-table.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import type { OrderSummaryVM } from "@/lib/types/view-models";
|
||||||
|
|
||||||
|
type OrdersTableProps = {
|
||||||
|
isLoading: boolean;
|
||||||
|
items: OrderSummaryVM[];
|
||||||
|
onOpenOrder?: (orderId: string) => void;
|
||||||
|
onOpenWorkflow?: (orderId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp: string) {
|
||||||
|
return timestamp.replace("T", " ").replace("Z", " UTC");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrdersTable({
|
||||||
|
isLoading,
|
||||||
|
items,
|
||||||
|
onOpenOrder,
|
||||||
|
onOpenWorkflow,
|
||||||
|
}: OrdersTableProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[var(--panel-radius)] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
|
||||||
|
正在加载订单列表…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
eyebrow="Orders empty"
|
||||||
|
title="当前筛选下没有订单"
|
||||||
|
description="调整关键词、状态或服务模式后再试。"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>订单号</TableHead>
|
||||||
|
<TableHead>workflowId</TableHead>
|
||||||
|
<TableHead>客户等级</TableHead>
|
||||||
|
<TableHead>服务模式</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>当前步骤</TableHead>
|
||||||
|
<TableHead>修订数</TableHead>
|
||||||
|
<TableHead>更新时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((order) => (
|
||||||
|
<TableRow key={order.orderId}>
|
||||||
|
<TableCell className="font-medium">#{order.orderId}</TableCell>
|
||||||
|
<TableCell>{order.workflowId ?? "未关联"}</TableCell>
|
||||||
|
<TableCell>{order.customerLevel}</TableCell>
|
||||||
|
<TableCell>{order.serviceMode}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={order.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<StatusBadge variant="workflowStep" status={order.currentStep} />
|
||||||
|
<span className="text-xs text-[var(--ink-muted)]">
|
||||||
|
{order.currentStepLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="text-sm text-[var(--ink-strong)]">
|
||||||
|
{order.revisionCount}
|
||||||
|
</span>
|
||||||
|
{order.pendingManualConfirm ? (
|
||||||
|
<span className="rounded-full bg-[rgba(88,106,56,0.12)] px-2 py-0.5 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em] text-[#50633b]">
|
||||||
|
待确认
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-[var(--ink-muted)]">
|
||||||
|
{formatTimestamp(order.updatedAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onOpenOrder?.(String(order.orderId))}
|
||||||
|
>
|
||||||
|
查看订单
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenWorkflow?.(String(order.orderId))}
|
||||||
|
>
|
||||||
|
查看流程
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/features/orders/components/orders-toolbar.tsx
Normal file
96
src/features/orders/components/orders-toolbar.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { PageToolbar } from "@/components/ui/page-toolbar";
|
||||||
|
import { Select } from "@/components/ui/select";
|
||||||
|
import { ORDER_STATUS_META } from "@/lib/types/status";
|
||||||
|
import type { OrderStatus, ServiceMode } from "@/lib/types/backend";
|
||||||
|
|
||||||
|
export type OrderFilterStatus = OrderStatus | "all";
|
||||||
|
export type OrderFilterServiceMode = ServiceMode | "all";
|
||||||
|
|
||||||
|
type OrdersToolbarProps = {
|
||||||
|
currentPage: number;
|
||||||
|
query: string;
|
||||||
|
serviceMode: OrderFilterServiceMode;
|
||||||
|
status: OrderFilterStatus;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
onQueryChange: (value: string) => void;
|
||||||
|
onQuerySubmit?: (query: string) => void;
|
||||||
|
onServiceModeChange: (value: OrderFilterServiceMode) => void;
|
||||||
|
onStatusChange?: (value: OrderFilterStatus) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrdersToolbar({
|
||||||
|
currentPage,
|
||||||
|
query,
|
||||||
|
serviceMode,
|
||||||
|
status,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
onQueryChange,
|
||||||
|
onQuerySubmit,
|
||||||
|
onServiceModeChange,
|
||||||
|
onStatusChange,
|
||||||
|
}: OrdersToolbarProps) {
|
||||||
|
const safeTotalPages = Math.max(totalPages, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageToolbar className="justify-between gap-4">
|
||||||
|
<div className="flex flex-1 flex-wrap items-center gap-3">
|
||||||
|
<Input
|
||||||
|
aria-label="订单关键词搜索"
|
||||||
|
className="min-w-[220px] flex-1 md:max-w-[320px]"
|
||||||
|
placeholder="搜索订单号或 workflow_id"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<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}
|
||||||
|
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}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onServiceModeChange(value as OrderFilterServiceMode)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-[var(--ink-muted)]">
|
||||||
|
第 {Math.min(currentPage, safeTotalPages)} / {safeTotalPages} 页
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
onClick={() => onPageChange?.(currentPage - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={currentPage >= safeTotalPages}
|
||||||
|
onClick={() => onPageChange?.(currentPage + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PageToolbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,69 +6,67 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import type { ResourcePickerOption } from "@/features/orders/resource-picker-options";
|
import type { ResourcePickerOption } from "@/features/orders/resource-picker-options";
|
||||||
|
|
||||||
type ResourcePickerCardProps = {
|
type ResourcePickerCardProps = {
|
||||||
description: string;
|
description: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
items: ResourcePickerOption[];
|
|
||||||
label: string;
|
label: string;
|
||||||
|
selectedItem: ResourcePickerOption | null;
|
||||||
title: string;
|
title: string;
|
||||||
value: string;
|
onOpenPicker: () => void;
|
||||||
onChange: (value: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
|
||||||
return values.filter(Boolean).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResourcePickerCard({
|
export function ResourcePickerCard({
|
||||||
description,
|
description,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
items,
|
|
||||||
label,
|
label,
|
||||||
|
selectedItem,
|
||||||
title,
|
title,
|
||||||
value,
|
onOpenPicker,
|
||||||
onChange,
|
|
||||||
}: ResourcePickerCardProps) {
|
}: ResourcePickerCardProps) {
|
||||||
const selectedItem = items.find((item) => item.id === value) ?? null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardEyebrow>Mock backed selector</CardEyebrow>
|
<CardEyebrow>Resource manager</CardEyebrow>
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
<div className="space-y-2 text-sm text-[var(--ink-strong)]">
|
||||||
<span className="font-medium">{label}</span>
|
<span className="font-medium">{label}</span>
|
||||||
<select
|
<Button
|
||||||
aria-label={label}
|
aria-label={selectedItem ? `更换${label}` : `选择${label}`}
|
||||||
className={joinClasses(
|
className="min-h-12 w-full justify-between rounded-[18px] border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-left text-[var(--ink-strong)] shadow-none hover:bg-[var(--surface)]"
|
||||||
"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)]",
|
|
||||||
)}
|
|
||||||
disabled={disabled || isLoading}
|
disabled={disabled || isLoading}
|
||||||
value={value}
|
size="lg"
|
||||||
onChange={(event) => onChange(event.target.value)}
|
variant="secondary"
|
||||||
|
onClick={onOpenPicker}
|
||||||
>
|
>
|
||||||
<option value="">
|
{isLoading
|
||||||
{isLoading ? "正在加载占位资源..." : "请选择一个资源"}
|
? "正在加载资源..."
|
||||||
</option>
|
: selectedItem
|
||||||
{items.map((item) => (
|
? `更换${label}`
|
||||||
<option key={item.id} value={item.id}>
|
: `选择${label}`}
|
||||||
{item.name}
|
</Button>
|
||||||
</option>
|
</div>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{selectedItem ? (
|
{selectedItem ? (
|
||||||
<div className="rounded-[20px] border border-[var(--border-soft)] bg-[var(--surface-muted)] p-4">
|
<div className="rounded-[20px] border border-[var(--border-soft)] bg-[var(--surface-muted)] p-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start gap-3">
|
||||||
|
<div className="h-20 w-16 overflow-hidden rounded-[14px] bg-[rgba(74,64,53,0.08)]">
|
||||||
|
{selectedItem.previewUri ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
alt={`${selectedItem.name} 预览图`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
src={selectedItem.previewUri}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
||||||
{selectedItem.name}
|
{selectedItem.name}
|
||||||
@@ -77,17 +75,21 @@ export function ResourcePickerCard({
|
|||||||
{selectedItem.description}
|
{selectedItem.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-[var(--accent-soft)] px-3 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--accent-ink)]">
|
|
||||||
{selectedItem.isMock ? "mock" : "live"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 font-[var(--font-mono)] text-xs text-[var(--ink-faint)]">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{selectedItem.previewUri}
|
{selectedItem.tags.map((tag) => (
|
||||||
</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm leading-6 text-[var(--ink-muted)]">
|
<p className="text-sm leading-6 text-[var(--ink-muted)]">
|
||||||
选择后会在摘要卡中同步显示资源名称与提交 ID。
|
通过资源管理器弹窗直接点选图片,选择后会在摘要卡中同步显示资源名称与提交 ID。
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
132
src/features/orders/components/resource-picker-modal.tsx
Normal file
132
src/features/orders/components/resource-picker-modal.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { CardEyebrow } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import type { ResourcePickerOption } from "@/features/orders/resource-picker-options";
|
||||||
|
import type { LibraryType } from "@/lib/types/view-models";
|
||||||
|
|
||||||
|
type ResourcePickerModalProps = {
|
||||||
|
items: ResourcePickerOption[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
label: string;
|
||||||
|
libraryType: LibraryType;
|
||||||
|
open: boolean;
|
||||||
|
selectedId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EYE_BROW_BY_LIBRARY_TYPE: Record<LibraryType, string> = {
|
||||||
|
models: "Model library",
|
||||||
|
scenes: "Scene library",
|
||||||
|
garments: "Garment library",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResourcePickerModal({
|
||||||
|
items,
|
||||||
|
isLoading = false,
|
||||||
|
label,
|
||||||
|
libraryType,
|
||||||
|
open,
|
||||||
|
selectedId,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
}: ResourcePickerModalProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(nextOpen) => (!nextOpen ? onClose() : null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<CardEyebrow>{EYE_BROW_BY_LIBRARY_TYPE[libraryType]}</CardEyebrow>
|
||||||
|
<DialogTitle>{`选择${label}`}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
从当前资源库里直接点选图片。选中后会立即回填到提单工作台。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="rounded-[24px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-10 text-sm text-[var(--ink-muted)]">
|
||||||
|
正在加载资源...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading ? (
|
||||||
|
<div
|
||||||
|
data-testid="resource-picker-masonry"
|
||||||
|
className="columns-1 gap-5 md:columns-2 xl:columns-3 2xl:columns-4"
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const isSelected = item.id === selectedId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
aria-label={item.name}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
className="mb-5 block w-full break-inside-avoid overflow-hidden rounded-[28px] border border-[var(--border-soft)] bg-[var(--surface)] text-left shadow-[var(--shadow-card)] transition hover:-translate-y-0.5 hover:shadow-[0_20px_50px_rgba(52,39,27,0.14)] 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)]"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(item.id);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isSelected
|
||||||
|
? "border-b border-[rgba(110,127,82,0.22)] bg-[rgba(225,232,214,0.7)] p-4"
|
||||||
|
: "border-b 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 ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
alt={item.name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
src={item.previewUri}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{isSelected ? (
|
||||||
|
<div className="absolute right-3 top-3 rounded-full bg-[var(--accent-primary)] px-3 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--accent-ink)]">
|
||||||
|
已选中
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-base font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-6 text-[var(--ink-muted)]">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{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}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DialogBody>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,16 +3,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { MetricChip } from "@/components/ui/metric-chip";
|
||||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
|
||||||
import { ORDER_STATUS_META } from "@/lib/types/status";
|
|
||||||
import type { OrderStatus } from "@/lib/types/backend";
|
|
||||||
import type { OrderSummaryVM } from "@/lib/types/view-models";
|
import type { OrderSummaryVM } from "@/lib/types/view-models";
|
||||||
|
import {
|
||||||
|
OrdersToolbar,
|
||||||
|
type OrderFilterServiceMode,
|
||||||
|
type OrderFilterStatus,
|
||||||
|
} from "@/features/orders/components/orders-toolbar";
|
||||||
|
import { OrdersTable } from "@/features/orders/components/orders-table";
|
||||||
|
|
||||||
type FilterStatus = OrderStatus | "all";
|
|
||||||
type PaginationData = {
|
type PaginationData = {
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@@ -28,10 +28,10 @@ type OrdersHomeProps = {
|
|||||||
onOpenWorkflow?: (orderId: string) => void;
|
onOpenWorkflow?: (orderId: string) => void;
|
||||||
onPageChange?: (page: number) => void;
|
onPageChange?: (page: number) => void;
|
||||||
onQuerySubmit?: (query: string) => void;
|
onQuerySubmit?: (query: string) => void;
|
||||||
onStatusChange?: (status: FilterStatus) => void;
|
onStatusChange?: (status: OrderFilterStatus) => void;
|
||||||
recentOrders: OrderSummaryVM[];
|
recentOrders: OrderSummaryVM[];
|
||||||
selectedQuery?: string;
|
selectedQuery?: string;
|
||||||
selectedStatus?: FilterStatus;
|
selectedStatus?: OrderFilterStatus;
|
||||||
totalPages?: number;
|
totalPages?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,27 +47,13 @@ type OrdersOverviewEnvelope = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TITLE_MESSAGE = "最近订单已接入真实后端接口";
|
const TITLE_MESSAGE = "最近订单已接入真实后端接口";
|
||||||
const DEFAULT_MESSAGE = "当前首页展示真实最近订单,同时保留订单号直达入口,方便快速跳转详情和流程。";
|
const DEFAULT_MESSAGE = "当前页面直接展示真实订单列表,支持关键词、状态和分页操作。";
|
||||||
const DEFAULT_PAGINATION: PaginationData = {
|
const DEFAULT_PAGINATION: PaginationData = {
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 6,
|
limit: 6,
|
||||||
total: 0,
|
total: 0,
|
||||||
totalPages: 0,
|
totalPages: 0,
|
||||||
};
|
};
|
||||||
const ORDER_STATUS_FILTER_OPTIONS: Array<{
|
|
||||||
label: string;
|
|
||||||
value: FilterStatus;
|
|
||||||
}> = [
|
|
||||||
{ value: "all", label: "全部状态" },
|
|
||||||
...Object.entries(ORDER_STATUS_META).map(([value, meta]) => ({
|
|
||||||
value: value as OrderStatus,
|
|
||||||
label: meta.label,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatTimestamp(timestamp: string) {
|
|
||||||
return timestamp.replace("T", " ").replace("Z", " UTC");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OrdersHome({
|
export function OrdersHome({
|
||||||
currentPage = 1,
|
currentPage = 1,
|
||||||
@@ -83,190 +69,55 @@ export function OrdersHome({
|
|||||||
selectedStatus = "all",
|
selectedStatus = "all",
|
||||||
totalPages = 0,
|
totalPages = 0,
|
||||||
}: OrdersHomeProps) {
|
}: OrdersHomeProps) {
|
||||||
const [lookupValue, setLookupValue] = useState("");
|
|
||||||
const [queryValue, setQueryValue] = useState(selectedQuery);
|
const [queryValue, setQueryValue] = useState(selectedQuery);
|
||||||
const normalizedLookup = lookupValue.trim();
|
const [serviceModeFilter, setServiceModeFilter] =
|
||||||
const canLookup = /^\d+$/.test(normalizedLookup);
|
useState<OrderFilterServiceMode>("all");
|
||||||
const effectiveTotalPages = Math.max(totalPages, 1);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQueryValue(selectedQuery);
|
setQueryValue(selectedQuery);
|
||||||
}, [selectedQuery]);
|
}, [selectedQuery]);
|
||||||
|
|
||||||
|
const visibleOrders =
|
||||||
|
serviceModeFilter === "all"
|
||||||
|
? recentOrders
|
||||||
|
: recentOrders.filter((order) => order.serviceMode === serviceModeFilter);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-8">
|
<section className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Orders home"
|
eyebrow="Orders home"
|
||||||
title="订单总览"
|
title="订单总览"
|
||||||
description="这里是后台首页入口,当前已经接入真实最近订单列表,并保留订单号直达入口方便快速处理。"
|
description="订单页直接承担扫描、筛选和跳转职责,不再把首屏浪费在入口说明和大卡片上。"
|
||||||
meta="真实列表入口"
|
meta="真实列表页"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="rounded-[28px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-6 py-5">
|
<div className="flex flex-wrap gap-2">
|
||||||
<p className="text-lg font-semibold tracking-[-0.02em] text-[#7a5323]">
|
<MetricChip label="mode" value="真实订单列表" />
|
||||||
{TITLE_MESSAGE}
|
<MetricChip label="rows" value={visibleOrders.length} />
|
||||||
</p>
|
<MetricChip label="message" value={TITLE_MESSAGE} />
|
||||||
<p className="mt-2 text-sm leading-7 text-[#7a5323]">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
|
<p className="text-sm text-[var(--ink-muted)]">{message}</p>
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<CardEyebrow>Direct lookup</CardEyebrow>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<CardTitle>订单号直达</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
保留订单号和流程号的直接入口,适合在列表之外快速跳转到指定订单。
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<label className="block space-y-2">
|
|
||||||
<span className="text-sm font-medium text-[var(--ink-strong)]">
|
|
||||||
订单号
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
value={lookupValue}
|
|
||||||
onChange={(event) => setLookupValue(event.target.value)}
|
|
||||||
placeholder="输入订单号,例如 4201"
|
|
||||||
className="min-h-12 w-full rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)]"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Button
|
|
||||||
disabled={!canLookup}
|
|
||||||
onClick={() => onOpenOrder?.(normalizedLookup)}
|
|
||||||
>
|
|
||||||
打开订单详情
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
disabled={!canLookup}
|
|
||||||
onClick={() => onOpenWorkflow?.(normalizedLookup)}
|
|
||||||
>
|
|
||||||
打开流程详情
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<OrdersToolbar
|
||||||
<CardHeader>
|
currentPage={currentPage}
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
query={queryValue}
|
||||||
<div className="space-y-2">
|
serviceMode={serviceModeFilter}
|
||||||
<CardEyebrow>Recent visits</CardEyebrow>
|
status={selectedStatus}
|
||||||
<div className="space-y-1">
|
totalPages={totalPages}
|
||||||
<CardTitle>最近访问</CardTitle>
|
onPageChange={onPageChange}
|
||||||
<CardDescription>
|
onQueryChange={setQueryValue}
|
||||||
这里已经接入真实后端最近订单列表,页面结构继续沿用首版设计。
|
onQuerySubmit={onQuerySubmit}
|
||||||
</CardDescription>
|
onServiceModeChange={setServiceModeFilter}
|
||||||
</div>
|
onStatusChange={onStatusChange}
|
||||||
</div>
|
/>
|
||||||
<div className="flex flex-col gap-3 lg:min-w-[360px]">
|
|
||||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
|
||||||
<span className="font-medium">订单关键词搜索</span>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<input
|
|
||||||
aria-label="订单关键词搜索"
|
|
||||||
value={queryValue}
|
|
||||||
onChange={(event) => setQueryValue(event.target.value)}
|
|
||||||
placeholder="搜索订单号或 workflow_id"
|
|
||||||
className="min-h-12 flex-1 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)] focus:ring-2 focus:ring-[var(--accent-ring)]"
|
|
||||||
/>
|
|
||||||
<Button onClick={() => onQuerySubmit?.(queryValue.trim())}>
|
|
||||||
搜索订单
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
|
||||||
<span className="font-medium">订单状态筛选</span>
|
|
||||||
<select
|
|
||||||
aria-label="订单状态筛选"
|
|
||||||
className="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)]"
|
|
||||||
value={selectedStatus}
|
|
||||||
onChange={(event) =>
|
|
||||||
onStatusChange?.(event.target.value as FilterStatus)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{ORDER_STATUS_FILTER_OPTIONS.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{isLoadingRecent ? (
|
|
||||||
<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}
|
|
||||||
|
|
||||||
{!isLoadingRecent && recentOrders.length ? (
|
<OrdersTable
|
||||||
recentOrders.map((order) => (
|
isLoading={isLoadingRecent}
|
||||||
<div
|
items={visibleOrders}
|
||||||
key={order.orderId}
|
onOpenOrder={onOpenOrder}
|
||||||
className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4"
|
onOpenWorkflow={onOpenWorkflow}
|
||||||
>
|
/>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
|
||||||
订单 #{order.orderId}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--ink-muted)]">
|
|
||||||
工作流 {order.workflowId ?? "未关联"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={order.status} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs text-[var(--ink-muted)]">
|
|
||||||
<StatusBadge variant="workflowStep" status={order.currentStep} />
|
|
||||||
<span>{order.currentStepLabel}</span>
|
|
||||||
<span>{formatTimestamp(order.updatedAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isLoadingRecent && !recentOrders.length ? (
|
|
||||||
<EmptyState
|
|
||||||
eyebrow="No recent orders"
|
|
||||||
title="暂无最近访问记录"
|
|
||||||
description="当前筛选条件下还没有可展示的订单。"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--border-soft)] pt-3">
|
|
||||||
<p className="text-xs text-[var(--ink-muted)]">
|
|
||||||
第 {Math.min(currentPage, effectiveTotalPages)} / {effectiveTotalPages} 页
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
disabled={currentPage <= 1}
|
|
||||||
onClick={() => onPageChange?.(currentPage - 1)}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
disabled={currentPage >= effectiveTotalPages}
|
|
||||||
onClick={() => onPageChange?.(currentPage + 1)}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -277,7 +128,8 @@ export function OrdersHomeScreen() {
|
|||||||
const [message, setMessage] = useState(DEFAULT_MESSAGE);
|
const [message, setMessage] = useState(DEFAULT_MESSAGE);
|
||||||
const [isLoadingRecent, setIsLoadingRecent] = useState(true);
|
const [isLoadingRecent, setIsLoadingRecent] = useState(true);
|
||||||
const [selectedQuery, setSelectedQuery] = useState("");
|
const [selectedQuery, setSelectedQuery] = useState("");
|
||||||
const [selectedStatus, setSelectedStatus] = useState<FilterStatus>("all");
|
const [selectedStatus, setSelectedStatus] =
|
||||||
|
useState<OrderFilterStatus>("all");
|
||||||
const [pagination, setPagination] = useState<PaginationData>(DEFAULT_PAGINATION);
|
const [pagination, setPagination] = useState<PaginationData>(DEFAULT_PAGINATION);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type ResourcePickerOption = LibraryItemVM & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ModelPickerOption = ResourcePickerOption & {
|
export type ModelPickerOption = ResourcePickerOption & {
|
||||||
poseId: number;
|
poseId?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResourceBinding = {
|
type ResourceBinding = {
|
||||||
@@ -68,9 +68,19 @@ export function getServiceModeForCustomerLevel(
|
|||||||
|
|
||||||
export function mapModelOptions(items: LibraryItemVM[]): ModelPickerOption[] {
|
export function mapModelOptions(items: LibraryItemVM[]): ModelPickerOption[] {
|
||||||
return items.flatMap((item) => {
|
return items.flatMap((item) => {
|
||||||
|
if (typeof item.backendId === "number") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...item,
|
||||||
|
backendId: item.backendId,
|
||||||
|
poseId: item.poseId ?? null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const binding = getResourceBinding(item.id);
|
const binding = getResourceBinding(item.id);
|
||||||
|
|
||||||
if (!binding?.poseId) {
|
if (!binding) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +88,7 @@ export function mapModelOptions(items: LibraryItemVM[]): ModelPickerOption[] {
|
|||||||
{
|
{
|
||||||
...item,
|
...item,
|
||||||
backendId: binding.backendId,
|
backendId: binding.backendId,
|
||||||
poseId: binding.poseId,
|
poseId: binding.poseId ?? null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -88,6 +98,15 @@ export function mapResourceOptions(
|
|||||||
items: LibraryItemVM[],
|
items: LibraryItemVM[],
|
||||||
): ResourcePickerOption[] {
|
): ResourcePickerOption[] {
|
||||||
return items.flatMap((item) => {
|
return items.flatMap((item) => {
|
||||||
|
if (typeof item.backendId === "number") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...item,
|
||||||
|
backendId: item.backendId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const binding = getResourceBinding(item.id);
|
const binding = getResourceBinding(item.id);
|
||||||
|
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
|
|||||||
@@ -176,8 +176,8 @@ export function SubmitWorkbench() {
|
|||||||
(item) => item.id === formValues.garmentId,
|
(item) => item.id === formValues.garmentId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!selectedModel || !selectedScene || !selectedGarment) {
|
if (!selectedModel || !selectedGarment) {
|
||||||
setSubmitError("请先完成模特、场景和服装资源选择。");
|
setSubmitError("请先完成模特和服装资源选择。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,9 +195,10 @@ export function SubmitWorkbench() {
|
|||||||
customer_level: formValues.customerLevel,
|
customer_level: formValues.customerLevel,
|
||||||
service_mode: formValues.serviceMode,
|
service_mode: formValues.serviceMode,
|
||||||
model_id: selectedModel.backendId,
|
model_id: selectedModel.backendId,
|
||||||
pose_id: selectedModel.poseId,
|
|
||||||
garment_asset_id: selectedGarment.backendId,
|
garment_asset_id: selectedGarment.backendId,
|
||||||
scene_ref_asset_id: selectedScene.backendId,
|
...(selectedScene
|
||||||
|
? { scene_ref_asset_id: selectedScene.backendId }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,14 +51,14 @@ export function ReviewActionPanel({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<CardEyebrow>Review action</CardEyebrow>
|
<CardEyebrow>Review action</CardEyebrow>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<CardTitle>审核动作面板</CardTitle>
|
<CardTitle>审核动作</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
审核页只负责做决策,不改详情数据。提交后等待后端结果,再刷新左侧待审核队列。
|
通过、重跑和驳回都在这里完成。详情页只负责决策,不直接修改订单资料。
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{order ? (
|
{order ? (
|
||||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4 text-sm text-[var(--ink-muted)]">
|
<div className="rounded-[var(--panel-radius)] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-3 py-3 text-sm text-[var(--ink-muted)]">
|
||||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
||||||
当前订单 #{order.orderId}
|
当前订单 #{order.orderId}
|
||||||
</p>
|
</p>
|
||||||
@@ -74,20 +74,20 @@ export function ReviewActionPanel({
|
|||||||
<textarea
|
<textarea
|
||||||
value={comment}
|
value={comment}
|
||||||
onChange={(event) => setComment(event.target.value)}
|
onChange={(event) => setComment(event.target.value)}
|
||||||
rows={5}
|
rows={4}
|
||||||
placeholder="重跑或驳回时填写原因,便于流程追踪和复盘。"
|
placeholder="重跑或驳回时填写原因,便于流程追踪和复盘。"
|
||||||
className="min-h-[132px] w-full rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-3 text-sm leading-6 text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)]"
|
className="min-h-[112px] w-full rounded-[var(--panel-radius)] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm leading-6 text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{submissionError ? (
|
{submissionError ? (
|
||||||
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
|
<div className="rounded-[var(--panel-radius)] border border-[#b88472] bg-[#f8ece5] px-4 py-3 text-sm text-[#7f4b3b]">
|
||||||
{submissionError}
|
{submissionError}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{submissionResult ? (
|
{submissionResult ? (
|
||||||
<div className="rounded-[24px] border border-[rgba(88,106,56,0.18)] bg-[rgba(110,127,82,0.12)] px-5 py-4 text-sm text-[#50633b]">
|
<div className="rounded-[var(--panel-radius)] border border-[rgba(88,106,56,0.18)] bg-[rgba(110,127,82,0.12)] px-4 py-3 text-sm text-[#50633b]">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span>已提交审核动作:</span>
|
<span>已提交审核动作:</span>
|
||||||
<StatusBadge variant="reviewDecision" status={submissionResult.decision} />
|
<StatusBadge variant="reviewDecision" status={submissionResult.decision} />
|
||||||
@@ -96,12 +96,12 @@ export function ReviewActionPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-2">
|
||||||
{ACTIONS.map((action) => (
|
{ACTIONS.map((action) => (
|
||||||
<Button
|
<Button
|
||||||
key={action.decision}
|
key={action.decision}
|
||||||
variant={action.variant}
|
variant={action.variant}
|
||||||
size="lg"
|
size="md"
|
||||||
disabled={!order || isSubmitting}
|
disabled={!order || isSubmitting}
|
||||||
onClick={() => onSubmit(action.decision, comment)}
|
onClick={() => onSubmit(action.decision, comment)}
|
||||||
>
|
>
|
||||||
|
|||||||
86
src/features/reviews/components/review-filters.tsx
Normal file
86
src/features/reviews/components/review-filters.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { PageToolbar } from "@/components/ui/page-toolbar";
|
||||||
|
import { Select } from "@/components/ui/select";
|
||||||
|
import type { ReviewTaskStatus } from "@/lib/types/backend";
|
||||||
|
|
||||||
|
export type ReviewStatusFilter = "all" | "waiting_review";
|
||||||
|
export type ReviewRevisionFilter =
|
||||||
|
| "all"
|
||||||
|
| "none"
|
||||||
|
| "revision_uploaded"
|
||||||
|
| "pending_manual_confirm";
|
||||||
|
|
||||||
|
type ReviewFiltersProps = {
|
||||||
|
query: string;
|
||||||
|
revisionFilter: ReviewRevisionFilter;
|
||||||
|
statusFilter: ReviewStatusFilter;
|
||||||
|
onQueryChange: (value: string) => void;
|
||||||
|
onRevisionFilterChange: (value: ReviewRevisionFilter) => void;
|
||||||
|
onStatusFilterChange: (value: ReviewStatusFilter) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REVISION_LABELS: Record<ReviewTaskStatus, string> = {
|
||||||
|
pending: "无修订稿",
|
||||||
|
revision_uploaded: "已上传修订稿",
|
||||||
|
submitted: "已提交",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getRevisionFilterLabel(
|
||||||
|
reviewTaskStatus: ReviewTaskStatus,
|
||||||
|
pendingManualConfirm: boolean,
|
||||||
|
) {
|
||||||
|
if (pendingManualConfirm) {
|
||||||
|
return "修订待确认";
|
||||||
|
}
|
||||||
|
|
||||||
|
return REVISION_LABELS[reviewTaskStatus];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewFilters({
|
||||||
|
query,
|
||||||
|
revisionFilter,
|
||||||
|
statusFilter,
|
||||||
|
onQueryChange,
|
||||||
|
onRevisionFilterChange,
|
||||||
|
onStatusFilterChange,
|
||||||
|
onRefresh,
|
||||||
|
}: ReviewFiltersProps) {
|
||||||
|
return (
|
||||||
|
<PageToolbar>
|
||||||
|
<Input
|
||||||
|
aria-label="审核关键词搜索"
|
||||||
|
className="min-w-[220px] flex-1 md:max-w-[320px]"
|
||||||
|
placeholder="搜索订单号或 workflowId"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
aria-label="审核状态筛选"
|
||||||
|
options={[
|
||||||
|
{ value: "all", label: "全部状态" },
|
||||||
|
{ value: "waiting_review", label: "待审核" },
|
||||||
|
]}
|
||||||
|
value={statusFilter}
|
||||||
|
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}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onRevisionFilterChange(value as ReviewRevisionFilter)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" onClick={onRefresh}>
|
||||||
|
刷新队列
|
||||||
|
</Button>
|
||||||
|
</PageToolbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -108,8 +108,8 @@ export function ReviewImagePanel({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{selectedAsset ? (
|
{selectedAsset ? (
|
||||||
<div className="rounded-[28px] border border-[var(--border-soft)] bg-[linear-gradient(180deg,rgba(250,247,242,0.96),rgba(238,231,221,0.92))] p-5">
|
<div className="rounded-[var(--panel-radius)] border border-[var(--border-soft)] bg-[linear-gradient(180deg,rgba(250,247,242,0.96),rgba(238,231,221,0.92))] p-4">
|
||||||
<div className="flex min-h-[320px] items-center justify-center rounded-[22px] border border-dashed border-[var(--border-strong)] bg-[rgba(255,255,255,0.55)] p-6 text-center">
|
<div className="flex min-h-[280px] items-center justify-center rounded-[12px] border border-dashed border-[var(--border-strong)] bg-[rgba(255,255,255,0.55)] p-6 text-center">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.22em] text-[var(--ink-faint)]">
|
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.22em] text-[var(--ink-faint)]">
|
||||||
{selectedAsset.isMock ? "Mock preview asset" : "Preview asset"}
|
{selectedAsset.isMock ? "Mock preview asset" : "Preview asset"}
|
||||||
@@ -135,13 +135,13 @@ export function ReviewImagePanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{order.hasMockAssets ? (
|
{order.hasMockAssets ? (
|
||||||
<div className="rounded-[24px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-5 py-4 text-sm text-[#7a5323]">
|
<div className="rounded-[var(--panel-radius)] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-4 py-3 text-sm text-[#7a5323]">
|
||||||
当前订单包含 mock 资产,审核结论仅用于前后台联调,不代表真实生产结果。
|
当前订单包含 mock 资产,审核结论仅用于前后台联调,不代表真实生产结果。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{assets.length ? (
|
{assets.length ? (
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{assets.map((asset) => {
|
{assets.map((asset) => {
|
||||||
const isSelected = asset.id === selectedAsset?.id;
|
const isSelected = asset.id === selectedAsset?.id;
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ export function ReviewImagePanel({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectAsset(asset.id)}
|
onClick={() => onSelectAsset(asset.id)}
|
||||||
className={joinClasses(
|
className={joinClasses(
|
||||||
"rounded-[24px] border px-4 py-4 text-left transition duration-150",
|
"rounded-[var(--panel-radius)] border px-3 py-3 text-left transition duration-150",
|
||||||
isSelected
|
isSelected
|
||||||
? "border-[var(--accent-primary)] bg-[rgba(110,127,82,0.09)]"
|
? "border-[var(--accent-primary)] bg-[rgba(110,127,82,0.09)]"
|
||||||
: "border-[var(--border-soft)] bg-[var(--surface-muted)] hover:bg-[var(--surface)]",
|
: "border-[var(--border-soft)] bg-[var(--surface-muted)] hover:bg-[var(--surface)]",
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { Card, CardContent, CardEyebrow, CardHeader } from "@/components/ui/card";
|
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { SectionTitle } from "@/components/ui/section-title";
|
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import type { ReviewQueueVM } from "@/lib/types/view-models";
|
import type { ReviewQueueVM } from "@/lib/types/view-models";
|
||||||
|
|
||||||
type ReviewQueueProps = {
|
type ReviewQueueProps = {
|
||||||
@@ -25,88 +31,119 @@ export function ReviewQueue({
|
|||||||
isLoading,
|
isLoading,
|
||||||
queue,
|
queue,
|
||||||
}: ReviewQueueProps) {
|
}: ReviewQueueProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[var(--panel-radius)] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
|
||||||
|
正在加载待审核队列…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[var(--panel-radius)] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queue?.state.kind === "business-empty") {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
eyebrow="Queue empty"
|
||||||
|
title={queue.state.title}
|
||||||
|
description={queue.state.description}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!queue?.items.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full">
|
<Table>
|
||||||
<CardHeader>
|
<TableHeader>
|
||||||
<SectionTitle
|
<TableRow>
|
||||||
eyebrow="Pending queue"
|
<TableHead>订单号</TableHead>
|
||||||
title="待审核队列"
|
<TableHead>workflowId</TableHead>
|
||||||
description="只展示当前后端真实返回的待审核订单,动作提交后再以非乐观方式刷新队列。"
|
<TableHead>当前状态</TableHead>
|
||||||
/>
|
<TableHead>当前步骤</TableHead>
|
||||||
</CardHeader>
|
<TableHead>修订状态</TableHead>
|
||||||
<CardContent className="space-y-4">
|
<TableHead>失败次数</TableHead>
|
||||||
{isLoading ? (
|
<TableHead>更新时间</TableHead>
|
||||||
<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)]">
|
<TableHead className="text-right">操作</TableHead>
|
||||||
正在加载待审核队列…
|
</TableRow>
|
||||||
</div>
|
</TableHeader>
|
||||||
) : null}
|
<TableBody>
|
||||||
|
{queue.items.map((item) => (
|
||||||
{!isLoading && error ? (
|
<TableRow key={item.reviewTaskId}>
|
||||||
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
|
<TableCell className="font-medium">#{item.orderId}</TableCell>
|
||||||
{error}
|
<TableCell>
|
||||||
</div>
|
<div className="space-y-1">
|
||||||
) : null}
|
<div>{item.workflowId}</div>
|
||||||
|
{item.workflowType ? (
|
||||||
{!isLoading && !error && queue?.state.kind === "business-empty" ? (
|
<div className="text-xs text-[var(--ink-muted)]">{item.workflowType}</div>
|
||||||
<EmptyState
|
) : null}
|
||||||
eyebrow="Queue empty"
|
</div>
|
||||||
title={queue.state.title}
|
</TableCell>
|
||||||
description={queue.state.description}
|
<TableCell>
|
||||||
/>
|
<StatusBadge status={item.status} />
|
||||||
) : null}
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
{!isLoading && !error && queue?.items.length ? (
|
<div className="flex flex-col gap-1">
|
||||||
<div className="space-y-3">
|
<StatusBadge variant="workflowStep" status={item.currentStep} />
|
||||||
{queue.items.map((item) => (
|
<span className="text-xs text-[var(--ink-muted)]">{item.currentStepLabel}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{item.pendingManualConfirm ? (
|
||||||
|
<span className="rounded-full bg-[rgba(88,106,56,0.12)] px-2 py-0.5 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em] text-[#50633b]">
|
||||||
|
修订待确认
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{item.latestRevisionAssetId ? (
|
||||||
|
<span className="rounded-full bg-[rgba(57,86,95,0.1)] px-2 py-0.5 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em] text-[#2e4d56]">
|
||||||
|
v{item.latestRevisionVersion ?? "?"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[var(--ink-muted)]">无修订稿</span>
|
||||||
|
)}
|
||||||
|
{item.hasMockAssets ? (
|
||||||
|
<span className="rounded-full bg-[rgba(145,104,46,0.12)] px-2 py-0.5 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em] text-[#7a5323]">
|
||||||
|
Mock 资产
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.failureCount > 0 ? (
|
||||||
|
<span className="rounded-full bg-[rgba(140,74,67,0.12)] px-2 py-0.5 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em] text-[#7f3f38]">
|
||||||
|
失败 {item.failureCount}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[var(--ink-muted)]">0</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-[var(--ink-muted)]">
|
||||||
|
{formatTimestamp(item.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
<Link
|
<Link
|
||||||
key={item.reviewTaskId}
|
key={item.orderId}
|
||||||
href={`/reviews/workbench/${item.orderId}`}
|
href={`/reviews/workbench/${item.orderId}`}
|
||||||
className={joinClasses(
|
className={joinClasses(
|
||||||
"block w-full rounded-[24px] border px-4 py-4 text-left transition duration-150",
|
"inline-flex h-9 items-center rounded-md border border-[var(--border-strong)] px-3 text-sm text-[var(--ink-strong)] transition",
|
||||||
"border-[var(--border-soft)] bg-[var(--surface-muted)] hover:border-[var(--border-strong)] hover:bg-[var(--surface)]",
|
"hover:bg-[var(--surface-muted)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
进入详情
|
||||||
<div className="space-y-2">
|
|
||||||
<CardEyebrow>Order #{item.orderId}</CardEyebrow>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-base font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
|
||||||
审核目标 #{item.orderId}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-[var(--ink-muted)]">
|
|
||||||
工作流 {item.workflowId}
|
|
||||||
{item.workflowType ? ` / ${item.workflowType}` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={item.status} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs text-[var(--ink-muted)]">
|
|
||||||
<StatusBadge variant="workflowStep" status={item.currentStep} />
|
|
||||||
<span>{item.currentStepLabel}</span>
|
|
||||||
<span>{formatTimestamp(item.createdAt)}</span>
|
|
||||||
{item.hasMockAssets ? (
|
|
||||||
<span className="rounded-full bg-[rgba(145,104,46,0.12)] px-2.5 py-1 font-[var(--font-mono)] uppercase tracking-[0.18em] text-[#7a5323]">
|
|
||||||
Mock 资产
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{item.failureCount > 0 ? (
|
|
||||||
<span className="rounded-full bg-[rgba(140,74,67,0.12)] px-2.5 py-1 font-[var(--font-mono)] uppercase tracking-[0.18em] text-[#7f3f38]">
|
|
||||||
失败 {item.failureCount}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{item.pendingManualConfirm ? (
|
|
||||||
<span className="rounded-full bg-[rgba(88,106,56,0.12)] px-2.5 py-1 font-[var(--font-mono)] uppercase tracking-[0.18em] text-[#50633b]">
|
|
||||||
修订待确认
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
</TableCell>
|
||||||
</div>
|
</TableRow>
|
||||||
) : null}
|
))}
|
||||||
</CardContent>
|
</TableBody>
|
||||||
</Card>
|
</Table>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function ReviewRevisionPanel({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<CardEyebrow>Manual revision</CardEyebrow>
|
<CardEyebrow>Manual revision</CardEyebrow>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<CardTitle>人工修订稿</CardTitle>
|
<CardTitle>人工修订</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
当前后端支持登记离线修订稿,并在修订稿确认后复用既有 approve
|
当前后端支持登记离线修订稿,并在修订稿确认后复用既有 approve
|
||||||
signal 继续流水线。
|
signal 继续流水线。
|
||||||
@@ -83,7 +83,7 @@ export function ReviewRevisionPanel({
|
|||||||
value={uploadedUri}
|
value={uploadedUri}
|
||||||
onChange={(event) => setUploadedUri(event.target.value)}
|
onChange={(event) => setUploadedUri(event.target.value)}
|
||||||
placeholder="mock://manual-revision-v1"
|
placeholder="mock://manual-revision-v1"
|
||||||
className="min-h-12 w-full rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)]"
|
className="h-9 w-full rounded-[var(--panel-radius)] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -96,32 +96,32 @@ export function ReviewRevisionPanel({
|
|||||||
onChange={(event) => setComment(event.target.value)}
|
onChange={(event) => setComment(event.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder="说明这版离线修订解决了什么问题。"
|
placeholder="说明这版离线修订解决了什么问题。"
|
||||||
className="min-h-[112px] w-full rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-3 text-sm leading-6 text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)]"
|
className="min-h-[104px] w-full rounded-[var(--panel-radius)] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm leading-6 text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{revisionError ? (
|
{revisionError ? (
|
||||||
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
|
<div className="rounded-[var(--panel-radius)] border border-[#b88472] bg-[#f8ece5] px-4 py-3 text-sm text-[#7f4b3b]">
|
||||||
{revisionError}
|
{revisionError}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{revisionResult ? (
|
{revisionResult ? (
|
||||||
<div className="rounded-[24px] border border-[rgba(88,106,56,0.18)] bg-[rgba(110,127,82,0.12)] px-5 py-4 text-sm text-[#50633b]">
|
<div className="rounded-[var(--panel-radius)] border border-[rgba(88,106,56,0.18)] bg-[rgba(110,127,82,0.12)] px-4 py-3 text-sm text-[#50633b]">
|
||||||
已登记修订稿 v{revisionResult.versionNo},当前共有 {revisionResult.revisionCount} 个修订版本。
|
已登记修订稿 v{revisionResult.versionNo},当前共有 {revisionResult.revisionCount} 个修订版本。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{confirmResult ? (
|
{confirmResult ? (
|
||||||
<div className="rounded-[24px] border border-[rgba(88,106,56,0.18)] bg-[rgba(110,127,82,0.12)] px-5 py-4 text-sm text-[#50633b]">
|
<div className="rounded-[var(--panel-radius)] border border-[rgba(88,106,56,0.18)] bg-[rgba(110,127,82,0.12)] px-4 py-3 text-sm text-[#50633b]">
|
||||||
已确认修订稿并继续流水线,等待返回审核队列。
|
已确认修订稿并继续流水线,等待返回审核队列。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="lg"
|
size="md"
|
||||||
disabled={!order || !selectedAsset || isSubmitting}
|
disabled={!order || !selectedAsset || isSubmitting}
|
||||||
onClick={() => onRegisterRevision({ uploadedUri, comment })}
|
onClick={() => onRegisterRevision({ uploadedUri, comment })}
|
||||||
>
|
>
|
||||||
@@ -130,7 +130,7 @@ export function ReviewRevisionPanel({
|
|||||||
{pendingManualConfirm ? (
|
{pendingManualConfirm ? (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="lg"
|
size="md"
|
||||||
disabled={!order || isSubmitting}
|
disabled={!order || isSubmitting}
|
||||||
onClick={() => onConfirmRevision(comment)}
|
onClick={() => onConfirmRevision(comment)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ export function ReviewWorkflowSummary({
|
|||||||
|
|
||||||
{!isLoading && !error && workflow ? (
|
{!isLoading && !error && workflow ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
|
<div className="rounded-[var(--panel-radius)] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-3 py-3">
|
||||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||||
Current step
|
Current step
|
||||||
</p>
|
</p>
|
||||||
@@ -64,7 +64,7 @@ export function ReviewWorkflowSummary({
|
|||||||
<StatusBadge status={workflow.status} />
|
<StatusBadge status={workflow.status} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
|
<div className="rounded-[var(--panel-radius)] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-3 py-3">
|
||||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
||||||
Failure count
|
Failure count
|
||||||
</p>
|
</p>
|
||||||
@@ -75,7 +75,7 @@ export function ReviewWorkflowSummary({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{workflow.hasMockAssets ? (
|
{workflow.hasMockAssets ? (
|
||||||
<div className="rounded-[24px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-5 py-4 text-sm text-[#7a5323]">
|
<div className="rounded-[var(--panel-radius)] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-4 py-3 text-sm text-[#7a5323]">
|
||||||
当前流程包含 mock 资产,适合联调审核动作,但不应被视为最终生产输出。
|
当前流程包含 mock 资产,适合联调审核动作,但不应被视为最终生产输出。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -85,7 +85,7 @@ export function ReviewWorkflowSummary({
|
|||||||
<div
|
<div
|
||||||
key={step.id}
|
key={step.id}
|
||||||
className={joinClasses(
|
className={joinClasses(
|
||||||
"rounded-[24px] border px-4 py-4",
|
"rounded-[var(--panel-radius)] border px-3 py-3",
|
||||||
step.isCurrent
|
step.isCurrent
|
||||||
? "border-[var(--accent-primary)] bg-[rgba(110,127,82,0.09)]"
|
? "border-[var(--accent-primary)] bg-[rgba(110,127,82,0.09)]"
|
||||||
: "border-[var(--border-soft)] bg-[var(--surface-muted)]",
|
: "border-[var(--border-soft)] bg-[var(--surface-muted)]",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
import { MetricChip } from "@/components/ui/metric-chip";
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
import { ReviewActionPanel } from "@/features/reviews/components/review-action-panel";
|
import { ReviewActionPanel } from "@/features/reviews/components/review-action-panel";
|
||||||
import { ReviewImagePanel } from "@/features/reviews/components/review-image-panel";
|
import { ReviewImagePanel } from "@/features/reviews/components/review-image-panel";
|
||||||
@@ -340,61 +340,48 @@ export function ReviewWorkbenchDetailScreen({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-8">
|
<section className="space-y-5">
|
||||||
<PageHeader
|
<div className="sticky top-0 z-10 -mx-4 border-b border-[var(--border-soft)] bg-[rgba(255,250,242,0.95)] px-4 py-4 backdrop-blur md:-mx-6 md:px-6">
|
||||||
eyebrow="Review detail"
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
title={`订单 #${orderDetail.orderId}`}
|
<div className="space-y-2">
|
||||||
description="审核详情页只处理单个订单,列表筛选和切单行为统一留在审核工作台首页。"
|
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-[var(--ink-faint)]">
|
||||||
meta={`更新于 ${formatTimestamp(orderDetail.updatedAt)}`}
|
Review detail
|
||||||
actions={
|
</p>
|
||||||
<Link
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
href="/reviews/workbench"
|
<h1 className="text-2xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]">
|
||||||
className="inline-flex min-h-11 items-center justify-center rounded-full border border-[var(--border-strong)] bg-[var(--surface)] px-4 text-sm font-medium text-[var(--ink-strong)] transition hover:bg-[var(--surface-muted)]"
|
订单 #{orderDetail.orderId}
|
||||||
>
|
</h1>
|
||||||
返回审核列表
|
<StatusBadge status={orderDetail.status} />
|
||||||
</Link>
|
<StatusBadge variant="workflowStep" status={orderDetail.currentStep} />
|
||||||
}
|
</div>
|
||||||
/>
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<MetricChip
|
||||||
|
label="workflow"
|
||||||
|
value={orderDetail.workflowId ?? "暂未分配"}
|
||||||
|
/>
|
||||||
|
<MetricChip label="step" value={orderDetail.currentStepLabel} />
|
||||||
|
<MetricChip
|
||||||
|
label="revision"
|
||||||
|
value={orderDetail.pendingManualConfirm ? "修订待确认" : "无待确认修订"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="flex flex-col items-start gap-2 xl:items-end">
|
||||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--ink-faint)]">
|
||||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
更新于 {formatTimestamp(orderDetail.updatedAt)}
|
||||||
Order status
|
</p>
|
||||||
</p>
|
<Link
|
||||||
<div className="mt-3">
|
href="/reviews/workbench"
|
||||||
<StatusBadge status={orderDetail.status} />
|
className="inline-flex h-9 items-center justify-center rounded-md border border-[var(--border-strong)] bg-[var(--surface)] px-3 text-sm font-medium text-[var(--ink-strong)] transition hover:bg-[var(--surface-muted)]"
|
||||||
</div>
|
>
|
||||||
</div>
|
返回审核列表
|
||||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
</Link>
|
||||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
|
||||||
Workflow
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
|
||||||
{orderDetail.workflowId ?? "暂未分配"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
|
||||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
|
||||||
Current step
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
||||||
<StatusBadge variant="workflowStep" status={orderDetail.currentStep} />
|
|
||||||
<span className="text-sm text-[var(--ink-muted)]">
|
|
||||||
{orderDetail.currentStepLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface)] px-4 py-4">
|
|
||||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
|
|
||||||
Workflow status
|
|
||||||
</p>
|
|
||||||
<div className="mt-3">
|
|
||||||
<StatusBadge status={workflowDetail.status} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||||
<ReviewImagePanel
|
<ReviewImagePanel
|
||||||
error={contextError}
|
error={contextError}
|
||||||
isLoading={isLoadingContext}
|
isLoading={isLoadingContext}
|
||||||
@@ -403,7 +390,16 @@ export function ReviewWorkbenchDetailScreen({
|
|||||||
onSelectAsset={setSelectedAssetId}
|
onSelectAsset={setSelectedAssetId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-4 xl:sticky xl:top-24 xl:self-start">
|
||||||
|
<ReviewActionPanel
|
||||||
|
key={`${orderDetail.orderId}-${submissionResult?.decision ?? "idle"}`}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
order={orderDetail}
|
||||||
|
selectedAsset={selectedAsset}
|
||||||
|
submissionError={submissionError}
|
||||||
|
submissionResult={submissionResult}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
<ReviewRevisionPanel
|
<ReviewRevisionPanel
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
order={orderDetail}
|
order={orderDetail}
|
||||||
@@ -415,15 +411,6 @@ export function ReviewWorkbenchDetailScreen({
|
|||||||
onRegisterRevision={handleRegisterRevision}
|
onRegisterRevision={handleRegisterRevision}
|
||||||
onConfirmRevision={handleConfirmRevision}
|
onConfirmRevision={handleConfirmRevision}
|
||||||
/>
|
/>
|
||||||
<ReviewActionPanel
|
|
||||||
key={`${orderDetail.orderId}-${submissionResult?.decision ?? "idle"}`}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
order={orderDetail}
|
|
||||||
selectedAsset={selectedAsset}
|
|
||||||
submissionError={submissionError}
|
|
||||||
submissionResult={submissionResult}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
/>
|
|
||||||
<ReviewWorkflowSummary
|
<ReviewWorkflowSummary
|
||||||
error={contextError}
|
error={contextError}
|
||||||
isLoading={isLoadingContext}
|
isLoading={isLoadingContext}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
|
import {
|
||||||
|
ReviewFilters,
|
||||||
|
type ReviewRevisionFilter,
|
||||||
|
type ReviewStatusFilter,
|
||||||
|
} from "@/features/reviews/components/review-filters";
|
||||||
import { ReviewQueue } from "@/features/reviews/components/review-queue";
|
import { ReviewQueue } from "@/features/reviews/components/review-queue";
|
||||||
import type { ReviewQueueVM } from "@/lib/types/view-models";
|
import type { ReviewQueueVM } from "@/lib/types/view-models";
|
||||||
|
|
||||||
@@ -19,6 +24,11 @@ export function ReviewWorkbenchListScreen() {
|
|||||||
const [queue, setQueue] = useState<ReviewQueueVM | null>(null);
|
const [queue, setQueue] = useState<ReviewQueueVM | null>(null);
|
||||||
const [queueError, setQueueError] = useState<string | null>(null);
|
const [queueError, setQueueError] = useState<string | null>(null);
|
||||||
const [isLoadingQueue, setIsLoadingQueue] = useState(true);
|
const [isLoadingQueue, setIsLoadingQueue] = useState(true);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<ReviewStatusFilter>("all");
|
||||||
|
const [revisionFilter, setRevisionFilter] =
|
||||||
|
useState<ReviewRevisionFilter>("all");
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -59,21 +69,72 @@ export function ReviewWorkbenchListScreen() {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [reloadKey]);
|
||||||
|
|
||||||
|
const filteredQueue = useMemo(() => {
|
||||||
|
if (!queue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const nextItems = queue.items.filter((item) => {
|
||||||
|
const matchesQuery =
|
||||||
|
!normalizedQuery ||
|
||||||
|
String(item.orderId).includes(normalizedQuery) ||
|
||||||
|
item.workflowId.toLowerCase().includes(normalizedQuery);
|
||||||
|
|
||||||
|
const matchesStatus =
|
||||||
|
statusFilter === "all" ? true : item.status === statusFilter;
|
||||||
|
|
||||||
|
const matchesRevision =
|
||||||
|
revisionFilter === "all"
|
||||||
|
? true
|
||||||
|
: revisionFilter === "none"
|
||||||
|
? !item.latestRevisionAssetId
|
||||||
|
: revisionFilter === "revision_uploaded"
|
||||||
|
? item.reviewTaskStatus === "revision_uploaded"
|
||||||
|
: item.pendingManualConfirm;
|
||||||
|
|
||||||
|
return matchesQuery && matchesStatus && matchesRevision;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...queue,
|
||||||
|
items: nextItems,
|
||||||
|
state:
|
||||||
|
nextItems.length > 0
|
||||||
|
? queue.state
|
||||||
|
: {
|
||||||
|
kind: "business-empty" as const,
|
||||||
|
title: "当前筛选下没有待处理任务",
|
||||||
|
description: "调整关键词或修订状态后再试,或者刷新队列同步后端最新状态。",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [query, queue, revisionFilter, statusFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-8">
|
<section className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Human review queue"
|
eyebrow="Human review queue"
|
||||||
title="审核工作台"
|
title="审核工作台"
|
||||||
description="列表页只负责展示待审核队列,点击具体订单后再进入独立详情页处理资产、动作和流程摘要。"
|
description="先在队列里筛选任务,再进入详情页执行审核或人工修订,列表本身不承载动作面板。"
|
||||||
meta="先看列表,再进详情"
|
meta="队列 -> 决策详情"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReviewFilters
|
||||||
|
query={query}
|
||||||
|
revisionFilter={revisionFilter}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
onRevisionFilterChange={setRevisionFilter}
|
||||||
|
onStatusFilterChange={setStatusFilter}
|
||||||
|
onRefresh={() => setReloadKey((value) => value + 1)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReviewQueue
|
<ReviewQueue
|
||||||
error={queueError}
|
error={queueError}
|
||||||
isLoading={isLoadingQueue}
|
isLoading={isLoadingQueue}
|
||||||
queue={queue}
|
queue={filteredQueue}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
108
src/features/workflows/components/workflow-table.tsx
Normal file
108
src/features/workflows/components/workflow-table.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import type { WorkflowLookupItemVM } from "@/lib/types/view-models";
|
||||||
|
|
||||||
|
type WorkflowTableProps = {
|
||||||
|
isLoading: boolean;
|
||||||
|
items: WorkflowLookupItemVM[];
|
||||||
|
onOpenWorkflow?: (orderId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp: string) {
|
||||||
|
return timestamp.replace("T", " ").replace("Z", " UTC");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowTable({
|
||||||
|
isLoading,
|
||||||
|
items,
|
||||||
|
onOpenWorkflow,
|
||||||
|
}: WorkflowTableProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[var(--panel-radius)] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
|
||||||
|
正在加载流程列表…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
eyebrow="Workflows empty"
|
||||||
|
title="当前筛选下没有流程"
|
||||||
|
description="调整关键词或状态后再试。"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>订单号</TableHead>
|
||||||
|
<TableHead>workflowId</TableHead>
|
||||||
|
<TableHead>流程类型</TableHead>
|
||||||
|
<TableHead>流程状态</TableHead>
|
||||||
|
<TableHead>当前步骤</TableHead>
|
||||||
|
<TableHead>失败次数</TableHead>
|
||||||
|
<TableHead>修订数</TableHead>
|
||||||
|
<TableHead>更新时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<TableRow key={item.workflowId}>
|
||||||
|
<TableCell className="font-medium">#{item.orderId}</TableCell>
|
||||||
|
<TableCell>{item.workflowId}</TableCell>
|
||||||
|
<TableCell>{item.workflowType}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<StatusBadge variant="workflowStep" status={item.currentStep} />
|
||||||
|
<span className="text-xs text-[var(--ink-muted)]">
|
||||||
|
{item.currentStepLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.failureCount}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="text-sm text-[var(--ink-strong)]">
|
||||||
|
{item.revisionCount}
|
||||||
|
</span>
|
||||||
|
{item.pendingManualConfirm ? (
|
||||||
|
<span className="rounded-full bg-[rgba(88,106,56,0.12)] px-2 py-0.5 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em] text-[#50633b]">
|
||||||
|
待确认
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-[var(--ink-muted)]">
|
||||||
|
{formatTimestamp(item.updatedAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onOpenWorkflow?.(String(item.orderId))}
|
||||||
|
>
|
||||||
|
查看流程
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,16 +34,16 @@ export function WorkflowTimeline({ viewModel }: WorkflowTimelineProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{viewModel.hasMockAssets ? (
|
{viewModel.hasMockAssets ? (
|
||||||
<div className="rounded-[24px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-5 py-4 text-sm text-[#7a5323]">
|
<div className="rounded-[24px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-5 py-4 text-sm text-[#7a5323]">
|
||||||
当前流程包含 mock 资产
|
当前流程包含 mock 资产
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{viewModel.steps.length ? (
|
{viewModel.steps.length ? (
|
||||||
<ol className="space-y-3">
|
<ol className="space-y-3">
|
||||||
{viewModel.steps.map((step) => (
|
{viewModel.steps.map((step) => (
|
||||||
<li
|
<li
|
||||||
key={step.id}
|
key={step.id}
|
||||||
className={joinClasses(
|
className={joinClasses(
|
||||||
@@ -77,14 +77,33 @@ export function WorkflowTimeline({ viewModel }: WorkflowTimelineProps) {
|
|||||||
<StatusBadge variant="workflowStep" status={step.name} />
|
<StatusBadge variant="workflowStep" status={step.name} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{step.errorMessage ? (
|
{step.errorMessage ? (
|
||||||
<p className="mt-4 rounded-[18px] bg-[rgba(255,255,255,0.5)] px-3 py-3 text-sm leading-6 text-[#7f3f38]">
|
<p className="mt-4 rounded-[18px] bg-[rgba(255,255,255,0.5)] px-3 py-3 text-sm leading-6 text-[#7f3f38]">
|
||||||
{step.errorMessage}
|
{step.errorMessage}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step.mockAssetUris.length ? (
|
{step.previewUri ? (
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 rounded-[20px] border border-[var(--border-soft)] bg-[rgba(74,64,53,0.06)] p-3">
|
||||||
|
{step.previewUri.startsWith("mock://") ? (
|
||||||
|
<div className="flex aspect-[4/3] items-center justify-center rounded-[16px] border border-dashed border-[rgba(145,104,46,0.24)] bg-[rgba(202,164,97,0.1)] px-4 py-4 text-center text-xs leading-5 text-[#7a5323]">
|
||||||
|
当前步骤只有 mock 预览
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-[4/3] items-center justify-center rounded-[16px] bg-[rgba(255,255,255,0.55)] p-3">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={step.previewUri}
|
||||||
|
alt={`${step.label}预览`}
|
||||||
|
className="block max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step.mockAssetUris.length ? (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
{step.mockAssetUris.map((uri) => (
|
{step.mockAssetUris.map((uri) => (
|
||||||
<code
|
<code
|
||||||
key={uri}
|
key={uri}
|
||||||
|
|||||||
79
src/features/workflows/components/workflow-toolbar.tsx
Normal file
79
src/features/workflows/components/workflow-toolbar.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { PageToolbar } from "@/components/ui/page-toolbar";
|
||||||
|
import { Select } from "@/components/ui/select";
|
||||||
|
import { ORDER_STATUS_META } from "@/lib/types/status";
|
||||||
|
import type { OrderStatus } from "@/lib/types/backend";
|
||||||
|
|
||||||
|
export type WorkflowFilterStatus = OrderStatus | "all";
|
||||||
|
|
||||||
|
type WorkflowToolbarProps = {
|
||||||
|
currentPage: number;
|
||||||
|
query: string;
|
||||||
|
status: WorkflowFilterStatus;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
onQueryChange: (value: string) => void;
|
||||||
|
onQuerySubmit?: (query: string) => void;
|
||||||
|
onStatusChange?: (value: WorkflowFilterStatus) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkflowToolbar({
|
||||||
|
currentPage,
|
||||||
|
query,
|
||||||
|
status,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
onQueryChange,
|
||||||
|
onQuerySubmit,
|
||||||
|
onStatusChange,
|
||||||
|
}: WorkflowToolbarProps) {
|
||||||
|
const safeTotalPages = Math.max(totalPages, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageToolbar className="justify-between gap-4">
|
||||||
|
<div className="flex flex-1 flex-wrap items-center gap-3">
|
||||||
|
<Input
|
||||||
|
aria-label="流程关键词搜索"
|
||||||
|
className="min-w-[220px] flex-1 md:max-w-[320px]"
|
||||||
|
placeholder="搜索订单号或 workflow_id"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<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}
|
||||||
|
onValueChange={(value) => onStatusChange?.(value as WorkflowFilterStatus)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-[var(--ink-muted)]">
|
||||||
|
第 {Math.min(currentPage, safeTotalPages)} / {safeTotalPages} 页
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
onClick={() => onPageChange?.(currentPage - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={currentPage >= safeTotalPages}
|
||||||
|
onClick={() => onPageChange?.(currentPage + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PageToolbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,16 +3,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { MetricChip } from "@/components/ui/metric-chip";
|
||||||
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
|
||||||
import { ORDER_STATUS_META } from "@/lib/types/status";
|
|
||||||
import type { OrderStatus } from "@/lib/types/backend";
|
|
||||||
import type { WorkflowLookupItemVM } from "@/lib/types/view-models";
|
import type { WorkflowLookupItemVM } from "@/lib/types/view-models";
|
||||||
|
import {
|
||||||
|
WorkflowToolbar,
|
||||||
|
type WorkflowFilterStatus,
|
||||||
|
} from "@/features/workflows/components/workflow-toolbar";
|
||||||
|
import { WorkflowTable } from "@/features/workflows/components/workflow-table";
|
||||||
|
|
||||||
type FilterStatus = OrderStatus | "all";
|
|
||||||
type PaginationData = {
|
type PaginationData = {
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@@ -28,9 +27,9 @@ type WorkflowLookupProps = {
|
|||||||
onOpenWorkflow?: (orderId: string) => void;
|
onOpenWorkflow?: (orderId: string) => void;
|
||||||
onPageChange?: (page: number) => void;
|
onPageChange?: (page: number) => void;
|
||||||
onQuerySubmit?: (query: string) => void;
|
onQuerySubmit?: (query: string) => void;
|
||||||
onStatusChange?: (status: FilterStatus) => void;
|
onStatusChange?: (status: WorkflowFilterStatus) => void;
|
||||||
selectedQuery?: string;
|
selectedQuery?: string;
|
||||||
selectedStatus?: FilterStatus;
|
selectedStatus?: WorkflowFilterStatus;
|
||||||
totalPages?: number;
|
totalPages?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,17 +51,6 @@ const DEFAULT_PAGINATION: PaginationData = {
|
|||||||
total: 0,
|
total: 0,
|
||||||
totalPages: 0,
|
totalPages: 0,
|
||||||
};
|
};
|
||||||
const WORKFLOW_STATUS_FILTER_OPTIONS: Array<{
|
|
||||||
label: string;
|
|
||||||
value: FilterStatus;
|
|
||||||
}> = [
|
|
||||||
{ value: "all", label: "全部状态" },
|
|
||||||
...Object.entries(ORDER_STATUS_META).map(([value, meta]) => ({
|
|
||||||
value: value as OrderStatus,
|
|
||||||
label: meta.label,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
export function WorkflowLookup({
|
export function WorkflowLookup({
|
||||||
currentPage = 1,
|
currentPage = 1,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -76,171 +64,45 @@ export function WorkflowLookup({
|
|||||||
selectedStatus = "all",
|
selectedStatus = "all",
|
||||||
totalPages = 0,
|
totalPages = 0,
|
||||||
}: WorkflowLookupProps) {
|
}: WorkflowLookupProps) {
|
||||||
const [lookupValue, setLookupValue] = useState("");
|
|
||||||
const [queryValue, setQueryValue] = useState(selectedQuery);
|
const [queryValue, setQueryValue] = useState(selectedQuery);
|
||||||
const normalizedLookup = lookupValue.trim();
|
|
||||||
const canLookup = /^\d+$/.test(normalizedLookup);
|
|
||||||
const effectiveTotalPages = Math.max(totalPages, 1);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQueryValue(selectedQuery);
|
setQueryValue(selectedQuery);
|
||||||
}, [selectedQuery]);
|
}, [selectedQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-8">
|
<section className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Workflow lookup"
|
eyebrow="Workflow lookup"
|
||||||
title="流程追踪"
|
title="流程追踪"
|
||||||
description="当前首页已经接入真实最近流程列表,并保留订单号直达能力,方便快速排查。"
|
description="流程页承担排查和定位职责,首屏优先显示状态、失败信息和进入详情的操作。"
|
||||||
meta="真实列表入口"
|
meta="真实列表页"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="rounded-[28px] border border-[rgba(57,86,95,0.16)] bg-[rgba(57,86,95,0.1)] px-6 py-5 text-sm leading-7 text-[#2e4d56]">
|
<div className="flex flex-wrap gap-2">
|
||||||
{message}
|
<MetricChip label="mode" value="真实流程列表" />
|
||||||
|
<MetricChip label="rows" value={items.length} />
|
||||||
|
<MetricChip label="message" value="流程追踪已接入真实后端" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
|
<p className="text-sm text-[var(--ink-muted)]">{message}</p>
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<CardEyebrow>Direct lookup</CardEyebrow>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<CardTitle>按订单号打开流程</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
除了最近流程列表,也支持按订单号直接进入真实流程详情页。
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<input
|
|
||||||
value={lookupValue}
|
|
||||||
onChange={(event) => setLookupValue(event.target.value)}
|
|
||||||
placeholder="输入订单号,例如 4201"
|
|
||||||
className="min-h-12 w-full rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)]"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
disabled={!canLookup}
|
|
||||||
onClick={() => onOpenWorkflow?.(normalizedLookup)}
|
|
||||||
>
|
|
||||||
打开流程详情
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<WorkflowToolbar
|
||||||
<CardHeader>
|
currentPage={currentPage}
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
query={queryValue}
|
||||||
<div className="space-y-2">
|
status={selectedStatus}
|
||||||
<CardEyebrow>Placeholder index</CardEyebrow>
|
totalPages={totalPages}
|
||||||
<div className="space-y-1">
|
onPageChange={onPageChange}
|
||||||
<CardTitle>流程索引占位</CardTitle>
|
onQueryChange={setQueryValue}
|
||||||
<CardDescription>
|
onQuerySubmit={onQuerySubmit}
|
||||||
这里已经接入真实后端最近流程列表,继续沿用首版查询页结构。
|
onStatusChange={onStatusChange}
|
||||||
</CardDescription>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3 lg:min-w-[360px]">
|
|
||||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
|
||||||
<span className="font-medium">流程关键词搜索</span>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<input
|
|
||||||
aria-label="流程关键词搜索"
|
|
||||||
value={queryValue}
|
|
||||||
onChange={(event) => setQueryValue(event.target.value)}
|
|
||||||
placeholder="搜索订单号或 workflow_id"
|
|
||||||
className="min-h-12 flex-1 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)] outline-none transition placeholder:text-[var(--ink-faint)] focus:border-[var(--accent-primary)] focus:bg-[var(--surface)] focus:ring-2 focus:ring-[var(--accent-ring)]"
|
|
||||||
/>
|
|
||||||
<Button onClick={() => onQuerySubmit?.(queryValue.trim())}>
|
|
||||||
搜索流程
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
|
|
||||||
<span className="font-medium">流程状态筛选</span>
|
|
||||||
<select
|
|
||||||
aria-label="流程状态筛选"
|
|
||||||
className="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)]"
|
|
||||||
value={selectedStatus}
|
|
||||||
onChange={(event) =>
|
|
||||||
onStatusChange?.(event.target.value as FilterStatus)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{WORKFLOW_STATUS_FILTER_OPTIONS.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{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}
|
|
||||||
|
|
||||||
{!isLoading && items.length ? (
|
<WorkflowTable
|
||||||
items.map((item) => (
|
isLoading={isLoading}
|
||||||
<div
|
items={items}
|
||||||
key={item.workflowId}
|
onOpenWorkflow={onOpenWorkflow}
|
||||||
className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4"
|
/>
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-semibold text-[var(--ink-strong)]">
|
|
||||||
订单 #{item.orderId}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--ink-muted)]">
|
|
||||||
{item.workflowId} / {item.workflowType}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={item.status} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs text-[var(--ink-muted)]">
|
|
||||||
<StatusBadge variant="workflowStep" status={item.currentStep} />
|
|
||||||
<span>{item.currentStepLabel}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isLoading && !items.length ? (
|
|
||||||
<EmptyState
|
|
||||||
eyebrow="Lookup empty"
|
|
||||||
title="暂无流程索引"
|
|
||||||
description="当前筛选条件下还没有可展示的流程记录。"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--border-soft)] pt-3">
|
|
||||||
<p className="text-xs text-[var(--ink-muted)]">
|
|
||||||
第 {Math.min(currentPage, effectiveTotalPages)} / {effectiveTotalPages} 页
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
disabled={currentPage <= 1}
|
|
||||||
onClick={() => onPageChange?.(currentPage - 1)}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
disabled={currentPage >= effectiveTotalPages}
|
|
||||||
onClick={() => onPageChange?.(currentPage + 1)}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -251,7 +113,8 @@ export function WorkflowLookupScreen() {
|
|||||||
const [message, setMessage] = useState(DEFAULT_MESSAGE);
|
const [message, setMessage] = useState(DEFAULT_MESSAGE);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [selectedQuery, setSelectedQuery] = useState("");
|
const [selectedQuery, setSelectedQuery] = useState("");
|
||||||
const [selectedStatus, setSelectedStatus] = useState<FilterStatus>("all");
|
const [selectedStatus, setSelectedStatus] =
|
||||||
|
useState<WorkflowFilterStatus>("all");
|
||||||
const [pagination, setPagination] = useState<PaginationData>(DEFAULT_PAGINATION);
|
const [pagination, setPagination] = useState<PaginationData>(DEFAULT_PAGINATION);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -62,16 +62,30 @@ export function adaptAsset(asset: AssetDto): AssetViewModel {
|
|||||||
export function adaptOrderSummary(
|
export function adaptOrderSummary(
|
||||||
order: Pick<
|
order: Pick<
|
||||||
OrderDetailResponseDto | OrderListItemDto,
|
OrderDetailResponseDto | OrderListItemDto,
|
||||||
"order_id" | "workflow_id" | "status" | "current_step" | "updated_at"
|
| "order_id"
|
||||||
|
| "workflow_id"
|
||||||
|
| "customer_level"
|
||||||
|
| "service_mode"
|
||||||
|
| "status"
|
||||||
|
| "current_step"
|
||||||
|
| "review_task_status"
|
||||||
|
| "revision_count"
|
||||||
|
| "pending_manual_confirm"
|
||||||
|
| "updated_at"
|
||||||
>,
|
>,
|
||||||
): OrderSummaryVM {
|
): OrderSummaryVM {
|
||||||
return {
|
return {
|
||||||
orderId: order.order_id,
|
orderId: order.order_id,
|
||||||
workflowId: order.workflow_id,
|
workflowId: order.workflow_id,
|
||||||
|
customerLevel: order.customer_level,
|
||||||
|
serviceMode: order.service_mode,
|
||||||
status: order.status,
|
status: order.status,
|
||||||
statusMeta: getOrderStatusMeta(order.status),
|
statusMeta: getOrderStatusMeta(order.status),
|
||||||
currentStep: order.current_step,
|
currentStep: order.current_step,
|
||||||
currentStepLabel: getWorkflowStepMeta(order.current_step).label,
|
currentStepLabel: getWorkflowStepMeta(order.current_step).label,
|
||||||
|
reviewTaskStatus: order.review_task_status,
|
||||||
|
revisionCount: order.revision_count,
|
||||||
|
pendingManualConfirm: order.pending_manual_confirm,
|
||||||
updatedAt: order.updated_at,
|
updatedAt: order.updated_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,43 @@ import {
|
|||||||
} from "@/lib/types/view-models";
|
} from "@/lib/types/view-models";
|
||||||
|
|
||||||
type WorkflowAssetUriField =
|
type WorkflowAssetUriField =
|
||||||
|
| "uri"
|
||||||
| "asset_uri"
|
| "asset_uri"
|
||||||
| "candidate_uri"
|
| "candidate_uri"
|
||||||
| "preview_uri"
|
| "preview_uri"
|
||||||
| "result_uri"
|
| "result_uri"
|
||||||
| "source_uri";
|
| "source_uri";
|
||||||
|
|
||||||
|
type WorkflowLookupSource =
|
||||||
|
| Pick<
|
||||||
|
WorkflowListItemDto,
|
||||||
|
| "order_id"
|
||||||
|
| "workflow_id"
|
||||||
|
| "workflow_type"
|
||||||
|
| "workflow_status"
|
||||||
|
| "current_step"
|
||||||
|
| "failure_count"
|
||||||
|
| "review_task_status"
|
||||||
|
| "revision_count"
|
||||||
|
| "pending_manual_confirm"
|
||||||
|
| "updated_at"
|
||||||
|
>
|
||||||
|
| Pick<
|
||||||
|
WorkflowStatusResponseDto,
|
||||||
|
| "order_id"
|
||||||
|
| "workflow_id"
|
||||||
|
| "workflow_type"
|
||||||
|
| "workflow_status"
|
||||||
|
| "current_step"
|
||||||
|
| "review_task_status"
|
||||||
|
| "revision_count"
|
||||||
|
| "pending_manual_confirm"
|
||||||
|
| "updated_at"
|
||||||
|
| "steps"
|
||||||
|
>;
|
||||||
|
|
||||||
const WORKFLOW_ASSET_URI_FIELDS = new Set<WorkflowAssetUriField>([
|
const WORKFLOW_ASSET_URI_FIELDS = new Set<WorkflowAssetUriField>([
|
||||||
|
"uri",
|
||||||
"asset_uri",
|
"asset_uri",
|
||||||
"candidate_uri",
|
"candidate_uri",
|
||||||
"preview_uri",
|
"preview_uri",
|
||||||
@@ -64,8 +94,47 @@ function collectKnownAssetUris(
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findFirstKnownAssetUri(value: JsonValue | undefined): string | null {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
const nested = findFirstKnownAssetUri(item);
|
||||||
|
if (nested) {
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, nestedValue] of Object.entries(value)) {
|
||||||
|
if (
|
||||||
|
WORKFLOW_ASSET_URI_FIELDS.has(key as WorkflowAssetUriField) &&
|
||||||
|
typeof nestedValue === "string"
|
||||||
|
) {
|
||||||
|
return nestedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = findFirstKnownAssetUri(nestedValue);
|
||||||
|
if (nested) {
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function uniqueMockUris(...payloads: Array<JsonObject | null>): string[] {
|
function uniqueMockUris(...payloads: Array<JsonObject | null>): string[] {
|
||||||
return [...new Set(payloads.flatMap((payload) => collectKnownAssetUris(payload)))];
|
return [
|
||||||
|
...new Set(
|
||||||
|
payloads.flatMap((payload) =>
|
||||||
|
collectKnownAssetUris(payload).filter((uri) => uri.startsWith("mock://")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function adaptWorkflowStep(
|
function adaptWorkflowStep(
|
||||||
@@ -89,22 +158,20 @@ function adaptWorkflowStep(
|
|||||||
endedAt: step.ended_at,
|
endedAt: step.ended_at,
|
||||||
containsMockAssets: mockAssetUris.length > 0,
|
containsMockAssets: mockAssetUris.length > 0,
|
||||||
mockAssetUris,
|
mockAssetUris,
|
||||||
|
previewUri: findFirstKnownAssetUri(step.output_json) ?? findFirstKnownAssetUri(step.input_json),
|
||||||
isCurrent: currentStep === step.step_name,
|
isCurrent: currentStep === step.step_name,
|
||||||
isFailed: step.step_status === "failed",
|
isFailed: step.step_status === "failed",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adaptWorkflowLookupItem(
|
export function adaptWorkflowLookupItem(workflow: WorkflowLookupSource): WorkflowLookupItemVM {
|
||||||
workflow: Pick<
|
const failureCount =
|
||||||
WorkflowStatusResponseDto | WorkflowListItemDto,
|
"failure_count" in workflow
|
||||||
| "order_id"
|
? workflow.failure_count
|
||||||
| "workflow_id"
|
: "steps" in workflow
|
||||||
| "workflow_type"
|
? workflow.steps.filter((step) => step.step_status === "failed").length
|
||||||
| "workflow_status"
|
: 0;
|
||||||
| "current_step"
|
|
||||||
| "updated_at"
|
|
||||||
>,
|
|
||||||
): WorkflowLookupItemVM {
|
|
||||||
return {
|
return {
|
||||||
orderId: workflow.order_id,
|
orderId: workflow.order_id,
|
||||||
workflowId: workflow.workflow_id,
|
workflowId: workflow.workflow_id,
|
||||||
@@ -113,6 +180,10 @@ export function adaptWorkflowLookupItem(
|
|||||||
statusMeta: getOrderStatusMeta(workflow.workflow_status),
|
statusMeta: getOrderStatusMeta(workflow.workflow_status),
|
||||||
currentStep: workflow.current_step,
|
currentStep: workflow.current_step,
|
||||||
currentStepLabel: getWorkflowStepMeta(workflow.current_step).label,
|
currentStepLabel: getWorkflowStepMeta(workflow.current_step).label,
|
||||||
|
failureCount,
|
||||||
|
reviewTaskStatus: workflow.review_task_status,
|
||||||
|
revisionCount: workflow.revision_count,
|
||||||
|
pendingManualConfirm: workflow.pending_manual_confirm,
|
||||||
updatedAt: workflow.updated_at,
|
updatedAt: workflow.updated_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ export type CreateOrderRequestDto = {
|
|||||||
customer_level: CustomerLevel;
|
customer_level: CustomerLevel;
|
||||||
service_mode: ServiceMode;
|
service_mode: ServiceMode;
|
||||||
model_id: number;
|
model_id: number;
|
||||||
pose_id: number;
|
pose_id?: number;
|
||||||
garment_asset_id: number;
|
garment_asset_id: number;
|
||||||
scene_ref_asset_id: number;
|
scene_ref_asset_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateOrderResponseDto = {
|
export type CreateOrderResponseDto = {
|
||||||
@@ -92,9 +92,9 @@ export type OrderDetailResponseDto = {
|
|||||||
service_mode: ServiceMode;
|
service_mode: ServiceMode;
|
||||||
status: OrderStatus;
|
status: OrderStatus;
|
||||||
model_id: number;
|
model_id: number;
|
||||||
pose_id: number;
|
pose_id: number | null;
|
||||||
garment_asset_id: number;
|
garment_asset_id: number;
|
||||||
scene_ref_asset_id: number;
|
scene_ref_asset_id: number | null;
|
||||||
final_asset_id: number | null;
|
final_asset_id: number | null;
|
||||||
workflow_id: string | null;
|
workflow_id: string | null;
|
||||||
current_step: WorkflowStepName | null;
|
current_step: WorkflowStepName | null;
|
||||||
|
|||||||
@@ -43,10 +43,15 @@ export type AssetViewModel = {
|
|||||||
export type OrderSummaryVM = {
|
export type OrderSummaryVM = {
|
||||||
orderId: number;
|
orderId: number;
|
||||||
workflowId: string | null;
|
workflowId: string | null;
|
||||||
|
customerLevel: CustomerLevel;
|
||||||
|
serviceMode: ServiceMode;
|
||||||
status: OrderStatus;
|
status: OrderStatus;
|
||||||
statusMeta: StatusMeta;
|
statusMeta: StatusMeta;
|
||||||
currentStep: WorkflowStepName | null;
|
currentStep: WorkflowStepName | null;
|
||||||
currentStepLabel: string;
|
currentStepLabel: string;
|
||||||
|
reviewTaskStatus: ReviewTaskStatus | null;
|
||||||
|
revisionCount: number;
|
||||||
|
pendingManualConfirm: boolean;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,9 +65,9 @@ export type OrderDetailVM = {
|
|||||||
currentStep: WorkflowStepName | null;
|
currentStep: WorkflowStepName | null;
|
||||||
currentStepLabel: string;
|
currentStepLabel: string;
|
||||||
modelId: number;
|
modelId: number;
|
||||||
poseId: number;
|
poseId: number | null;
|
||||||
garmentAssetId: number;
|
garmentAssetId: number;
|
||||||
sceneRefAssetId: number;
|
sceneRefAssetId: number | null;
|
||||||
currentRevisionAssetId: number | null;
|
currentRevisionAssetId: number | null;
|
||||||
currentRevisionVersion: number | null;
|
currentRevisionVersion: number | null;
|
||||||
latestRevisionAssetId: number | null;
|
latestRevisionAssetId: number | null;
|
||||||
@@ -157,6 +162,7 @@ export type WorkflowStepVM = {
|
|||||||
endedAt: string | null;
|
endedAt: string | null;
|
||||||
containsMockAssets: boolean;
|
containsMockAssets: boolean;
|
||||||
mockAssetUris: string[];
|
mockAssetUris: string[];
|
||||||
|
previewUri: string | null;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
isFailed: boolean;
|
isFailed: boolean;
|
||||||
};
|
};
|
||||||
@@ -169,6 +175,10 @@ export type WorkflowLookupItemVM = {
|
|||||||
statusMeta: StatusMeta;
|
statusMeta: StatusMeta;
|
||||||
currentStep: WorkflowStepName | null;
|
currentStep: WorkflowStepName | null;
|
||||||
currentStepLabel: string;
|
currentStepLabel: string;
|
||||||
|
failureCount: number;
|
||||||
|
reviewTaskStatus: ReviewTaskStatus | null;
|
||||||
|
revisionCount: number;
|
||||||
|
pendingManualConfirm: boolean;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -197,14 +207,24 @@ export type WorkflowDetailVM = {
|
|||||||
|
|
||||||
export type LibraryType = "models" | "scenes" | "garments";
|
export type LibraryType = "models" | "scenes" | "garments";
|
||||||
|
|
||||||
|
export type LibraryFileVM = {
|
||||||
|
id: number;
|
||||||
|
role: "original" | "thumbnail" | "gallery";
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type LibraryItemVM = {
|
export type LibraryItemVM = {
|
||||||
id: string;
|
id: string;
|
||||||
libraryType: LibraryType;
|
libraryType: LibraryType;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
previewUri: string;
|
previewUri: string;
|
||||||
|
originalUri?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
files?: LibraryFileVM[];
|
||||||
isMock: boolean;
|
isMock: boolean;
|
||||||
|
backendId?: number;
|
||||||
|
poseId?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const READY_STATE: ReadyState = {
|
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,9 +8,9 @@ export const createOrderSchema = z
|
|||||||
customer_level: z.enum(["low", "mid"]),
|
customer_level: z.enum(["low", "mid"]),
|
||||||
service_mode: z.enum(["auto_basic", "semi_pro"]),
|
service_mode: z.enum(["auto_basic", "semi_pro"]),
|
||||||
model_id: z.number().int().positive(),
|
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(),
|
garment_asset_id: z.number().int().positive(),
|
||||||
scene_ref_asset_id: z.number().int().positive(),
|
scene_ref_asset_id: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
const validServiceMode =
|
const validServiceMode =
|
||||||
|
|||||||
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"), {
|
const response = await GET(new Request("http://localhost/api/libraries/models"), {
|
||||||
params: Promise.resolve({ libraryType: "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(response.status).toBe(200);
|
||||||
expect(payload).toMatchObject({
|
expect(payload).toMatchObject({
|
||||||
mode: "placeholder",
|
mode: "proxy",
|
||||||
data: {
|
data: {
|
||||||
items: expect.arrayContaining([
|
items: expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
id: "12",
|
||||||
libraryType: "models",
|
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 () => {
|
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: "不支持的资源库类型。",
|
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",
|
customer_level: "mid",
|
||||||
service_mode: "semi_pro",
|
service_mode: "semi_pro",
|
||||||
model_id: 101,
|
model_id: 101,
|
||||||
pose_id: 202,
|
|
||||||
garment_asset_id: 303,
|
garment_asset_id: 303,
|
||||||
scene_ref_asset_id: 404,
|
scene_ref_asset_id: 404,
|
||||||
}),
|
}),
|
||||||
@@ -76,7 +75,6 @@ test("rejects invalid order creation payloads before proxying", async () => {
|
|||||||
customer_level: "low",
|
customer_level: "low",
|
||||||
service_mode: "semi_pro",
|
service_mode: "semi_pro",
|
||||||
model_id: 101,
|
model_id: 101,
|
||||||
pose_id: 202,
|
|
||||||
garment_asset_id: 303,
|
garment_asset_id: 303,
|
||||||
scene_ref_asset_id: 404,
|
scene_ref_asset_id: 404,
|
||||||
}),
|
}),
|
||||||
@@ -143,7 +141,6 @@ test("normalizes upstream validation errors from the backend", async () => {
|
|||||||
customer_level: "mid",
|
customer_level: "mid",
|
||||||
service_mode: "semi_pro",
|
service_mode: "semi_pro",
|
||||||
model_id: 101,
|
model_id: 101,
|
||||||
pose_id: 202,
|
|
||||||
garment_asset_id: 303,
|
garment_asset_id: 303,
|
||||||
scene_ref_asset_id: 404,
|
scene_ref_asset_id: 404,
|
||||||
}),
|
}),
|
||||||
@@ -161,3 +158,63 @@ test("normalizes upstream validation errors from the backend", async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts order creation payloads without a scene asset id", async () => {
|
||||||
|
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
order_id: 88,
|
||||||
|
workflow_id: "wf-88",
|
||||||
|
status: "created",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const request = new Request("http://localhost/api/orders", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
customer_level: "low",
|
||||||
|
service_mode: "auto_basic",
|
||||||
|
model_id: 101,
|
||||||
|
garment_asset_id: 303,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(payload).toEqual({
|
||||||
|
mode: "proxy",
|
||||||
|
data: {
|
||||||
|
orderId: 88,
|
||||||
|
workflowId: "wf-88",
|
||||||
|
status: "created",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://backend.test/api/v1/orders",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
customer_level: "low",
|
||||||
|
service_mode: "auto_basic",
|
||||||
|
model_id: 101,
|
||||||
|
garment_asset_id: 303,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ test("proxies recent orders overview from the backend list api", async () => {
|
|||||||
{
|
{
|
||||||
orderId: 3,
|
orderId: 3,
|
||||||
workflowId: "order-3",
|
workflowId: "order-3",
|
||||||
|
customerLevel: "mid",
|
||||||
|
serviceMode: "semi_pro",
|
||||||
status: "waiting_review",
|
status: "waiting_review",
|
||||||
statusMeta: {
|
statusMeta: {
|
||||||
label: "待审核",
|
label: "待审核",
|
||||||
@@ -70,6 +72,9 @@ test("proxies recent orders overview from the backend list api", async () => {
|
|||||||
},
|
},
|
||||||
currentStep: "review",
|
currentStep: "review",
|
||||||
currentStepLabel: "人工审核",
|
currentStepLabel: "人工审核",
|
||||||
|
reviewTaskStatus: "revision_uploaded",
|
||||||
|
revisionCount: 1,
|
||||||
|
pendingManualConfirm: true,
|
||||||
updatedAt: "2026-03-27T14:00:03Z",
|
updatedAt: "2026-03-27T14:00:03Z",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ test("proxies workflow lookup items from the backend workflows list api", async
|
|||||||
},
|
},
|
||||||
currentStep: "review",
|
currentStep: "review",
|
||||||
currentStepLabel: "人工审核",
|
currentStepLabel: "人工审核",
|
||||||
|
failureCount: 0,
|
||||||
|
reviewTaskStatus: "revision_uploaded",
|
||||||
|
revisionCount: 1,
|
||||||
|
pendingManualConfirm: true,
|
||||||
updatedAt: "2026-03-27T14:00:03Z",
|
updatedAt: "2026-03-27T14:00:03Z",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
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 { expect, test } from "vitest";
|
||||||
|
|
||||||
import { LibraryPage } from "@/features/libraries/library-page";
|
import { LibraryPage } from "@/features/libraries/library-page";
|
||||||
@@ -6,19 +6,112 @@ import type { LibraryItemVM } from "@/lib/types/view-models";
|
|||||||
|
|
||||||
const MODEL_ITEMS: LibraryItemVM[] = [
|
const MODEL_ITEMS: LibraryItemVM[] = [
|
||||||
{
|
{
|
||||||
id: "model-ava",
|
id: "12",
|
||||||
libraryType: "models",
|
libraryType: "models",
|
||||||
name: "Ava / Studio",
|
name: "Ava / Studio",
|
||||||
description: "中性棚拍模特占位数据,用于提交页联调。",
|
description: "中性棚拍模特占位数据,用于提交页联调。",
|
||||||
previewUri: "mock://libraries/models/ava",
|
previewUri: "mock://libraries/models/ava",
|
||||||
tags: ["女装", "半身", "mock"],
|
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} />);
|
render(<LibraryPage libraryType="models" items={MODEL_ITEMS} />);
|
||||||
|
|
||||||
expect(screen.getByText("当前资源库仍使用 mock 数据")).toBeInTheDocument();
|
expect(screen.getByText("资源库当前显示真实后端数据。")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Ava / Studio")).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);
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { test, expect } from "vitest";
|
import { test, expect } from "vitest";
|
||||||
|
|
||||||
import { OrderDetail } from "@/features/orders/order-detail";
|
import { OrderDetail } from "@/features/orders/order-detail";
|
||||||
@@ -97,3 +97,140 @@ test("renders the business-empty final-result state when no final asset exists",
|
|||||||
expect(screen.getByText("最终图暂未生成")).toBeInTheDocument();
|
expect(screen.getByText("最终图暂未生成")).toBeInTheDocument();
|
||||||
expect(screen.getByText("当前订单还没有可展示的最终结果。")).toBeInTheDocument();
|
expect(screen.getByText("当前订单还没有可展示的最终结果。")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders prepared-model input snapshots when asset metadata contains resource inputs", () => {
|
||||||
|
render(
|
||||||
|
<OrderDetail
|
||||||
|
viewModel={{
|
||||||
|
...BASE_ORDER_DETAIL,
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
id: 66,
|
||||||
|
orderId: 101,
|
||||||
|
type: "prepared_model",
|
||||||
|
stepName: "prepare_model",
|
||||||
|
parentAssetId: null,
|
||||||
|
rootAssetId: null,
|
||||||
|
versionNo: 0,
|
||||||
|
isCurrentVersion: false,
|
||||||
|
stepLabel: "模型准备",
|
||||||
|
label: "模型准备产物",
|
||||||
|
uri: "https://images.example.com/prepared-model.jpg",
|
||||||
|
metadata: {
|
||||||
|
model_input: {
|
||||||
|
resource_id: 3,
|
||||||
|
resource_name: "主模特",
|
||||||
|
original_url: "https://images.example.com/model.jpg",
|
||||||
|
},
|
||||||
|
garment_input: {
|
||||||
|
resource_id: 4,
|
||||||
|
resource_name: "上衣",
|
||||||
|
original_url: "https://images.example.com/garment.jpg",
|
||||||
|
},
|
||||||
|
scene_input: {
|
||||||
|
resource_id: 5,
|
||||||
|
resource_name: "白棚",
|
||||||
|
original_url: "https://images.example.com/scene.jpg",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: "2026-03-27T00:08:00Z",
|
||||||
|
isMock: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("输入素材")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("模特图")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("服装图")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("场景图")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("主模特")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("上衣")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("白棚")).toBeInTheDocument();
|
||||||
|
expect(screen.getByAltText("模型准备产物预览")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders image previews for non-mock final and process assets", () => {
|
||||||
|
render(
|
||||||
|
<OrderDetail
|
||||||
|
viewModel={{
|
||||||
|
...BASE_ORDER_DETAIL,
|
||||||
|
hasMockAssets: false,
|
||||||
|
finalAsset: {
|
||||||
|
...BASE_ORDER_DETAIL.finalAsset!,
|
||||||
|
uri: "https://images.example.com/final.jpg",
|
||||||
|
isMock: false,
|
||||||
|
},
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
id: 78,
|
||||||
|
orderId: 101,
|
||||||
|
type: "tryon",
|
||||||
|
stepName: "tryon",
|
||||||
|
parentAssetId: null,
|
||||||
|
rootAssetId: null,
|
||||||
|
versionNo: 0,
|
||||||
|
isCurrentVersion: false,
|
||||||
|
stepLabel: "试穿生成",
|
||||||
|
label: "试穿生成产物",
|
||||||
|
uri: "https://images.example.com/tryon.jpg",
|
||||||
|
metadata: null,
|
||||||
|
createdAt: "2026-03-27T00:09:00Z",
|
||||||
|
isMock: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByAltText("最终图预览")).toBeInTheDocument();
|
||||||
|
expect(screen.getByAltText("试穿生成产物预览")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opens an asset preview dialog for real images and resets zoom state", () => {
|
||||||
|
render(
|
||||||
|
<OrderDetail
|
||||||
|
viewModel={{
|
||||||
|
...BASE_ORDER_DETAIL,
|
||||||
|
hasMockAssets: false,
|
||||||
|
finalAsset: {
|
||||||
|
...BASE_ORDER_DETAIL.finalAsset!,
|
||||||
|
uri: "https://images.example.com/final.jpg",
|
||||||
|
isMock: false,
|
||||||
|
},
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
id: 78,
|
||||||
|
orderId: 101,
|
||||||
|
type: "scene",
|
||||||
|
stepName: "scene",
|
||||||
|
parentAssetId: null,
|
||||||
|
rootAssetId: null,
|
||||||
|
versionNo: 0,
|
||||||
|
isCurrentVersion: false,
|
||||||
|
stepLabel: "场景处理",
|
||||||
|
label: "场景处理产物",
|
||||||
|
uri: "https://images.example.com/scene.jpg",
|
||||||
|
metadata: null,
|
||||||
|
createdAt: "2026-03-27T00:09:00Z",
|
||||||
|
isMock: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "查看场景处理产物大图" }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog", { name: "场景处理产物预览" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
const zoomableImage = screen.getByAltText("场景处理产物大图");
|
||||||
|
fireEvent.wheel(zoomableImage, { deltaY: -120 });
|
||||||
|
|
||||||
|
expect(zoomableImage).toHaveStyle({ transform: "translate(0px, 0px) scale(1.12)" });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "重置预览" }));
|
||||||
|
|
||||||
|
expect(zoomableImage).toHaveStyle({ transform: "translate(0px, 0px) scale(1)" });
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const RECENT_ORDERS: OrderSummaryVM[] = [
|
|||||||
{
|
{
|
||||||
orderId: 4201,
|
orderId: 4201,
|
||||||
workflowId: "wf-4201",
|
workflowId: "wf-4201",
|
||||||
|
customerLevel: "mid",
|
||||||
|
serviceMode: "semi_pro",
|
||||||
status: "waiting_review",
|
status: "waiting_review",
|
||||||
statusMeta: {
|
statusMeta: {
|
||||||
label: "待审核",
|
label: "待审核",
|
||||||
@@ -15,15 +17,20 @@ const RECENT_ORDERS: OrderSummaryVM[] = [
|
|||||||
},
|
},
|
||||||
currentStep: "review",
|
currentStep: "review",
|
||||||
currentStepLabel: "人工审核",
|
currentStepLabel: "人工审核",
|
||||||
|
reviewTaskStatus: "revision_uploaded",
|
||||||
|
revisionCount: 1,
|
||||||
|
pendingManualConfirm: true,
|
||||||
updatedAt: "2026-03-27T09:15:00Z",
|
updatedAt: "2026-03-27T09:15:00Z",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
test("shows the real recent-orders entry state", () => {
|
test("renders orders as a high-density table with shared toolbar controls", () => {
|
||||||
render(<OrdersHome recentOrders={RECENT_ORDERS} />);
|
render(<OrdersHome recentOrders={RECENT_ORDERS} />);
|
||||||
|
|
||||||
expect(screen.getByText("最近订单已接入真实后端接口")).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "订单号" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("订单 #4201")).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "服务模式" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("订单状态筛选")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("#4201")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("supports status filtering and pagination actions", () => {
|
test("supports status filtering and pagination actions", () => {
|
||||||
|
|||||||
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,24 @@ function createFetchMock({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function chooseSelectOption(label: string, optionName: string) {
|
||||||
|
fireEvent.click(screen.getByRole("combobox", { name: label }));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: optionName }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseLibraryResource(label: string, resourceName: string) {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: `选择${label}` }));
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: `选择${label}` });
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: resourceName }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("dialog", { name: `选择${label}` }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
pushMock.mockReset();
|
pushMock.mockReset();
|
||||||
});
|
});
|
||||||
@@ -92,26 +110,30 @@ test("forces low customers to use auto_basic and mid customers to use semi_pro",
|
|||||||
|
|
||||||
render(<SubmitWorkbench />);
|
render(<SubmitWorkbench />);
|
||||||
|
|
||||||
const customerLevelSelect = await screen.findByLabelText("客户层级");
|
await screen.findByRole("button", { name: "选择模特资源" });
|
||||||
const serviceModeSelect = screen.getByLabelText("服务模式");
|
|
||||||
|
|
||||||
fireEvent.change(customerLevelSelect, {
|
await chooseSelectOption("客户层级", "低客单 low");
|
||||||
target: { value: "low" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(serviceModeSelect).toHaveValue("auto_basic");
|
expect(screen.getByRole("combobox", { name: "服务模式" })).toHaveTextContent(
|
||||||
|
"自动基础处理 auto_basic",
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("combobox", { name: "服务模式" }));
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("option", { name: "半人工专业处理 semi_pro" }),
|
screen.getByRole("option", { name: "半人工专业处理 semi_pro" }),
|
||||||
).toBeDisabled();
|
).toHaveAttribute("data-disabled");
|
||||||
|
fireEvent.keyDown(document.activeElement ?? document.body, {
|
||||||
fireEvent.change(customerLevelSelect, {
|
key: "Escape",
|
||||||
target: { value: "mid" },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(serviceModeSelect).toHaveValue("semi_pro");
|
await chooseSelectOption("客户层级", "中客单 mid");
|
||||||
|
|
||||||
|
expect(screen.getByRole("combobox", { name: "服务模式" })).toHaveTextContent(
|
||||||
|
"半人工专业处理 semi_pro",
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("combobox", { name: "服务模式" }));
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("option", { name: "自动基础处理 auto_basic" }),
|
screen.getByRole("option", { name: "自动基础处理 auto_basic" }),
|
||||||
).toBeDisabled();
|
).toHaveAttribute("data-disabled");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preserves selected values when order submission fails", async () => {
|
test("preserves selected values when order submission fails", async () => {
|
||||||
@@ -133,20 +155,12 @@ test("preserves selected values when order submission fails", async () => {
|
|||||||
|
|
||||||
render(<SubmitWorkbench />);
|
render(<SubmitWorkbench />);
|
||||||
|
|
||||||
await screen.findByText("Ava / Studio");
|
await screen.findByRole("button", { name: "选择模特资源" });
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("客户层级"), {
|
await chooseSelectOption("客户层级", "低客单 low");
|
||||||
target: { value: "low" },
|
await chooseLibraryResource("模特资源", "Ava / Studio");
|
||||||
});
|
await chooseLibraryResource("场景资源", "Loft Window");
|
||||||
fireEvent.change(screen.getByLabelText("模特资源"), {
|
await chooseLibraryResource("服装资源", "Structured Coat 01");
|
||||||
target: { value: "model-ava" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText("场景资源"), {
|
|
||||||
target: { value: "scene-loft" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText("服装资源"), {
|
|
||||||
target: { value: "garment-coat-01" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const summaryCard = screen.getByRole("region", { name: "提单摘要" });
|
const summaryCard = screen.getByRole("region", { name: "提单摘要" });
|
||||||
expect(within(summaryCard).getByText("Ava / Studio")).toBeInTheDocument();
|
expect(within(summaryCard).getByText("Ava / Studio")).toBeInTheDocument();
|
||||||
@@ -158,11 +172,15 @@ test("preserves selected values when order submission fails", async () => {
|
|||||||
expect(
|
expect(
|
||||||
await screen.findByText("后端暂时不可用,请稍后重试。"),
|
await screen.findByText("后端暂时不可用,请稍后重试。"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("客户层级")).toHaveValue("low");
|
expect(screen.getByRole("combobox", { name: "客户层级" })).toHaveTextContent(
|
||||||
expect(screen.getByLabelText("服务模式")).toHaveValue("auto_basic");
|
"低客单 low",
|
||||||
expect(screen.getByLabelText("模特资源")).toHaveValue("model-ava");
|
);
|
||||||
expect(screen.getByLabelText("场景资源")).toHaveValue("scene-loft");
|
expect(screen.getByRole("combobox", { name: "服务模式" })).toHaveTextContent(
|
||||||
expect(screen.getByLabelText("服装资源")).toHaveValue("garment-coat-01");
|
"自动基础处理 auto_basic",
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText("Ava / Studio").length).toBeGreaterThan(1);
|
||||||
|
expect(screen.getAllByText("Loft Window").length).toBeGreaterThan(1);
|
||||||
|
expect(screen.getAllByText("Structured Coat 01").length).toBeGreaterThan(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("submits the selected resources, surfaces returned ids, and redirects to the order detail page", async () => {
|
test("submits the selected resources, surfaces returned ids, and redirects to the order detail page", async () => {
|
||||||
@@ -187,20 +205,12 @@ test("submits the selected resources, surfaces returned ids, and redirects to th
|
|||||||
|
|
||||||
render(<SubmitWorkbench />);
|
render(<SubmitWorkbench />);
|
||||||
|
|
||||||
await screen.findByText("Ava / Studio");
|
await screen.findByRole("button", { name: "选择模特资源" });
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("客户层级"), {
|
await chooseSelectOption("客户层级", "低客单 low");
|
||||||
target: { value: "low" },
|
await chooseLibraryResource("模特资源", "Ava / Studio");
|
||||||
});
|
await chooseLibraryResource("场景资源", "Loft Window");
|
||||||
fireEvent.change(screen.getByLabelText("模特资源"), {
|
await chooseLibraryResource("服装资源", "Structured Coat 01");
|
||||||
target: { value: "model-ava" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText("场景资源"), {
|
|
||||||
target: { value: "scene-loft" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText("服装资源"), {
|
|
||||||
target: { value: "garment-coat-01" },
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "提交订单" }));
|
fireEvent.click(screen.getByRole("button", { name: "提交订单" }));
|
||||||
|
|
||||||
@@ -213,7 +223,6 @@ test("submits the selected resources, surfaces returned ids, and redirects to th
|
|||||||
customer_level: "low",
|
customer_level: "low",
|
||||||
service_mode: "auto_basic",
|
service_mode: "auto_basic",
|
||||||
model_id: 101,
|
model_id: 101,
|
||||||
pose_id: 202,
|
|
||||||
garment_asset_id: 303,
|
garment_asset_id: 303,
|
||||||
scene_ref_asset_id: 404,
|
scene_ref_asset_id: 404,
|
||||||
}),
|
}),
|
||||||
@@ -230,3 +239,78 @@ test("submits the selected resources, surfaces returned ids, and redirects to th
|
|||||||
expect(pushMock).toHaveBeenCalledWith("/orders/77");
|
expect(pushMock).toHaveBeenCalledWith("/orders/77");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("allows submitting without selecting a scene resource", async () => {
|
||||||
|
const fetchMock = createFetchMock({
|
||||||
|
orderResponse: new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
mode: "proxy",
|
||||||
|
data: {
|
||||||
|
orderId: 88,
|
||||||
|
workflowId: "wf-88",
|
||||||
|
status: "created",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
render(<SubmitWorkbench />);
|
||||||
|
|
||||||
|
await screen.findByRole("button", { name: "选择模特资源" });
|
||||||
|
|
||||||
|
await chooseSelectOption("客户层级", "低客单 low");
|
||||||
|
await chooseLibraryResource("模特资源", "Ava / Studio");
|
||||||
|
await chooseLibraryResource("服装资源", "Structured Coat 01");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "提交订单" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/orders",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
customer_level: "low",
|
||||||
|
service_mode: "auto_basic",
|
||||||
|
model_id: 101,
|
||||||
|
garment_asset_id: 303,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opens a shared resource manager modal and picks a resource card for each library type", async () => {
|
||||||
|
vi.stubGlobal("fetch", createFetchMock());
|
||||||
|
|
||||||
|
render(<SubmitWorkbench />);
|
||||||
|
|
||||||
|
await screen.findByRole("button", { name: "选择模特资源" });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "选择模特资源" }));
|
||||||
|
|
||||||
|
const modelDialog = await screen.findByRole("dialog", { name: "选择模特资源" });
|
||||||
|
expect(within(modelDialog).getByTestId("resource-picker-masonry").className).toContain(
|
||||||
|
"columns-1",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
within(modelDialog).getByRole("button", { name: "Ava / Studio" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(within(modelDialog).getByRole("button", { name: "Ava / Studio" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("dialog", { name: "选择模特资源" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryCard = screen.getByRole("region", { name: "提单摘要" });
|
||||||
|
expect(within(summaryCard).getByText("Ava / Studio")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|||||||
@@ -222,6 +222,17 @@ afterEach(() => {
|
|||||||
pushMock.mockReset();
|
pushMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders a sticky summary with grouped decision panels", async () => {
|
||||||
|
const fetchMock = createFetchMock();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
render(<ReviewWorkbenchDetailScreen orderId={101} />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("link", { name: "返回审核列表" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("审核动作")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("人工修订")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test("requires a comment before rerun_face submission in detail view", async () => {
|
test("requires a comment before rerun_face submission in detail view", async () => {
|
||||||
const fetchMock = createFetchMock();
|
const fetchMock = createFetchMock();
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders medium-density list rows that link into independent review detail pages", async () => {
|
test("renders a compact review queue table with triage columns", async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
new Response(JSON.stringify(createPendingPayload()), {
|
new Response(JSON.stringify(createPendingPayload()), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -50,13 +50,14 @@ test("renders medium-density list rows that link into independent review detail
|
|||||||
|
|
||||||
render(<ReviewWorkbenchListScreen />);
|
render(<ReviewWorkbenchListScreen />);
|
||||||
|
|
||||||
expect(await screen.findByText("审核目标 #101")).toBeInTheDocument();
|
expect(await screen.findByRole("columnheader", { name: "订单号" })).toBeInTheDocument();
|
||||||
expect(screen.getByText(/工作流 wf-101/)).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "修订状态" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("Mock 资产")).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "刷新队列" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("失败 2")).toBeInTheDocument();
|
expect(screen.getByRole("link", { name: "进入详情" })).toHaveAttribute(
|
||||||
expect(screen.queryByText("审核动作面板")).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("link", { name: /审核目标 #101/ })).toHaveAttribute(
|
|
||||||
"href",
|
"href",
|
||||||
"/reviews/workbench/101",
|
"/reviews/workbench/101",
|
||||||
);
|
);
|
||||||
|
expect(screen.getByText("Mock 资产")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("失败 2")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("审核动作面板")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const BASE_WORKFLOW_DETAIL: WorkflowDetailVM = {
|
|||||||
endedAt: "2026-03-27T00:07:00Z",
|
endedAt: "2026-03-27T00:07:00Z",
|
||||||
containsMockAssets: false,
|
containsMockAssets: false,
|
||||||
mockAssetUris: [],
|
mockAssetUris: [],
|
||||||
|
previewUri: null,
|
||||||
isCurrent: false,
|
isCurrent: false,
|
||||||
isFailed: true,
|
isFailed: true,
|
||||||
},
|
},
|
||||||
@@ -64,6 +65,7 @@ const BASE_WORKFLOW_DETAIL: WorkflowDetailVM = {
|
|||||||
endedAt: null,
|
endedAt: null,
|
||||||
containsMockAssets: true,
|
containsMockAssets: true,
|
||||||
mockAssetUris: ["mock://fusion-preview"],
|
mockAssetUris: ["mock://fusion-preview"],
|
||||||
|
previewUri: "mock://fusion-preview",
|
||||||
isCurrent: true,
|
isCurrent: true,
|
||||||
isFailed: false,
|
isFailed: false,
|
||||||
},
|
},
|
||||||
@@ -82,3 +84,41 @@ test("highlights failed steps and mock asset hints in the workflow timeline", ()
|
|||||||
expect(screen.getByText("Temporal activity timed out.")).toBeInTheDocument();
|
expect(screen.getByText("Temporal activity timed out.")).toBeInTheDocument();
|
||||||
expect(screen.getByText("当前流程包含 mock 资产")).toBeInTheDocument();
|
expect(screen.getByText("当前流程包含 mock 资产")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders image previews for real workflow step outputs", () => {
|
||||||
|
render(
|
||||||
|
<WorkflowDetail
|
||||||
|
viewModel={{
|
||||||
|
...BASE_WORKFLOW_DETAIL,
|
||||||
|
hasMockAssets: false,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 21,
|
||||||
|
workflowRunId: 9001,
|
||||||
|
name: "scene",
|
||||||
|
label: "场景处理",
|
||||||
|
status: "succeeded",
|
||||||
|
statusMeta: {
|
||||||
|
label: "已完成",
|
||||||
|
tone: "success",
|
||||||
|
},
|
||||||
|
input: null,
|
||||||
|
output: {
|
||||||
|
uri: "https://images.example.com/orders/101/scene/generated.jpg",
|
||||||
|
},
|
||||||
|
errorMessage: null,
|
||||||
|
startedAt: "2026-03-27T00:06:00Z",
|
||||||
|
endedAt: "2026-03-27T00:07:00Z",
|
||||||
|
containsMockAssets: false,
|
||||||
|
mockAssetUris: [],
|
||||||
|
previewUri: "https://images.example.com/orders/101/scene/generated.jpg",
|
||||||
|
isCurrent: false,
|
||||||
|
isFailed: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByAltText("场景处理预览")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,15 +16,21 @@ const WORKFLOW_ITEMS: WorkflowLookupItemVM[] = [
|
|||||||
},
|
},
|
||||||
currentStep: "review",
|
currentStep: "review",
|
||||||
currentStepLabel: "人工审核",
|
currentStepLabel: "人工审核",
|
||||||
|
failureCount: 2,
|
||||||
|
reviewTaskStatus: "revision_uploaded",
|
||||||
|
revisionCount: 1,
|
||||||
|
pendingManualConfirm: true,
|
||||||
updatedAt: "2026-03-27T09:15:00Z",
|
updatedAt: "2026-03-27T09:15:00Z",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
test("shows the real recent-workflows entry state", () => {
|
test("renders workflows as a high-density table with shared toolbar controls", () => {
|
||||||
render(<WorkflowLookup items={WORKFLOW_ITEMS} />);
|
render(<WorkflowLookup items={WORKFLOW_ITEMS} />);
|
||||||
|
|
||||||
expect(screen.getByText("流程追踪首页当前显示真实后端最近流程。")).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "流程类型" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("订单 #4201")).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "失败次数" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("流程状态筛选")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("#4201")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("supports workflow status filtering and pagination actions", () => {
|
test("supports workflow status filtering and pagination actions", () => {
|
||||||
|
|||||||
@@ -68,9 +68,13 @@ test("maps order summary status metadata and current step labels", () => {
|
|||||||
expect(viewModel).toMatchObject({
|
expect(viewModel).toMatchObject({
|
||||||
orderId: 101,
|
orderId: 101,
|
||||||
workflowId: "wf-101",
|
workflowId: "wf-101",
|
||||||
|
customerLevel: "mid",
|
||||||
|
serviceMode: "semi_pro",
|
||||||
status: "waiting_review",
|
status: "waiting_review",
|
||||||
currentStep: "review",
|
currentStep: "review",
|
||||||
currentStepLabel: "人工审核",
|
currentStepLabel: "人工审核",
|
||||||
|
revisionCount: 0,
|
||||||
|
pendingManualConfirm: false,
|
||||||
statusMeta: {
|
statusMeta: {
|
||||||
label: "待审核",
|
label: "待审核",
|
||||||
tone: "warning",
|
tone: "warning",
|
||||||
|
|||||||
@@ -69,6 +69,33 @@ test("tags nested mock asset uris found in workflow step payloads", () => {
|
|||||||
expect(viewModel.failureCount).toBe(1);
|
expect(viewModel.failureCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("extracts a step preview uri from standard output uri fields", () => {
|
||||||
|
const viewModel = adaptWorkflowDetail({
|
||||||
|
...WORKFLOW_BASE,
|
||||||
|
current_step: "scene",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
workflow_run_id: 9001,
|
||||||
|
step_name: "scene",
|
||||||
|
step_status: "succeeded",
|
||||||
|
input_json: null,
|
||||||
|
output_json: {
|
||||||
|
uri: "https://images.example.com/orders/101/scene/generated.jpg",
|
||||||
|
},
|
||||||
|
error_message: null,
|
||||||
|
started_at: "2026-03-27T00:08:00Z",
|
||||||
|
ended_at: "2026-03-27T00:09:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(viewModel.steps[0].previewUri).toBe(
|
||||||
|
"https://images.example.com/orders/101/scene/generated.jpg",
|
||||||
|
);
|
||||||
|
expect(viewModel.steps[0].containsMockAssets).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("maps workflow lookup status and current step labels", () => {
|
test("maps workflow lookup status and current step labels", () => {
|
||||||
const viewModel = adaptWorkflowLookupItem(WORKFLOW_BASE);
|
const viewModel = adaptWorkflowLookupItem(WORKFLOW_BASE);
|
||||||
|
|
||||||
@@ -79,6 +106,9 @@ test("maps workflow lookup status and current step labels", () => {
|
|||||||
status: "running",
|
status: "running",
|
||||||
currentStep: "fusion",
|
currentStep: "fusion",
|
||||||
currentStepLabel: "融合",
|
currentStepLabel: "融合",
|
||||||
|
failureCount: 0,
|
||||||
|
revisionCount: 0,
|
||||||
|
pendingManualConfirm: false,
|
||||||
statusMeta: {
|
statusMeta: {
|
||||||
label: "处理中",
|
label: "处理中",
|
||||||
tone: "info",
|
tone: "info",
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ test("locks the rail to the viewport and makes the content pane independently sc
|
|||||||
expect(main).toHaveClass("md:h-full", "md:overflow-y-auto");
|
expect(main).toHaveClass("md:h-full", "md:overflow-y-auto");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses a narrow desktop rail and removes the max-width shell cap", () => {
|
||||||
|
const { container } = render(<DashboardShell>dashboard body</DashboardShell>);
|
||||||
|
const shellFrame = container.firstElementChild;
|
||||||
|
const rail = screen.getByRole("complementary", { name: "Dashboard rail" });
|
||||||
|
|
||||||
|
expect(shellFrame).not.toHaveClass("max-w-7xl");
|
||||||
|
expect(rail.className).toContain("md:w-[228px]");
|
||||||
|
});
|
||||||
|
|
||||||
test("redirects the root page to orders", () => {
|
test("redirects the root page to orders", () => {
|
||||||
HomePage();
|
HomePage();
|
||||||
|
|
||||||
|
|||||||
21
tests/ui/page-toolbar.test.tsx
Normal file
21
tests/ui/page-toolbar.test.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { PageToolbar } from "@/components/ui/page-toolbar";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select } from "@/components/ui/select";
|
||||||
|
|
||||||
|
test("renders a dense toolbar row with compact controls", () => {
|
||||||
|
render(
|
||||||
|
<PageToolbar>
|
||||||
|
<Input aria-label="search" />
|
||||||
|
<Select
|
||||||
|
aria-label="status"
|
||||||
|
options={[{ value: "all", label: "全部状态" }]}
|
||||||
|
value="all"
|
||||||
|
/>
|
||||||
|
</PageToolbar>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("search").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");
|
||||||
|
});
|
||||||
@@ -21,6 +21,13 @@ test("uses order status metadata for the rendered tone", () => {
|
|||||||
expect(screen.getByText("失败")).toHaveAttribute("data-tone", "danger");
|
expect(screen.getByText("失败")).toHaveAttribute("data-tone", "danger");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses compact dense-console badge sizing", () => {
|
||||||
|
render(<StatusBadge status="waiting_review" />);
|
||||||
|
|
||||||
|
expect(screen.getByText("待审核").className).toContain("px-2");
|
||||||
|
expect(screen.getByText("待审核").className).toContain("py-0.5");
|
||||||
|
});
|
||||||
|
|
||||||
test("can render review decision metadata when a variant is provided", () => {
|
test("can render review decision metadata when a variant is provided", () => {
|
||||||
render(<StatusBadge status="reject" variant="reviewDecision" />);
|
render(<StatusBadge status="reject" variant="reviewDecision" />);
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,17 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
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