Compare commits

...

10 Commits

Author SHA1 Message Date
afei A
d09491cd8a feat: enhance order asset selection and previews 2026-03-29 00:24:29 +08:00
afei A
162d3e12d2 feat: connect resource library workflows 2026-03-28 13:42:22 +08:00
afei A
c604e6ace1 refactor: align detail views with dense console ui 2026-03-28 00:31:24 +08:00
afei A
59d3f4d054 feat: rewrite workflows page as dense list 2026-03-28 00:28:51 +08:00
afei A
ae8ab2cf9c feat: rewrite orders page as dense list 2026-03-28 00:25:45 +08:00
afei A
edd03b03a7 feat: rebuild review detail decision surface 2026-03-28 00:22:08 +08:00
afei A
f2deb54f3a feat: rewrite review queue as dense table 2026-03-28 00:18:50 +08:00
afei A
025ae31f9f feat: add dense console ui primitives 2026-03-28 00:16:01 +08:00
afei A
4ca3ef96b9 feat: tighten dashboard shell density 2026-03-28 00:14:00 +08:00
afei A
ded6555dbc docs: add shadcn ui rewrite implementation plan 2026-03-28 00:07:26 +08:00
84 changed files with 7421 additions and 954 deletions

View File

@@ -2,6 +2,12 @@
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
- Node.js 20+
@@ -82,8 +88,6 @@ Real integration pages:
Placeholder or transitional pages:
- `/orders`
- `/workflows`
- `/libraries/models`
- `/libraries/scenes`
- `/libraries/garments`

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
@import "tailwindcss";
:root {
--app-bg: #f6f1e8;
--bg-canvas: #f6f1e8;
--bg-canvas-strong: #efe5d7;
--bg-elevated: rgba(255, 250, 243, 0.86);
@@ -23,6 +24,9 @@
--border-strong: rgba(82, 71, 57, 0.24);
--shadow-shell: 0 28px 80px rgba(47, 38, 28, 0.12);
--shadow-card: 0 18px 40px rgba(62, 46, 27, 0.08);
--page-gap: 16px;
--panel-radius: 14px;
--control-height: 38px;
--font-sans:
"Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;

View File

@@ -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"
```

View File

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

View File

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

2
next-env.d.ts vendored
View File

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

1603
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -9,38 +9,38 @@ type DashboardShellProps = {
export function DashboardShell({ children }: DashboardShellProps) {
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="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="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="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
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>
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.3em] text-white/48">
Auto Tryon Ops
</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>
<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>
</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) => (
<Link
key={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>
</Link>
))}
</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">
Shared shell
</p>
@@ -52,9 +52,9 @@ export function DashboardShell({ children }: DashboardShellProps) {
</aside>
<main
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>
</div>
</div>

View File

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

View File

@@ -26,9 +26,9 @@ const VARIANT_STYLES: Record<ButtonVariant, string> = {
};
const SIZE_STYLES: Record<ButtonSize, string> = {
sm: "min-h-9 rounded-full px-3.5 text-sm",
md: "min-h-11 rounded-full px-4 text-sm",
lg: "min-h-12 rounded-full px-5 text-base",
sm: "h-8 rounded-md px-2.5 text-xs",
md: "h-9 rounded-md px-3 text-sm",
lg: "h-10 rounded-md px-4 text-sm",
};
function joinClasses(...values: Array<string | false | null | undefined>) {

View File

@@ -19,7 +19,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(
<div
ref={ref}
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,
)}
{...props}
@@ -36,7 +36,7 @@ export const CardHeader = forwardRef<HTMLDivElement, CardSectionProps>(
<div
ref={ref}
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,
)}
{...props}
@@ -53,7 +53,7 @@ export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
<h3
ref={ref}
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,
)}
{...props}
@@ -71,7 +71,7 @@ export const CardDescription = forwardRef<
return (
<p
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}
/>
);
@@ -99,7 +99,7 @@ export const CardFooter = forwardRef<HTMLDivElement, CardSectionProps>(
<div
ref={ref}
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,
)}
{...props}

View 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,
};

View 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";

View 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>
);
}

View File

@@ -16,24 +16,24 @@ export function PageHeader({
title,
}: PageHeaderProps) {
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="space-y-3">
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.26em] text-[var(--ink-faint)]">
<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-2">
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-[var(--ink-faint)]">
{eyebrow}
</p>
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]">
{title}
</h1>
{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}
</div>
) : null}
</div>
</div>
{actions || meta ? (
<div className="flex flex-col gap-3 md:items-end">
<div className="flex flex-col gap-2 md:items-end">
{meta ? (
<div className="font-[var(--font-mono)] text-xs uppercase tracking-[0.18em] text-[var(--ink-faint)]">
{meta}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View File

@@ -104,7 +104,7 @@ export function StatusBadge({ className, ...props }: StatusBadgeProps) {
<span
data-tone={meta.tone}
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],
className,
)}

View 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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,10 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Select } from "@/components/ui/select";
import { OrderSummaryCard } from "@/features/orders/components/order-summary-card";
import { ResourcePickerCard } from "@/features/orders/components/resource-picker-card";
import { ResourcePickerModal } from "@/features/orders/components/resource-picker-modal";
import {
SERVICE_MODE_LABELS,
type ModelPickerOption,
@@ -18,6 +20,8 @@ import type {
CustomerLevel,
ServiceMode,
} from "@/lib/types/backend";
import type { LibraryType } from "@/lib/types/view-models";
import { useState } from "react";
type SubmissionSuccess = {
orderId: number;
@@ -50,10 +54,6 @@ type CreateOrderFormProps = {
onSubmit: () => void;
};
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
export function CreateOrderForm({
allowedServiceMode,
garments,
@@ -71,12 +71,37 @@ export function CreateOrderForm({
onServiceModeChange,
onSubmit,
}: CreateOrderFormProps) {
const [activePicker, setActivePicker] = useState<LibraryType | null>(null);
const selectedModel = models.find((item) => item.id === value.modelId) ?? null;
const selectedScene = scenes.find((item) => item.id === value.sceneId) ?? null;
const selectedGarment =
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 (
<>
<form
className="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.85fr)]"
onSubmit={(event) => {
@@ -96,84 +121,75 @@ export function CreateOrderForm({
<CardContent className="grid gap-4 md:grid-cols-2">
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
<span className="font-medium"></span>
<select
<Select
aria-label="客户层级"
className={joinClasses(
"min-h-12 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)]",
"focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]",
)}
className="min-h-12 rounded-[18px] border-[var(--border-strong)] bg-[var(--surface-muted)] px-4"
disabled={isSubmitting}
options={[
{ value: "low", label: "低客单 low" },
{ value: "mid", label: "中客单 mid" },
]}
value={value.customerLevel}
onChange={(event) =>
onCustomerLevelChange(event.target.value as CustomerLevel)
onValueChange={(nextValue) =>
onCustomerLevelChange(nextValue as CustomerLevel)
}
>
<option value="low"> low</option>
<option value="mid"> mid</option>
</select>
/>
</label>
<label className="flex flex-col gap-2 text-sm text-[var(--ink-strong)]">
<span className="font-medium"></span>
<select
<Select
aria-label="服务模式"
className={joinClasses(
"min-h-12 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)]",
"focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]",
)}
className="min-h-12 rounded-[18px] border-[var(--border-strong)] bg-[var(--surface-muted)] px-4"
disabled={isSubmitting}
options={[
{
value: "auto_basic",
label: `${SERVICE_MODE_LABELS.auto_basic} auto_basic`,
disabled: allowedServiceMode !== "auto_basic",
},
{
value: "semi_pro",
label: `${SERVICE_MODE_LABELS.semi_pro} semi_pro`,
disabled: allowedServiceMode !== "semi_pro",
},
]}
value={value.serviceMode}
onChange={(event) =>
onServiceModeChange(event.target.value as ServiceMode)
onValueChange={(nextValue) =>
onServiceModeChange(nextValue as ServiceMode)
}
>
<option
disabled={allowedServiceMode !== "auto_basic"}
value="auto_basic"
>
{SERVICE_MODE_LABELS.auto_basic} auto_basic
</option>
<option
disabled={allowedServiceMode !== "semi_pro"}
value="semi_pro"
>
{SERVICE_MODE_LABELS.semi_pro} semi_pro
</option>
</select>
/>
</label>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-3">
<ResourcePickerCard
description="使用资源库占位数据挑选模特,提交时会映射到真实后端 ID 与 pose_id。"
description="使用资源库素材挑选模特,提交时会映射到真实后端资源 ID。"
disabled={isSubmitting}
isLoading={isLoadingResources}
items={models}
label="模特资源"
selectedItem={selectedModel}
title="模特"
value={value.modelId}
onChange={onModelChange}
onOpenPicker={() => setActivePicker("models")}
/>
<ResourcePickerCard
description="场景仍来自 mock 数据,但通过 BFF 路由加载,保持页面集成方式真实。"
disabled={isSubmitting}
isLoading={isLoadingResources}
items={scenes}
label="场景资源"
selectedItem={selectedScene}
title="场景"
value={value.sceneId}
onChange={onSceneChange}
onOpenPicker={() => setActivePicker("scenes")}
/>
<ResourcePickerCard
description="服装选择沿用当前资源库占位素材,为订单创建页提供稳定联调入口。"
disabled={isSubmitting}
isLoading={isLoadingResources}
items={garments}
label="服装资源"
selectedItem={selectedGarment}
title="服装"
value={value.garmentId}
onChange={onGarmentChange}
onOpenPicker={() => setActivePicker("garments")}
/>
</div>
@@ -197,5 +213,19 @@ export function CreateOrderForm({
submissionSuccess={submissionSuccess}
/>
</form>
{activePicker && activePickerConfig ? (
<ResourcePickerModal
isLoading={isLoadingResources}
items={activePickerConfig.items}
label={activePickerConfig.label}
libraryType={activePicker}
open
selectedId={activePickerConfig.selectedId}
onClose={() => setActivePicker(null)}
onSelect={activePickerConfig.onSelect}
/>
) : null}
</>
);
}

View File

@@ -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 { Dialog, DialogBody, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { EmptyState } from "@/components/ui/empty-state";
import type { AssetViewModel, OrderDetailVM } from "@/lib/types/view-models";
@@ -6,7 +10,156 @@ type OrderAssetsPanelProps = {
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 (
<div
key={asset.id}
@@ -23,14 +176,87 @@ function renderAssetCard(asset: AssetViewModel) {
</span>
) : null}
</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}
</code>
</code> */}
{renderInputSnapshots(asset, onOpenPreview)}
</div>
);
}
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 =
viewModel.finalAssetState.kind === "business-empty"
? viewModel.finalAssetState.title
@@ -76,7 +302,7 @@ export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) {
</p>
</div>
{viewModel.finalAsset ? (
renderAssetCard(viewModel.finalAsset)
renderAssetCard(viewModel.finalAsset, openPreview)
) : (
<EmptyState
eyebrow="Final asset empty"
@@ -94,7 +320,9 @@ export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) {
</p>
</div>
{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
eyebrow="Gallery empty"
@@ -103,6 +331,53 @@ export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) {
/>
)}
</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>
</Card>
);

View File

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

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -6,69 +6,67 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import type { ResourcePickerOption } from "@/features/orders/resource-picker-options";
type ResourcePickerCardProps = {
description: string;
disabled?: boolean;
isLoading?: boolean;
items: ResourcePickerOption[];
label: string;
selectedItem: ResourcePickerOption | null;
title: string;
value: string;
onChange: (value: string) => void;
onOpenPicker: () => void;
};
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
export function ResourcePickerCard({
description,
disabled = false,
isLoading = false,
items,
label,
selectedItem,
title,
value,
onChange,
onOpenPicker,
}: ResourcePickerCardProps) {
const selectedItem = items.find((item) => item.id === value) ?? null;
return (
<Card>
<CardHeader>
<CardEyebrow>Mock backed selector</CardEyebrow>
<CardEyebrow>Resource manager</CardEyebrow>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<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>
<select
aria-label={label}
className={joinClasses(
"min-h-12 rounded-[18px] border border-[var(--border-strong)] bg-[var(--surface-muted)] px-4 text-sm text-[var(--ink-strong)]",
"focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]",
)}
<Button
aria-label={selectedItem ? `更换${label}` : `选择${label}`}
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)]"
disabled={disabled || isLoading}
value={value}
onChange={(event) => onChange(event.target.value)}
size="lg"
variant="secondary"
onClick={onOpenPicker}
>
<option value="">
{isLoading ? "正在加载占位资源..." : "请选择一个资源"}
</option>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</label>
{isLoading
? "正在加载资源..."
: selectedItem
? `更换${label}`
: `选择${label}`}
</Button>
</div>
{selectedItem ? (
<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">
<p className="text-sm font-semibold text-[var(--ink-strong)]">
{selectedItem.name}
@@ -77,17 +75,21 @@ export function ResourcePickerCard({
{selectedItem.description}
</p>
</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>
<p className="mt-3 font-[var(--font-mono)] text-xs text-[var(--ink-faint)]">
{selectedItem.previewUri}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{selectedItem.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>
</div>
) : (
<p className="text-sm leading-6 text-[var(--ink-muted)]">
ID
ID
</p>
)}
</CardContent>

View 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>
);
}

View File

@@ -3,16 +3,16 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { MetricChip } from "@/components/ui/metric-chip";
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 {
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 = {
page: number;
limit: number;
@@ -28,10 +28,10 @@ type OrdersHomeProps = {
onOpenWorkflow?: (orderId: string) => void;
onPageChange?: (page: number) => void;
onQuerySubmit?: (query: string) => void;
onStatusChange?: (status: FilterStatus) => void;
onStatusChange?: (status: OrderFilterStatus) => void;
recentOrders: OrderSummaryVM[];
selectedQuery?: string;
selectedStatus?: FilterStatus;
selectedStatus?: OrderFilterStatus;
totalPages?: number;
};
@@ -47,27 +47,13 @@ type OrdersOverviewEnvelope = {
};
const TITLE_MESSAGE = "最近订单已接入真实后端接口";
const DEFAULT_MESSAGE = "当前首页展示真实最近订单,同时保留订单号直达入口,方便快速跳转详情和流程。";
const DEFAULT_MESSAGE = "当前页面直接展示真实订单列表,支持关键词、状态和分页操作。";
const DEFAULT_PAGINATION: PaginationData = {
page: 1,
limit: 6,
total: 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({
currentPage = 1,
@@ -83,190 +69,55 @@ export function OrdersHome({
selectedStatus = "all",
totalPages = 0,
}: OrdersHomeProps) {
const [lookupValue, setLookupValue] = useState("");
const [queryValue, setQueryValue] = useState(selectedQuery);
const normalizedLookup = lookupValue.trim();
const canLookup = /^\d+$/.test(normalizedLookup);
const effectiveTotalPages = Math.max(totalPages, 1);
const [serviceModeFilter, setServiceModeFilter] =
useState<OrderFilterServiceMode>("all");
useEffect(() => {
setQueryValue(selectedQuery);
}, [selectedQuery]);
const visibleOrders =
serviceModeFilter === "all"
? recentOrders
: recentOrders.filter((order) => order.serviceMode === serviceModeFilter);
return (
<section className="space-y-8">
<section className="space-y-6">
<PageHeader
eyebrow="Orders home"
title="订单总览"
description="这里是后台首页入口,当前已经接入真实最近订单列表,并保留订单号直达入口方便快速处理。"
meta="真实列表入口"
description="订单页直接承担扫描、筛选和跳转职责,不再把首屏浪费在入口说明和大卡片上。"
meta="真实列表"
/>
<div className="rounded-[28px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-6 py-5">
<p className="text-lg font-semibold tracking-[-0.02em] text-[#7a5323]">
{TITLE_MESSAGE}
</p>
<p className="mt-2 text-sm leading-7 text-[#7a5323]">
{message}
</p>
<div className="flex flex-wrap gap-2">
<MetricChip label="mode" value="真实订单列表" />
<MetricChip label="rows" value={visibleOrders.length} />
<MetricChip label="message" value={TITLE_MESSAGE} />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<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)]"
<p className="text-sm text-[var(--ink-muted)]">{message}</p>
<OrdersToolbar
currentPage={currentPage}
query={queryValue}
serviceMode={serviceModeFilter}
status={selectedStatus}
totalPages={totalPages}
onPageChange={onPageChange}
onQueryChange={setQueryValue}
onQuerySubmit={onQuerySubmit}
onServiceModeChange={setServiceModeFilter}
onStatusChange={onStatusChange}
/>
</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>
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-2">
<CardEyebrow>Recent visits</CardEyebrow>
<div className="space-y-1">
<CardTitle>访</CardTitle>
<CardDescription>
沿
</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)]"
<OrdersTable
isLoading={isLoadingRecent}
items={visibleOrders}
onOpenOrder={onOpenOrder}
onOpenWorkflow={onOpenWorkflow}
/>
<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 ? (
recentOrders.map((order) => (
<div
key={order.orderId}
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)]">
#{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>
);
}
@@ -277,7 +128,8 @@ export function OrdersHomeScreen() {
const [message, setMessage] = useState(DEFAULT_MESSAGE);
const [isLoadingRecent, setIsLoadingRecent] = useState(true);
const [selectedQuery, setSelectedQuery] = useState("");
const [selectedStatus, setSelectedStatus] = useState<FilterStatus>("all");
const [selectedStatus, setSelectedStatus] =
useState<OrderFilterStatus>("all");
const [pagination, setPagination] = useState<PaginationData>(DEFAULT_PAGINATION);
useEffect(() => {

View File

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

View File

@@ -176,8 +176,8 @@ export function SubmitWorkbench() {
(item) => item.id === formValues.garmentId,
);
if (!selectedModel || !selectedScene || !selectedGarment) {
setSubmitError("请先完成模特、场景和服装资源选择。");
if (!selectedModel || !selectedGarment) {
setSubmitError("请先完成模特和服装资源选择。");
return;
}
@@ -195,9 +195,10 @@ export function SubmitWorkbench() {
customer_level: formValues.customerLevel,
service_mode: formValues.serviceMode,
model_id: selectedModel.backendId,
pose_id: selectedModel.poseId,
garment_asset_id: selectedGarment.backendId,
scene_ref_asset_id: selectedScene.backendId,
...(selectedScene
? { scene_ref_asset_id: selectedScene.backendId }
: {}),
}),
});

View File

@@ -51,14 +51,14 @@ export function ReviewActionPanel({
<div className="space-y-2">
<CardEyebrow>Review action</CardEyebrow>
<div className="space-y-1">
<CardTitle></CardTitle>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</div>
</div>
{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)]">
#{order.orderId}
</p>
@@ -74,20 +74,20 @@ export function ReviewActionPanel({
<textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
rows={5}
rows={4}
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>
{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}
</div>
) : null}
{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">
<span></span>
<StatusBadge variant="reviewDecision" status={submissionResult.decision} />
@@ -96,12 +96,12 @@ export function ReviewActionPanel({
</div>
) : null}
<div className="grid gap-3">
<div className="grid gap-2">
{ACTIONS.map((action) => (
<Button
key={action.decision}
variant={action.variant}
size="lg"
size="md"
disabled={!order || isSubmitting}
onClick={() => onSubmit(action.decision, comment)}
>

View 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>
);
}

View File

@@ -108,8 +108,8 @@ export function ReviewImagePanel({
</CardHeader>
<CardContent className="space-y-6">
{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="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="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-[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">
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.22em] text-[var(--ink-faint)]">
{selectedAsset.isMock ? "Mock preview asset" : "Preview asset"}
@@ -135,13 +135,13 @@ export function ReviewImagePanel({
)}
{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
</div>
) : null}
{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) => {
const isSelected = asset.id === selectedAsset?.id;
@@ -151,7 +151,7 @@ export function ReviewImagePanel({
type="button"
onClick={() => onSelectAsset(asset.id)}
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
? "border-[var(--accent-primary)] bg-[rgba(110,127,82,0.09)]"
: "border-[var(--border-soft)] bg-[var(--surface-muted)] hover:bg-[var(--surface)]",

View File

@@ -1,9 +1,15 @@
import Link from "next/link";
import { Card, CardContent, CardEyebrow, CardHeader } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { SectionTitle } from "@/components/ui/section-title";
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";
type ReviewQueueProps = {
@@ -25,88 +31,119 @@ export function ReviewQueue({
isLoading,
queue,
}: ReviewQueueProps) {
if (isLoading) {
return (
<Card className="h-full">
<CardHeader>
<SectionTitle
eyebrow="Pending queue"
title="待审核队列"
description="只展示当前后端真实返回的待审核订单,动作提交后再以非乐观方式刷新队列。"
/>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="rounded-[24px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
<div 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>
) : null}
);
}
{!isLoading && error ? (
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
if (error) {
return (
<div className="rounded-[var(--panel-radius)] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
{error}
</div>
) : null}
);
}
{!isLoading && !error && queue?.state.kind === "business-empty" ? (
if (queue?.state.kind === "business-empty") {
return (
<EmptyState
eyebrow="Queue empty"
title={queue.state.title}
description={queue.state.description}
/>
) : null}
);
}
{!isLoading && !error && queue?.items.length ? (
<div className="space-y-3">
if (!queue?.items.length) {
return null;
}
return (
<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>
<TableBody>
{queue.items.map((item) => (
<Link
key={item.reviewTaskId}
href={`/reviews/workbench/${item.orderId}`}
className={joinClasses(
"block w-full rounded-[24px] border px-4 py-4 text-left transition duration-150",
"border-[var(--border-soft)] bg-[var(--surface-muted)] hover:border-[var(--border-strong)] hover:bg-[var(--surface)]",
)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<CardEyebrow>Order #{item.orderId}</CardEyebrow>
<TableRow key={item.reviewTaskId}>
<TableCell className="font-medium">#{item.orderId}</TableCell>
<TableCell>
<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>{item.workflowId}</div>
{item.workflowType ? (
<div className="text-xs text-[var(--ink-muted)]">{item.workflowType}</div>
) : null}
</div>
</TableCell>
<TableCell>
<StatusBadge status={item.status} />
</div>
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs text-[var(--ink-muted)]">
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<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}
<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.5 py-1 font-[var(--font-mono)] uppercase tracking-[0.18em] text-[#50633b]">
<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>
</Link>
))}
</div>
{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}
</CardContent>
</Card>
</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
key={item.orderId}
href={`/reviews/workbench/${item.orderId}`}
className={joinClasses(
"inline-flex h-9 items-center rounded-md border border-[var(--border-strong)] px-3 text-sm text-[var(--ink-strong)] transition",
"hover:bg-[var(--surface-muted)]",
)}
>
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -54,7 +54,7 @@ export function ReviewRevisionPanel({
<div className="space-y-2">
<CardEyebrow>Manual revision</CardEyebrow>
<div className="space-y-1">
<CardTitle>稿</CardTitle>
<CardTitle></CardTitle>
<CardDescription>
线稿稿 approve
signal 线
@@ -83,7 +83,7 @@ export function ReviewRevisionPanel({
value={uploadedUri}
onChange={(event) => setUploadedUri(event.target.value)}
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>
@@ -96,32 +96,32 @@ export function ReviewRevisionPanel({
onChange={(event) => setComment(event.target.value)}
rows={4}
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>
{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}
</div>
) : null}
{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}
</div>
) : null}
{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>
) : null}
<div className="grid gap-3">
<div className="grid gap-2">
<Button
variant="secondary"
size="lg"
size="md"
disabled={!order || !selectedAsset || isSubmitting}
onClick={() => onRegisterRevision({ uploadedUri, comment })}
>
@@ -130,7 +130,7 @@ export function ReviewRevisionPanel({
{pendingManualConfirm ? (
<Button
variant="primary"
size="lg"
size="md"
disabled={!order || isSubmitting}
onClick={() => onConfirmRevision(comment)}
>

View File

@@ -54,8 +54,8 @@ export function ReviewWorkflowSummary({
{!isLoading && !error && workflow ? (
<>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
<div className="grid gap-2 sm:grid-cols-2">
<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)]">
Current step
</p>
@@ -64,7 +64,7 @@ export function ReviewWorkflowSummary({
<StatusBadge status={workflow.status} />
</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)]">
Failure count
</p>
@@ -75,7 +75,7 @@ export function ReviewWorkflowSummary({
</div>
{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
</div>
) : null}
@@ -85,7 +85,7 @@ export function ReviewWorkflowSummary({
<div
key={step.id}
className={joinClasses(
"rounded-[24px] border px-4 py-4",
"rounded-[var(--panel-radius)] border px-3 py-3",
step.isCurrent
? "border-[var(--accent-primary)] bg-[rgba(110,127,82,0.09)]"
: "border-[var(--border-soft)] bg-[var(--surface-muted)]",

View File

@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
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 { ReviewActionPanel } from "@/features/reviews/components/review-action-panel";
import { ReviewImagePanel } from "@/features/reviews/components/review-image-panel";
@@ -340,61 +340,48 @@ export function ReviewWorkbenchDetailScreen({
}
return (
<section className="space-y-8">
<PageHeader
eyebrow="Review detail"
title={`订单 #${orderDetail.orderId}`}
description="审核详情页只处理单个订单,列表筛选和切单行为统一留在审核工作台首页。"
meta={`更新于 ${formatTimestamp(orderDetail.updatedAt)}`}
actions={
<section className="space-y-5">
<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">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="space-y-2">
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-[var(--ink-faint)]">
Review detail
</p>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]">
#{orderDetail.orderId}
</h1>
<StatusBadge status={orderDetail.status} />
<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="flex flex-col items-start gap-2 xl:items-end">
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--ink-faint)]">
{formatTimestamp(orderDetail.updatedAt)}
</p>
<Link
href="/reviews/workbench"
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)]"
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)]"
>
</Link>
}
/>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<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)]">
Order status
</p>
<div className="mt-3">
<StatusBadge status={orderDetail.status} />
</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
</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 className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
<ReviewImagePanel
error={contextError}
isLoading={isLoadingContext}
@@ -403,7 +390,16 @@ export function ReviewWorkbenchDetailScreen({
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
isSubmitting={isSubmitting}
order={orderDetail}
@@ -415,15 +411,6 @@ export function ReviewWorkbenchDetailScreen({
onRegisterRevision={handleRegisterRevision}
onConfirmRevision={handleConfirmRevision}
/>
<ReviewActionPanel
key={`${orderDetail.orderId}-${submissionResult?.decision ?? "idle"}`}
isSubmitting={isSubmitting}
order={orderDetail}
selectedAsset={selectedAsset}
submissionError={submissionError}
submissionResult={submissionResult}
onSubmit={handleSubmit}
/>
<ReviewWorkflowSummary
error={contextError}
isLoading={isLoadingContext}

View File

@@ -1,8 +1,13 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
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 type { ReviewQueueVM } from "@/lib/types/view-models";
@@ -19,6 +24,11 @@ export function ReviewWorkbenchListScreen() {
const [queue, setQueue] = useState<ReviewQueueVM | null>(null);
const [queueError, setQueueError] = useState<string | null>(null);
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(() => {
let active = true;
@@ -59,21 +69,72 @@ export function ReviewWorkbenchListScreen() {
return () => {
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 (
<section className="space-y-8">
<section className="space-y-6">
<PageHeader
eyebrow="Human review queue"
title="审核工作台"
description="列表页只负责展示待审核队列,点击具体订单后再进入独立详情页处理资产、动作和流程摘要。"
meta="先看列表,再进详情"
description="先在队列里筛选任务,再进入详情页执行审核或人工修订,列表本身不承载动作面板。"
meta="队列 -> 决策详情"
/>
<ReviewFilters
query={query}
revisionFilter={revisionFilter}
statusFilter={statusFilter}
onQueryChange={setQuery}
onRevisionFilterChange={setRevisionFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={() => setReloadKey((value) => value + 1)}
/>
<ReviewQueue
error={queueError}
isLoading={isLoadingQueue}
queue={queue}
queue={filteredQueue}
/>
</section>
);

View 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>
);
}

View File

@@ -83,6 +83,25 @@ export function WorkflowTimeline({ viewModel }: WorkflowTimelineProps) {
</p>
) : null}
{step.previewUri ? (
<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) => (

View 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>
);
}

View File

@@ -3,16 +3,15 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { MetricChip } from "@/components/ui/metric-chip";
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 {
WorkflowToolbar,
type WorkflowFilterStatus,
} from "@/features/workflows/components/workflow-toolbar";
import { WorkflowTable } from "@/features/workflows/components/workflow-table";
type FilterStatus = OrderStatus | "all";
type PaginationData = {
page: number;
limit: number;
@@ -28,9 +27,9 @@ type WorkflowLookupProps = {
onOpenWorkflow?: (orderId: string) => void;
onPageChange?: (page: number) => void;
onQuerySubmit?: (query: string) => void;
onStatusChange?: (status: FilterStatus) => void;
onStatusChange?: (status: WorkflowFilterStatus) => void;
selectedQuery?: string;
selectedStatus?: FilterStatus;
selectedStatus?: WorkflowFilterStatus;
totalPages?: number;
};
@@ -52,17 +51,6 @@ const DEFAULT_PAGINATION: PaginationData = {
total: 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({
currentPage = 1,
isLoading = false,
@@ -76,171 +64,45 @@ export function WorkflowLookup({
selectedStatus = "all",
totalPages = 0,
}: WorkflowLookupProps) {
const [lookupValue, setLookupValue] = useState("");
const [queryValue, setQueryValue] = useState(selectedQuery);
const normalizedLookup = lookupValue.trim();
const canLookup = /^\d+$/.test(normalizedLookup);
const effectiveTotalPages = Math.max(totalPages, 1);
useEffect(() => {
setQueryValue(selectedQuery);
}, [selectedQuery]);
return (
<section className="space-y-8">
<section className="space-y-6">
<PageHeader
eyebrow="Workflow lookup"
title="流程追踪"
description="当前首页已经接入真实最近流程列表,并保留订单号直达能力,方便快速排查。"
meta="真实列表入口"
description="流程页承担排查和定位职责,首屏优先显示状态、失败信息和进入详情的操作。"
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]">
{message}
<div className="flex flex-wrap gap-2">
<MetricChip label="mode" value="真实流程列表" />
<MetricChip label="rows" value={items.length} />
<MetricChip label="message" value="流程追踪已接入真实后端" />
</div>
<div className="grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
<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)]"
<p className="text-sm text-[var(--ink-muted)]">{message}</p>
<WorkflowToolbar
currentPage={currentPage}
query={queryValue}
status={selectedStatus}
totalPages={totalPages}
onPageChange={onPageChange}
onQueryChange={setQueryValue}
onQuerySubmit={onQuerySubmit}
onStatusChange={onStatusChange}
/>
<Button
className="w-full"
disabled={!canLookup}
onClick={() => onOpenWorkflow?.(normalizedLookup)}
>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-2">
<CardEyebrow>Placeholder index</CardEyebrow>
<div className="space-y-1">
<CardTitle></CardTitle>
<CardDescription>
沿
</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)]"
<WorkflowTable
isLoading={isLoading}
items={items}
onOpenWorkflow={onOpenWorkflow}
/>
<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 ? (
items.map((item) => (
<div
key={item.workflowId}
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>
);
}
@@ -251,7 +113,8 @@ export function WorkflowLookupScreen() {
const [message, setMessage] = useState(DEFAULT_MESSAGE);
const [isLoading, setIsLoading] = useState(true);
const [selectedQuery, setSelectedQuery] = useState("");
const [selectedStatus, setSelectedStatus] = useState<FilterStatus>("all");
const [selectedStatus, setSelectedStatus] =
useState<WorkflowFilterStatus>("all");
const [pagination, setPagination] = useState<PaginationData>(DEFAULT_PAGINATION);
useEffect(() => {

View File

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

View File

@@ -62,16 +62,30 @@ export function adaptAsset(asset: AssetDto): AssetViewModel {
export function adaptOrderSummary(
order: Pick<
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 {
return {
orderId: order.order_id,
workflowId: order.workflow_id,
customerLevel: order.customer_level,
serviceMode: order.service_mode,
status: order.status,
statusMeta: getOrderStatusMeta(order.status),
currentStep: order.current_step,
currentStepLabel: getWorkflowStepMeta(order.current_step).label,
reviewTaskStatus: order.review_task_status,
revisionCount: order.revision_count,
pendingManualConfirm: order.pending_manual_confirm,
updatedAt: order.updated_at,
};
}

View File

@@ -18,13 +18,43 @@ import {
} from "@/lib/types/view-models";
type WorkflowAssetUriField =
| "uri"
| "asset_uri"
| "candidate_uri"
| "preview_uri"
| "result_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>([
"uri",
"asset_uri",
"candidate_uri",
"preview_uri",
@@ -64,8 +94,47 @@ function collectKnownAssetUris(
return results;
}
function findFirstKnownAssetUri(value: JsonValue | undefined): string | null {
if (!value || typeof value !== "object") {
return null;
}
if (Array.isArray(value)) {
for (const item of value) {
const nested = findFirstKnownAssetUri(item);
if (nested) {
return nested;
}
}
return null;
}
for (const [key, nestedValue] of Object.entries(value)) {
if (
WORKFLOW_ASSET_URI_FIELDS.has(key as WorkflowAssetUriField) &&
typeof nestedValue === "string"
) {
return nestedValue;
}
const nested = findFirstKnownAssetUri(nestedValue);
if (nested) {
return nested;
}
}
return null;
}
function uniqueMockUris(...payloads: Array<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(
@@ -89,22 +158,20 @@ function adaptWorkflowStep(
endedAt: step.ended_at,
containsMockAssets: mockAssetUris.length > 0,
mockAssetUris,
previewUri: findFirstKnownAssetUri(step.output_json) ?? findFirstKnownAssetUri(step.input_json),
isCurrent: currentStep === step.step_name,
isFailed: step.step_status === "failed",
};
}
export function adaptWorkflowLookupItem(
workflow: Pick<
WorkflowStatusResponseDto | WorkflowListItemDto,
| "order_id"
| "workflow_id"
| "workflow_type"
| "workflow_status"
| "current_step"
| "updated_at"
>,
): WorkflowLookupItemVM {
export function adaptWorkflowLookupItem(workflow: WorkflowLookupSource): WorkflowLookupItemVM {
const failureCount =
"failure_count" in workflow
? workflow.failure_count
: "steps" in workflow
? workflow.steps.filter((step) => step.step_status === "failed").length
: 0;
return {
orderId: workflow.order_id,
workflowId: workflow.workflow_id,
@@ -113,6 +180,10 @@ export function adaptWorkflowLookupItem(
statusMeta: getOrderStatusMeta(workflow.workflow_status),
currentStep: workflow.current_step,
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,
};
}

View File

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

View File

@@ -43,10 +43,15 @@ export type AssetViewModel = {
export type OrderSummaryVM = {
orderId: number;
workflowId: string | null;
customerLevel: CustomerLevel;
serviceMode: ServiceMode;
status: OrderStatus;
statusMeta: StatusMeta;
currentStep: WorkflowStepName | null;
currentStepLabel: string;
reviewTaskStatus: ReviewTaskStatus | null;
revisionCount: number;
pendingManualConfirm: boolean;
updatedAt: string;
};
@@ -60,9 +65,9 @@ export type OrderDetailVM = {
currentStep: WorkflowStepName | null;
currentStepLabel: string;
modelId: number;
poseId: number;
poseId: number | null;
garmentAssetId: number;
sceneRefAssetId: number;
sceneRefAssetId: number | null;
currentRevisionAssetId: number | null;
currentRevisionVersion: number | null;
latestRevisionAssetId: number | null;
@@ -157,6 +162,7 @@ export type WorkflowStepVM = {
endedAt: string | null;
containsMockAssets: boolean;
mockAssetUris: string[];
previewUri: string | null;
isCurrent: boolean;
isFailed: boolean;
};
@@ -169,6 +175,10 @@ export type WorkflowLookupItemVM = {
statusMeta: StatusMeta;
currentStep: WorkflowStepName | null;
currentStepLabel: string;
failureCount: number;
reviewTaskStatus: ReviewTaskStatus | null;
revisionCount: number;
pendingManualConfirm: boolean;
updatedAt: string;
};
@@ -197,14 +207,24 @@ export type WorkflowDetailVM = {
export type LibraryType = "models" | "scenes" | "garments";
export type LibraryFileVM = {
id: number;
role: "original" | "thumbnail" | "gallery";
url: string;
};
export type LibraryItemVM = {
id: string;
libraryType: LibraryType;
name: string;
description: string;
previewUri: string;
originalUri?: string;
tags: string[];
files?: LibraryFileVM[];
isMock: boolean;
backendId?: number;
poseId?: number | null;
};
export const READY_STATE: ReadyState = {

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

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

View File

@@ -8,9 +8,9 @@ export const createOrderSchema = z
customer_level: z.enum(["low", "mid"]),
service_mode: z.enum(["auto_basic", "semi_pro"]),
model_id: z.number().int().positive(),
pose_id: z.number().int().positive(),
pose_id: z.number().int().positive().optional(),
garment_asset_id: z.number().int().positive(),
scene_ref_asset_id: z.number().int().positive(),
scene_ref_asset_id: z.number().int().positive().optional(),
})
.superRefine((value, context) => {
const validServiceMode =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,6 +63,8 @@ test("proxies recent orders overview from the backend list api", async () => {
{
orderId: 3,
workflowId: "order-3",
customerLevel: "mid",
serviceMode: "semi_pro",
status: "waiting_review",
statusMeta: {
label: "待审核",
@@ -70,6 +72,9 @@ test("proxies recent orders overview from the backend list api", async () => {
},
currentStep: "review",
currentStepLabel: "人工审核",
reviewTaskStatus: "revision_uploaded",
revisionCount: 1,
pendingManualConfirm: true,
updatedAt: "2026-03-27T14:00:03Z",
},
],

View File

@@ -70,6 +70,10 @@ test("proxies workflow lookup items from the backend workflows list api", async
},
currentStep: "review",
currentStepLabel: "人工审核",
failureCount: 0,
reviewTaskStatus: "revision_uploaded",
revisionCount: 1,
pendingManualConfirm: true,
updatedAt: "2026-03-27T14:00:03Z",
},
],

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { test, expect } from "vitest";
import { OrderDetail } from "@/features/orders/order-detail";
@@ -97,3 +97,140 @@ test("renders the business-empty final-result state when no final asset exists",
expect(screen.getByText("最终图暂未生成")).toBeInTheDocument();
expect(screen.getByText("当前订单还没有可展示的最终结果。")).toBeInTheDocument();
});
test("renders prepared-model input snapshots when asset metadata contains resource inputs", () => {
render(
<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)" });
});

View File

@@ -8,6 +8,8 @@ const RECENT_ORDERS: OrderSummaryVM[] = [
{
orderId: 4201,
workflowId: "wf-4201",
customerLevel: "mid",
serviceMode: "semi_pro",
status: "waiting_review",
statusMeta: {
label: "待审核",
@@ -15,15 +17,20 @@ const RECENT_ORDERS: OrderSummaryVM[] = [
},
currentStep: "review",
currentStepLabel: "人工审核",
reviewTaskStatus: "revision_uploaded",
revisionCount: 1,
pendingManualConfirm: true,
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} />);
expect(screen.getByText("最近订单已接入真实后端接口")).toBeInTheDocument();
expect(screen.getByText("订单 #4201")).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "订单号" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "服务模式" })).toBeInTheDocument();
expect(screen.getByLabelText("订单状态筛选")).toBeInTheDocument();
expect(screen.getByText("#4201")).toBeInTheDocument();
});
test("supports status filtering and pagination actions", () => {

View File

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

View File

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

View File

@@ -222,6 +222,17 @@ afterEach(() => {
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 () => {
const fetchMock = createFetchMock();
vi.stubGlobal("fetch", fetchMock);

View File

@@ -36,7 +36,7 @@ afterEach(() => {
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(
new Response(JSON.stringify(createPendingPayload()), {
status: 200,
@@ -50,13 +50,14 @@ test("renders medium-density list rows that link into independent review detail
render(<ReviewWorkbenchListScreen />);
expect(await screen.findByText("审核目标 #101")).toBeInTheDocument();
expect(screen.getByText(/工作流 wf-101/)).toBeInTheDocument();
expect(screen.getByText("Mock 资产")).toBeInTheDocument();
expect(screen.getByText("失败 2")).toBeInTheDocument();
expect(screen.queryByText("审核动作面板")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: /审核目标 #101/ })).toHaveAttribute(
expect(await screen.findByRole("columnheader", { name: "订单号" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "修订状态" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "刷新队列" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "进入详情" })).toHaveAttribute(
"href",
"/reviews/workbench/101",
);
expect(screen.getByText("Mock 资产")).toBeInTheDocument();
expect(screen.getByText("失败 2")).toBeInTheDocument();
expect(screen.queryByText("审核动作面板")).not.toBeInTheDocument();
});

View File

@@ -42,6 +42,7 @@ const BASE_WORKFLOW_DETAIL: WorkflowDetailVM = {
endedAt: "2026-03-27T00:07:00Z",
containsMockAssets: false,
mockAssetUris: [],
previewUri: null,
isCurrent: false,
isFailed: true,
},
@@ -64,6 +65,7 @@ const BASE_WORKFLOW_DETAIL: WorkflowDetailVM = {
endedAt: null,
containsMockAssets: true,
mockAssetUris: ["mock://fusion-preview"],
previewUri: "mock://fusion-preview",
isCurrent: true,
isFailed: false,
},
@@ -82,3 +84,41 @@ test("highlights failed steps and mock asset hints in the workflow timeline", ()
expect(screen.getByText("Temporal activity timed out.")).toBeInTheDocument();
expect(screen.getByText("当前流程包含 mock 资产")).toBeInTheDocument();
});
test("renders image previews for real workflow step outputs", () => {
render(
<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();
});

View File

@@ -16,15 +16,21 @@ const WORKFLOW_ITEMS: WorkflowLookupItemVM[] = [
},
currentStep: "review",
currentStepLabel: "人工审核",
failureCount: 2,
reviewTaskStatus: "revision_uploaded",
revisionCount: 1,
pendingManualConfirm: true,
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} />);
expect(screen.getByText("流程追踪首页当前显示真实后端最近流程。")).toBeInTheDocument();
expect(screen.getByText("订单 #4201")).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "流程类型" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "失败次数" })).toBeInTheDocument();
expect(screen.getByLabelText("流程状态筛选")).toBeInTheDocument();
expect(screen.getByText("#4201")).toBeInTheDocument();
});
test("supports workflow status filtering and pagination actions", () => {

View File

@@ -68,9 +68,13 @@ test("maps order summary status metadata and current step labels", () => {
expect(viewModel).toMatchObject({
orderId: 101,
workflowId: "wf-101",
customerLevel: "mid",
serviceMode: "semi_pro",
status: "waiting_review",
currentStep: "review",
currentStepLabel: "人工审核",
revisionCount: 0,
pendingManualConfirm: false,
statusMeta: {
label: "待审核",
tone: "warning",

View File

@@ -69,6 +69,33 @@ test("tags nested mock asset uris found in workflow step payloads", () => {
expect(viewModel.failureCount).toBe(1);
});
test("extracts a step preview uri from standard output uri fields", () => {
const viewModel = adaptWorkflowDetail({
...WORKFLOW_BASE,
current_step: "scene",
steps: [
{
id: 2,
workflow_run_id: 9001,
step_name: "scene",
step_status: "succeeded",
input_json: null,
output_json: {
uri: "https://images.example.com/orders/101/scene/generated.jpg",
},
error_message: null,
started_at: "2026-03-27T00:08:00Z",
ended_at: "2026-03-27T00:09:00Z",
},
],
});
expect(viewModel.steps[0].previewUri).toBe(
"https://images.example.com/orders/101/scene/generated.jpg",
);
expect(viewModel.steps[0].containsMockAssets).toBe(false);
});
test("maps workflow lookup status and current step labels", () => {
const viewModel = adaptWorkflowLookupItem(WORKFLOW_BASE);
@@ -79,6 +106,9 @@ test("maps workflow lookup status and current step labels", () => {
status: "running",
currentStep: "fusion",
currentStepLabel: "融合",
failureCount: 0,
revisionCount: 0,
pendingManualConfirm: false,
statusMeta: {
label: "处理中",
tone: "info",

View File

@@ -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");
});
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", () => {
HomePage();

View 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
View File

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

View File

@@ -21,6 +21,13 @@ test("uses order status metadata for the rendered tone", () => {
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", () => {
render(<StatusBadge status="reject" variant="reviewDecision" />);

View File

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