feat: bootstrap auto virtual tryon admin frontend

This commit is contained in:
afei A
2026-03-27 23:38:50 +08:00
commit 98c6b741d6
119 changed files with 19046 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
import Link from "next/link";
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
export function LoginPlaceholder() {
return (
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,rgba(208,190,152,0.28),transparent_42%),var(--bg-canvas)] px-6 py-12">
<Card className="w-full max-w-xl">
<CardHeader>
<div className="space-y-2">
<CardEyebrow>Authentication pending</CardEyebrow>
<div className="space-y-1">
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 text-sm leading-7 text-[var(--ink-muted)]">
<p></p>
<p>1. Next.js BFF </p>
<p>2. </p>
<p>3. </p>
<Link
href="/orders"
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)]"
>
</Link>
</CardContent>
</Card>
</main>
);
}

View File

@@ -0,0 +1,194 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import type { LibraryItemVM, LibraryType } from "@/lib/types/view-models";
type LibraryPageProps = {
isLoading?: boolean;
items: LibraryItemVM[];
libraryType: LibraryType;
message?: string;
};
type LibraryEnvelope = {
data?: {
items?: LibraryItemVM[];
};
message?: string;
};
const LIBRARY_META: Record<
LibraryType,
{ title: string; description: string; eyebrow: string }
> = {
models: {
title: "模特库",
description: "继续复用提单页所需的 mock 模特数据,同时保留正式资源库的结构和标签体系。",
eyebrow: "Model library",
},
scenes: {
title: "场景库",
description: "场景库先用 mock 占位数据承接,等真实后台资源列表可用后再切换数据源。",
eyebrow: "Scene library",
},
garments: {
title: "服装库",
description: "服装资源暂时继续走 mock 资产,但页面本身已经按正式资源库组织。",
eyebrow: "Garment library",
},
};
const TITLE_MESSAGE = "当前资源库仍使用 mock 数据";
const DEFAULT_MESSAGE = "当前只保留页面结构和 mock 资源项,真实资源查询能力将在后端支持后接入。";
export function LibraryPage({
isLoading = false,
items,
libraryType,
message = DEFAULT_MESSAGE,
}: LibraryPageProps) {
const meta = LIBRARY_META[libraryType];
return (
<section className="space-y-8">
<PageHeader
eyebrow={meta.eyebrow}
title={meta.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>
<Card>
<CardHeader>
<div className="space-y-2">
<CardEyebrow>Library inventory</CardEyebrow>
<div className="space-y-1">
<CardTitle>{meta.title}</CardTitle>
<CardDescription>
BFF
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="rounded-[24px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
</div>
) : null}
{!isLoading && items.length ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<div
key={item.id}
className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4"
>
<div className="space-y-3">
<div>
<p className="text-sm font-semibold text-[var(--ink-strong)]">
{item.name}
</p>
<p className="mt-1 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>
<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>
</div>
</div>
))}
</div>
) : null}
{!isLoading && !items.length ? (
<EmptyState
eyebrow="Library empty"
title="暂无资源条目"
description="当前库还没有占位数据,等后端或运营资源列表准备好后再补齐。"
/>
) : null}
</CardContent>
</Card>
</section>
);
}
export function LibraryPageScreen({ libraryType }: { libraryType: LibraryType }) {
const [items, setItems] = useState<LibraryItemVM[]>([]);
const [message, setMessage] = useState(
"当前资源库仍使用 mock 数据,真实资源查询能力将在后端支持后接入。",
);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let active = true;
async function loadLibrary() {
setIsLoading(true);
try {
const response = await fetch(`/api/libraries/${libraryType}`);
const payload = (await response.json()) as LibraryEnvelope;
if (!active) {
return;
}
setItems(payload.data?.items ?? []);
setMessage(
payload.message ??
"当前资源库仍使用 mock 数据,真实资源查询能力将在后端支持后接入。",
);
} catch {
if (!active) {
return;
}
setItems([]);
setMessage("资源库数据加载失败,请稍后重试。");
} finally {
if (active) {
setIsLoading(false);
}
}
}
void loadLibrary();
return () => {
active = false;
};
}, [libraryType]);
return (
<LibraryPage
isLoading={isLoading}
items={items}
libraryType={libraryType}
message={message}
/>
);
}

View File

@@ -0,0 +1,201 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardEyebrow,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { OrderSummaryCard } from "@/features/orders/components/order-summary-card";
import { ResourcePickerCard } from "@/features/orders/components/resource-picker-card";
import {
SERVICE_MODE_LABELS,
type ModelPickerOption,
type ResourcePickerOption,
} from "@/features/orders/resource-picker-options";
import type {
CustomerLevel,
ServiceMode,
} from "@/lib/types/backend";
type SubmissionSuccess = {
orderId: number;
workflowId: string | null;
};
export type CreateOrderFormValues = {
customerLevel: CustomerLevel;
garmentId: string;
modelId: string;
sceneId: string;
serviceMode: ServiceMode;
};
type CreateOrderFormProps = {
allowedServiceMode: ServiceMode;
garments: ResourcePickerOption[];
isLoadingResources: boolean;
isSubmitting: boolean;
models: ModelPickerOption[];
scenes: ResourcePickerOption[];
submissionSuccess: SubmissionSuccess | null;
submitError: string | null;
value: CreateOrderFormValues;
onCustomerLevelChange: (value: CustomerLevel) => void;
onGarmentChange: (value: string) => void;
onModelChange: (value: string) => void;
onSceneChange: (value: string) => void;
onServiceModeChange: (value: ServiceMode) => void;
onSubmit: () => void;
};
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
export function CreateOrderForm({
allowedServiceMode,
garments,
isLoadingResources,
isSubmitting,
models,
scenes,
submissionSuccess,
submitError,
value,
onCustomerLevelChange,
onGarmentChange,
onModelChange,
onSceneChange,
onServiceModeChange,
onSubmit,
}: CreateOrderFormProps) {
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;
return (
<form
className="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.85fr)]"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<div className="space-y-6">
<Card>
<CardHeader>
<CardEyebrow>Business inputs</CardEyebrow>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<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
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)]",
)}
disabled={isSubmitting}
value={value.customerLevel}
onChange={(event) =>
onCustomerLevelChange(event.target.value 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
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)]",
)}
disabled={isSubmitting}
value={value.serviceMode}
onChange={(event) =>
onServiceModeChange(event.target.value 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。"
disabled={isSubmitting}
isLoading={isLoadingResources}
items={models}
label="模特资源"
title="模特"
value={value.modelId}
onChange={onModelChange}
/>
<ResourcePickerCard
description="场景仍来自 mock 数据,但通过 BFF 路由加载,保持页面集成方式真实。"
disabled={isSubmitting}
isLoading={isLoadingResources}
items={scenes}
label="场景资源"
title="场景"
value={value.sceneId}
onChange={onSceneChange}
/>
<ResourcePickerCard
description="服装选择沿用当前资源库占位素材,为订单创建页提供稳定联调入口。"
disabled={isSubmitting}
isLoading={isLoadingResources}
items={garments}
label="服装资源"
title="服装"
value={value.garmentId}
onChange={onGarmentChange}
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button disabled={isLoadingResources || isSubmitting} type="submit">
{isSubmitting ? "提交中..." : "提交订单"}
</Button>
<p className="text-sm text-[var(--ink-muted)]">
</p>
</div>
</div>
<OrderSummaryCard
customerLevel={value.customerLevel}
garment={selectedGarment}
model={selectedModel}
scene={selectedScene}
serviceMode={value.serviceMode}
submitError={submitError}
submissionSuccess={submissionSuccess}
/>
</form>
);
}

View File

@@ -0,0 +1,109 @@
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import type { AssetViewModel, OrderDetailVM } from "@/lib/types/view-models";
type OrderAssetsPanelProps = {
viewModel: OrderDetailVM;
};
function renderAssetCard(asset: AssetViewModel) {
return (
<div
key={asset.id}
className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4"
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-[var(--ink-strong)]">{asset.label}</p>
<p className="text-xs text-[var(--ink-muted)]">{asset.stepLabel}</p>
</div>
{asset.isMock ? (
<span className="rounded-full bg-[rgba(145,104,46,0.12)] px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[#7a5323]">
Mock
</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)]">
{asset.uri}
</code>
</div>
);
}
export function OrderAssetsPanel({ viewModel }: OrderAssetsPanelProps) {
const finalAssetTitle =
viewModel.finalAssetState.kind === "business-empty"
? viewModel.finalAssetState.title
: "最终图暂未生成";
const finalAssetDescription =
viewModel.finalAssetState.kind === "business-empty"
? viewModel.finalAssetState.description
: "当前订单还没有可展示的最终结果。";
const galleryEmptyTitle =
viewModel.assetGalleryState.kind === "business-empty"
? viewModel.assetGalleryState.title
: "暂无资产";
const galleryEmptyDescription =
viewModel.assetGalleryState.kind === "business-empty"
? viewModel.assetGalleryState.description
: "当前订单还没有生成可查看的资产列表。";
return (
<Card className="h-full">
<CardHeader>
<div className="space-y-2">
<CardEyebrow>Asset gallery</CardEyebrow>
<div className="space-y-1">
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{viewModel.hasMockAssets ? (
<div className="rounded-[24px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-5 py-4 text-sm text-[#7a5323]">
mock
</div>
) : null}
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-[var(--ink-strong)]"></p>
<p className="text-sm text-[var(--ink-muted)]">
</p>
</div>
{viewModel.finalAsset ? (
renderAssetCard(viewModel.finalAsset)
) : (
<EmptyState
eyebrow="Final asset empty"
title={finalAssetTitle}
description={finalAssetDescription}
/>
)}
</div>
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-[var(--ink-strong)]"></p>
<p className="text-sm text-[var(--ink-muted)]">
便 mock
</p>
</div>
{viewModel.assets.length ? (
<div className="grid gap-3 md:grid-cols-2">{viewModel.assets.map(renderAssetCard)}</div>
) : (
<EmptyState
eyebrow="Gallery empty"
title={galleryEmptyTitle}
description={galleryEmptyDescription}
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,80 @@
import Link from "next/link";
import { PageHeader } from "@/components/ui/page-header";
import { StatusBadge } from "@/components/ui/status-badge";
import type { OrderDetailVM } from "@/lib/types/view-models";
type OrderDetailHeaderProps = {
viewModel: OrderDetailVM;
};
function formatTimestamp(timestamp: string) {
return timestamp.replace("T", " ").replace("Z", " UTC");
}
function formatCustomerLevel(level: OrderDetailVM["customerLevel"]) {
return level === "mid" ? "Mid 客户" : "Low 客户";
}
function formatServiceMode(mode: OrderDetailVM["serviceMode"]) {
return mode === "semi_pro" ? "Semi Pro" : "Auto Basic";
}
export function OrderDetailHeader({ viewModel }: OrderDetailHeaderProps) {
return (
<div className="space-y-6">
<PageHeader
eyebrow="Order detail"
title={`订单 #${viewModel.orderId}`}
description="订单详情页只读展示核心参数、结果图和流程入口,不承接审核动作。"
meta={`更新于 ${formatTimestamp(viewModel.updatedAt)}`}
actions={
viewModel.workflowId ? (
<Link
href={`/workflows/${viewModel.orderId}`}
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)]"
>
</Link>
) : null
}
/>
<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={viewModel.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)]">
Customer level
</p>
<p className="mt-3 text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
{formatCustomerLevel(viewModel.customerLevel)}
</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)]">
Service mode
</p>
<p className="mt-3 text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
{formatServiceMode(viewModel.serviceMode)}
</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={viewModel.currentStep} />
<span className="text-sm text-[var(--ink-muted)]">{viewModel.currentStepLabel}</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import {
Card,
CardContent,
CardDescription,
CardEyebrow,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
CUSTOMER_LEVEL_LABELS,
SERVICE_MODE_LABELS,
type ModelPickerOption,
type ResourcePickerOption,
} from "@/features/orders/resource-picker-options";
import type {
CustomerLevel,
ServiceMode,
} from "@/lib/types/backend";
type SubmissionSuccess = {
orderId: number;
workflowId: string | null;
};
type OrderSummaryCardProps = {
customerLevel: CustomerLevel;
model: ModelPickerOption | null;
scene: ResourcePickerOption | null;
garment: ResourcePickerOption | null;
serviceMode: ServiceMode;
submitError: string | null;
submissionSuccess: SubmissionSuccess | null;
};
type SummaryRowProps = {
label: string;
value: string;
};
function SummaryRow({ label, value }: SummaryRowProps) {
return (
<div className="flex items-start justify-between gap-4 border-b border-[var(--border-soft)] py-3 last:border-b-0 last:pb-0">
<dt className="text-sm text-[var(--ink-muted)]">{label}</dt>
<dd className="text-right text-sm font-medium text-[var(--ink-strong)]">
{value}
</dd>
</div>
);
}
export function OrderSummaryCard({
customerLevel,
garment,
model,
scene,
serviceMode,
submitError,
submissionSuccess,
}: OrderSummaryCardProps) {
return (
<Card
aria-label="提单摘要"
role="region"
className="sticky top-6 h-fit"
>
<CardHeader>
<CardEyebrow>Submission summary</CardEyebrow>
<CardTitle></CardTitle>
<CardDescription>
BFF
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<dl>
<SummaryRow
label="客户层级"
value={CUSTOMER_LEVEL_LABELS[customerLevel]}
/>
<SummaryRow
label="服务模式"
value={`${SERVICE_MODE_LABELS[serviceMode]} ${serviceMode}`}
/>
<SummaryRow
label="模特资源"
value={model ? model.name : "待选择"}
/>
<SummaryRow
label="场景资源"
value={scene ? scene.name : "待选择"}
/>
<SummaryRow
label="服装资源"
value={garment ? garment.name : "待选择"}
/>
<SummaryRow
label="提交映射"
value={
model && scene && garment
? `model ${model.backendId} / pose ${model.poseId} / scene ${scene.backendId} / garment ${garment.backendId}`
: "完成选择后显示"
}
/>
</dl>
{submitError ? (
<div className="rounded-[20px] border border-[#b88472] bg-[#f8ece5] px-4 py-3 text-sm text-[#7f4b3b]">
{submitError}
</div>
) : null}
{submissionSuccess ? (
<div className="space-y-2 rounded-[20px] border border-[var(--accent-ring)] bg-[var(--accent-soft)] px-4 py-4 text-sm text-[var(--accent-ink)]">
<p className="font-semibold">
#{submissionSuccess.orderId}
</p>
<p>
ID {submissionSuccess.workflowId ?? "未返回"}
</p>
</div>
) : null}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,56 @@
import Link from "next/link";
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge";
import type { OrderDetailVM } from "@/lib/types/view-models";
type OrderWorkflowCardProps = {
viewModel: OrderDetailVM;
};
export function OrderWorkflowCard({ viewModel }: OrderWorkflowCardProps) {
return (
<Card>
<CardHeader>
<div className="space-y-2">
<CardEyebrow>Workflow linkage</CardEyebrow>
<div className="space-y-1">
<CardTitle></CardTitle>
<CardDescription>
线
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
Workflow ID
</p>
<p className="mt-3 text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
{viewModel.workflowId ?? "暂未分配"}
</p>
</div>
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] 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={viewModel.currentStep} />
<span className="text-sm text-[var(--ink-muted)]">{viewModel.currentStepLabel}</span>
</div>
</div>
{viewModel.workflowId ? (
<Link
href={`/workflows/${viewModel.orderId}`}
className="inline-flex min-h-11 w-full 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)]"
>
线
</Link>
) : null}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,96 @@
import {
Card,
CardContent,
CardDescription,
CardEyebrow,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { ResourcePickerOption } from "@/features/orders/resource-picker-options";
type ResourcePickerCardProps = {
description: string;
disabled?: boolean;
isLoading?: boolean;
items: ResourcePickerOption[];
label: string;
title: string;
value: string;
onChange: (value: string) => void;
};
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
export function ResourcePickerCard({
description,
disabled = false,
isLoading = false,
items,
label,
title,
value,
onChange,
}: ResourcePickerCardProps) {
const selectedItem = items.find((item) => item.id === value) ?? null;
return (
<Card>
<CardHeader>
<CardEyebrow>Mock backed selector</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)]">
<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)]",
)}
disabled={disabled || isLoading}
value={value}
onChange={(event) => onChange(event.target.value)}
>
<option value="">
{isLoading ? "正在加载占位资源..." : "请选择一个资源"}
</option>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</label>
{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="space-y-1">
<p className="text-sm font-semibold text-[var(--ink-strong)]">
{selectedItem.name}
</p>
<p className="text-sm leading-6 text-[var(--ink-muted)]">
{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>
) : (
<p className="text-sm leading-6 text-[var(--ink-muted)]">
ID
</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import { EmptyState } from "@/components/ui/empty-state";
import { OrderAssetsPanel } from "@/features/orders/components/order-assets-panel";
import { OrderDetailHeader } from "@/features/orders/components/order-detail-header";
import { OrderWorkflowCard } from "@/features/orders/components/order-workflow-card";
import type { OrderDetailVM } from "@/lib/types/view-models";
type ApiEnvelope<T> = {
data?: T;
message?: string;
};
type OrderDetailProps = {
viewModel: OrderDetailVM;
};
type OrderDetailScreenProps = {
orderId: number;
};
async function parseEnvelope<T>(response: Response): Promise<ApiEnvelope<T>> {
return (await response.json()) as ApiEnvelope<T>;
}
export function OrderDetail({ viewModel }: OrderDetailProps) {
return (
<section className="space-y-8">
<OrderDetailHeader viewModel={viewModel} />
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
<OrderAssetsPanel viewModel={viewModel} />
<OrderWorkflowCard viewModel={viewModel} />
</div>
</section>
);
}
export function OrderDetailScreen({ orderId }: OrderDetailScreenProps) {
const [viewModel, setViewModel] = useState<OrderDetailVM | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let active = true;
async function loadOrderDetail() {
setIsLoading(true);
try {
const response = await fetch(`/api/orders/${orderId}`);
const payload = await parseEnvelope<OrderDetailVM>(response);
if (!response.ok || !payload.data) {
throw new Error(payload.message ?? "ORDER_DETAIL_LOAD_FAILED");
}
if (!active) {
return;
}
setViewModel(payload.data);
setError(null);
} catch {
if (!active) {
return;
}
setViewModel(null);
setError("订单详情加载失败,请稍后重试。");
} finally {
if (active) {
setIsLoading(false);
}
}
}
void loadOrderDetail();
return () => {
active = false;
};
}, [orderId]);
if (isLoading) {
return (
<section className="space-y-4">
<div className="rounded-[28px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-6 py-10 text-sm text-[var(--ink-muted)]">
</div>
</section>
);
}
if (error || !viewModel) {
return (
<EmptyState
eyebrow="Order detail error"
title="订单详情暂时不可用"
description={error ?? "当前订单详情还无法展示,请稍后重试。"}
/>
);
}
return <OrderDetail viewModel={viewModel} />;
}

View File

@@ -0,0 +1,377 @@
"use client";
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 { 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";
type FilterStatus = OrderStatus | "all";
type PaginationData = {
page: number;
limit: number;
total: number;
totalPages: number;
};
type OrdersHomeProps = {
currentPage?: number;
isLoadingRecent?: boolean;
message?: string;
onOpenOrder?: (orderId: string) => void;
onOpenWorkflow?: (orderId: string) => void;
onPageChange?: (page: number) => void;
onQuerySubmit?: (query: string) => void;
onStatusChange?: (status: FilterStatus) => void;
recentOrders: OrderSummaryVM[];
selectedQuery?: string;
selectedStatus?: FilterStatus;
totalPages?: number;
};
type OrdersOverviewEnvelope = {
data?: {
limit?: number;
items?: OrderSummaryVM[];
page?: number;
total?: number;
totalPages?: number;
};
message?: string;
};
const TITLE_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,
isLoadingRecent = false,
message = DEFAULT_MESSAGE,
onOpenOrder,
onOpenWorkflow,
onPageChange,
onQuerySubmit,
onStatusChange,
recentOrders,
selectedQuery = "",
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);
useEffect(() => {
setQueryValue(selectedQuery);
}, [selectedQuery]);
return (
<section className="space-y-8">
<PageHeader
eyebrow="Orders home"
title="订单总览"
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>
<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)]"
/>
</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)]"
/>
<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>
);
}
export function OrdersHomeScreen() {
const router = useRouter();
const [recentOrders, setRecentOrders] = useState<OrderSummaryVM[]>([]);
const [message, setMessage] = useState(DEFAULT_MESSAGE);
const [isLoadingRecent, setIsLoadingRecent] = useState(true);
const [selectedQuery, setSelectedQuery] = useState("");
const [selectedStatus, setSelectedStatus] = useState<FilterStatus>("all");
const [pagination, setPagination] = useState<PaginationData>(DEFAULT_PAGINATION);
useEffect(() => {
let active = true;
async function loadRecentOrders() {
setIsLoadingRecent(true);
try {
const params = new URLSearchParams({
page: String(pagination.page),
limit: String(pagination.limit),
});
if (selectedStatus !== "all") {
params.set("status", selectedStatus);
}
if (selectedQuery.length > 0) {
params.set("query", selectedQuery);
}
const response = await fetch(`/api/dashboard/orders-overview?${params.toString()}`);
const payload = (await response.json()) as OrdersOverviewEnvelope;
if (!active) {
return;
}
setRecentOrders(payload.data?.items ?? []);
setPagination((current) => ({
page: payload.data?.page ?? current.page,
limit: payload.data?.limit ?? current.limit,
total: payload.data?.total ?? current.total,
totalPages: payload.data?.totalPages ?? current.totalPages,
}));
setMessage(payload.message ?? DEFAULT_MESSAGE);
} catch {
if (!active) {
return;
}
setRecentOrders([]);
setPagination((current) => ({
...current,
total: 0,
totalPages: 0,
}));
setMessage("最近访问记录加载失败,请稍后重试。");
} finally {
if (active) {
setIsLoadingRecent(false);
}
}
}
void loadRecentOrders();
return () => {
active = false;
};
}, [pagination.limit, pagination.page, selectedQuery, selectedStatus]);
return (
<OrdersHome
currentPage={pagination.page}
isLoadingRecent={isLoadingRecent}
message={message}
onPageChange={(page) =>
setPagination((current) => ({
...current,
page,
}))
}
onQuerySubmit={(query) => {
setSelectedQuery(query);
setPagination((current) => ({
...current,
page: 1,
}));
}}
recentOrders={recentOrders}
onStatusChange={(status) => {
setSelectedStatus(status);
setPagination((current) => ({
...current,
page: 1,
}));
}}
onOpenOrder={(orderId) => router.push(`/orders/${orderId}`)}
onOpenWorkflow={(orderId) => router.push(`/workflows/${orderId}`)}
selectedQuery={selectedQuery}
selectedStatus={selectedStatus}
totalPages={pagination.totalPages}
/>
);
}

View File

@@ -0,0 +1,104 @@
import type {
CustomerLevel,
ServiceMode,
} from "@/lib/types/backend";
import type { LibraryItemVM } from "@/lib/types/view-models";
export type ResourcePickerOption = LibraryItemVM & {
backendId: number;
};
export type ModelPickerOption = ResourcePickerOption & {
poseId: number;
};
type ResourceBinding = {
backendId: number;
poseId?: number;
};
const RESOURCE_BINDINGS: Record<string, ResourceBinding> = {
"model-ava": {
backendId: 101,
poseId: 202,
},
"model-jian": {
backendId: 102,
poseId: 203,
},
"scene-loft": {
backendId: 404,
},
"scene-garden": {
backendId: 405,
},
"garment-coat-01": {
backendId: 303,
},
"garment-dress-03": {
backendId: 304,
},
};
export const CUSTOMER_LEVEL_LABELS: Record<CustomerLevel, string> = {
low: "低客单",
mid: "中客单",
};
export const SERVICE_MODE_LABELS: Record<ServiceMode, string> = {
auto_basic: "自动基础处理",
semi_pro: "半人工专业处理",
};
export const SERVICE_MODE_BY_CUSTOMER_LEVEL: Record<CustomerLevel, ServiceMode> =
{
low: "auto_basic",
mid: "semi_pro",
};
function getResourceBinding(resourceId: string) {
return RESOURCE_BINDINGS[resourceId] ?? null;
}
export function getServiceModeForCustomerLevel(
customerLevel: CustomerLevel,
): ServiceMode {
return SERVICE_MODE_BY_CUSTOMER_LEVEL[customerLevel];
}
export function mapModelOptions(items: LibraryItemVM[]): ModelPickerOption[] {
return items.flatMap((item) => {
const binding = getResourceBinding(item.id);
if (!binding?.poseId) {
return [];
}
return [
{
...item,
backendId: binding.backendId,
poseId: binding.poseId,
},
];
});
}
export function mapResourceOptions(
items: LibraryItemVM[],
): ResourcePickerOption[] {
return items.flatMap((item) => {
const binding = getResourceBinding(item.id);
if (!binding) {
return [];
}
return [
{
...item,
backendId: binding.backendId,
},
];
});
}

View File

@@ -0,0 +1,263 @@
"use client";
import {
useEffect,
useState,
startTransition,
} from "react";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/ui/page-header";
import { CreateOrderForm, type CreateOrderFormValues } from "@/features/orders/components/create-order-form";
import {
getServiceModeForCustomerLevel,
mapModelOptions,
mapResourceOptions,
type ModelPickerOption,
type ResourcePickerOption,
} from "@/features/orders/resource-picker-options";
import type {
CustomerLevel,
ServiceMode,
} from "@/lib/types/backend";
import type { LibraryItemVM } from "@/lib/types/view-models";
type LibraryResponse = {
data?: {
items?: LibraryItemVM[];
};
};
type SubmissionSuccess = {
orderId: number;
workflowId: string | null;
};
type CreateOrderResponse = {
data?: {
orderId?: number;
workflowId?: string | null;
};
message?: string;
};
const INITIAL_FORM_VALUES: CreateOrderFormValues = {
customerLevel: "mid",
garmentId: "",
modelId: "",
sceneId: "",
serviceMode: "semi_pro",
};
async function fetchLibraryItems(libraryType: "models" | "scenes" | "garments") {
const response = await fetch(`/api/libraries/${libraryType}`);
const payload = (await response.json()) as LibraryResponse;
if (!response.ok || !payload.data?.items) {
throw new Error("RESOURCE_LOAD_FAILED");
}
return payload.data.items;
}
export function SubmitWorkbench() {
const router = useRouter();
const [formValues, setFormValues] =
useState<CreateOrderFormValues>(INITIAL_FORM_VALUES);
const [models, setModels] = useState<ModelPickerOption[]>([]);
const [scenes, setScenes] = useState<ResourcePickerOption[]>([]);
const [garments, setGarments] = useState<ResourcePickerOption[]>([]);
const [isLoadingResources, setIsLoadingResources] = useState(true);
const [resourceError, setResourceError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submissionSuccess, setSubmissionSuccess] =
useState<SubmissionSuccess | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
let isActive = true;
async function loadResources() {
setIsLoadingResources(true);
setResourceError(null);
try {
const [modelItems, sceneItems, garmentItems] = await Promise.all([
fetchLibraryItems("models"),
fetchLibraryItems("scenes"),
fetchLibraryItems("garments"),
]);
if (!isActive) {
return;
}
setModels(mapModelOptions(modelItems));
setScenes(mapResourceOptions(sceneItems));
setGarments(mapResourceOptions(garmentItems));
} catch {
if (isActive) {
setResourceError("提单资源加载失败,请刷新页面后重试。");
}
} finally {
if (isActive) {
setIsLoadingResources(false);
}
}
}
void loadResources();
return () => {
isActive = false;
};
}, []);
useEffect(() => {
if (!submissionSuccess) {
return undefined;
}
const timeoutId = window.setTimeout(() => {
startTransition(() => {
router.push(`/orders/${submissionSuccess.orderId}`);
});
}, 150);
return () => {
window.clearTimeout(timeoutId);
};
}, [router, submissionSuccess]);
const allowedServiceMode = getServiceModeForCustomerLevel(
formValues.customerLevel,
);
const handleCustomerLevelChange = (customerLevel: CustomerLevel) => {
const serviceMode = getServiceModeForCustomerLevel(customerLevel);
setFormValues((current) => ({
...current,
customerLevel,
serviceMode,
}));
setSubmitError(null);
setSubmissionSuccess(null);
};
const handleServiceModeChange = (serviceMode: ServiceMode) => {
if (serviceMode !== allowedServiceMode) {
return;
}
setFormValues((current) => ({
...current,
serviceMode,
}));
setSubmitError(null);
};
const updateSelection = (
field: "modelId" | "sceneId" | "garmentId",
value: string,
) => {
setFormValues((current) => ({
...current,
[field]: value,
}));
setSubmitError(null);
setSubmissionSuccess(null);
};
const handleSubmit = async () => {
const selectedModel = models.find((item) => item.id === formValues.modelId);
const selectedScene = scenes.find((item) => item.id === formValues.sceneId);
const selectedGarment = garments.find(
(item) => item.id === formValues.garmentId,
);
if (!selectedModel || !selectedScene || !selectedGarment) {
setSubmitError("请先完成模特、场景和服装资源选择。");
return;
}
setIsSubmitting(true);
setSubmitError(null);
setSubmissionSuccess(null);
try {
const response = await fetch("/api/orders", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
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,
}),
});
const payload = (await response.json()) as CreateOrderResponse;
if (!response.ok) {
setSubmitError(payload.message ?? "提单失败,请稍后重试。");
return;
}
if (!payload.data?.orderId) {
setSubmitError("提单成功但未返回订单 ID。");
return;
}
setSubmissionSuccess({
orderId: payload.data.orderId,
workflowId: payload.data.workflowId ?? null,
});
} catch {
setSubmitError("网络异常,请稍后重试。");
} finally {
setIsSubmitting(false);
}
};
return (
<section className="space-y-8">
<PageHeader
eyebrow="Order creation workspace"
title="提单工作台"
description="围绕当前后端能力构建真实订单创建页。资源选择通过 BFF 拉取 mock 数据,提单动作则直接提交到 /api/orders。"
meta="只负责创建订单"
/>
{resourceError ? (
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
{resourceError}
</div>
) : null}
<CreateOrderForm
allowedServiceMode={allowedServiceMode}
garments={garments}
isLoadingResources={isLoadingResources}
isSubmitting={isSubmitting}
models={models}
scenes={scenes}
submissionSuccess={submissionSuccess}
submitError={submitError}
value={formValues}
onCustomerLevelChange={handleCustomerLevelChange}
onGarmentChange={(value) => updateSelection("garmentId", value)}
onModelChange={(value) => updateSelection("modelId", value)}
onSceneChange={(value) => updateSelection("sceneId", value)}
onServiceModeChange={handleServiceModeChange}
onSubmit={() => {
void handleSubmit();
}}
/>
</section>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge";
import type { ReviewDecision } from "@/lib/types/backend";
import type {
AssetViewModel,
OrderDetailVM,
ReviewSubmissionVM,
} from "@/lib/types/view-models";
type ReviewActionPanelProps = {
isSubmitting: boolean;
order: OrderDetailVM | null;
selectedAsset: AssetViewModel | null;
submissionError: string | null;
submissionResult: ReviewSubmissionVM | null;
onSubmit: (decision: ReviewDecision, comment: string) => void;
};
type ReviewActionDefinition = {
decision: ReviewDecision;
label: string;
variant: "primary" | "secondary" | "danger";
};
const ACTIONS: ReviewActionDefinition[] = [
{ decision: "approve", label: "审核通过", variant: "primary" },
{ decision: "rerun_scene", label: "重跑 Scene", variant: "secondary" },
{ decision: "rerun_face", label: "重跑 Face", variant: "secondary" },
{ decision: "rerun_fusion", label: "重跑 Fusion", variant: "secondary" },
{ decision: "reject", label: "驳回订单", variant: "danger" },
];
export function ReviewActionPanel({
isSubmitting,
order,
selectedAsset,
submissionError,
submissionResult,
onSubmit,
}: ReviewActionPanelProps) {
const [comment, setComment] = useState("");
return (
<Card className="h-full">
<CardHeader className="gap-4">
<div className="space-y-2">
<CardEyebrow>Review action</CardEyebrow>
<div className="space-y-1">
<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)]">
<p className="text-sm font-semibold text-[var(--ink-strong)]">
#{order.orderId}
</p>
<p className="mt-1">
{selectedAsset ? `${selectedAsset.label} (#${selectedAsset.id})` : "未选择"}
</p>
</div>
) : null}
</CardHeader>
<CardContent className="space-y-5">
<label className="block space-y-2">
<span className="text-sm font-medium text-[var(--ink-strong)]"></span>
<textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
rows={5}
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)]"
/>
</label>
{submissionError ? (
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 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="flex flex-wrap items-center gap-3">
<span></span>
<StatusBadge variant="reviewDecision" status={submissionResult.decision} />
<span></span>
</div>
</div>
) : null}
<div className="grid gap-3">
{ACTIONS.map((action) => (
<Button
key={action.decision}
variant={action.variant}
size="lg"
disabled={!order || isSubmitting}
onClick={() => onSubmit(action.decision, comment)}
>
{action.label}
</Button>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,181 @@
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { StatusBadge } from "@/components/ui/status-badge";
import type { AssetViewModel, OrderDetailVM } from "@/lib/types/view-models";
type ReviewImagePanelProps = {
error: string | null;
isLoading: boolean;
order: OrderDetailVM | null;
selectedAssetId: number | null;
onSelectAsset: (assetId: number) => void;
};
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
function collectAssets(order: OrderDetailVM): AssetViewModel[] {
const uniqueAssets = new Map<number, AssetViewModel>();
if (order.finalAsset) {
uniqueAssets.set(order.finalAsset.id, order.finalAsset);
}
for (const asset of order.assets) {
uniqueAssets.set(asset.id, asset);
}
return Array.from(uniqueAssets.values());
}
export function ReviewImagePanel({
error,
isLoading,
order,
selectedAssetId,
onSelectAsset,
}: ReviewImagePanelProps) {
if (isLoading) {
return (
<Card className="h-full">
<CardContent className="px-6 py-8 text-sm text-[var(--ink-muted)]">
</CardContent>
</Card>
);
}
if (error) {
return (
<Card className="h-full">
<CardContent className="px-6 py-8">
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
{error}
</div>
</CardContent>
</Card>
);
}
if (!order) {
return (
<Card className="h-full">
<CardContent className="px-6 py-8">
<EmptyState
eyebrow="No active order"
title="选择一个待审核订单"
description="左侧队列选中订单后,这里会展示当前审核所需的结果图和过程资产。"
/>
</CardContent>
</Card>
);
}
const assets = collectAssets(order);
const selectedAsset =
assets.find((asset) => asset.id === selectedAssetId) ??
order.finalAsset ??
assets[0] ??
null;
const emptyStateTitle =
order.finalAssetState.kind === "business-empty"
? order.finalAssetState.title
: "暂无可用预览";
const emptyStateDescription =
order.finalAssetState.kind === "business-empty"
? order.finalAssetState.description
: "当前订单还没有能用于审核的结果图或过程资产。";
return (
<Card className="h-full">
<CardHeader className="gap-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<CardEyebrow>Review target</CardEyebrow>
<div className="space-y-1">
<CardTitle> #{order.orderId}</CardTitle>
<CardDescription>
{order.serviceMode} {order.currentStepLabel}
</CardDescription>
</div>
</div>
<div className="flex flex-wrap gap-2">
<StatusBadge status={order.status} />
<StatusBadge variant="workflowStep" status={order.currentStep} />
</div>
</div>
</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="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"}
</p>
<h3 className="text-xl font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
{selectedAsset.label}
</h3>
<p className="mx-auto max-w-lg text-sm leading-7 text-[var(--ink-muted)]">
URI Task 7
</p>
<code className="inline-flex rounded-full bg-[rgba(74,64,53,0.08)] px-4 py-2 text-xs text-[var(--ink-muted)]">
{selectedAsset.uri}
</code>
</div>
</div>
</div>
) : (
<EmptyState
eyebrow="Asset empty"
title={emptyStateTitle}
description={emptyStateDescription}
/>
)}
{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]">
mock
</div>
) : null}
{assets.length ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{assets.map((asset) => {
const isSelected = asset.id === selectedAsset?.id;
return (
<button
key={asset.id}
type="button"
onClick={() => onSelectAsset(asset.id)}
className={joinClasses(
"rounded-[24px] border px-4 py-4 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)]",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-[var(--ink-strong)]">
{asset.label}
</p>
<p className="text-xs text-[var(--ink-muted)]">{asset.stepLabel}</p>
</div>
{asset.isMock ? (
<span className="rounded-full bg-[rgba(145,104,46,0.12)] px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[#7a5323]">
Mock
</span>
) : null}
</div>
</button>
);
})}
</div>
) : null}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,112 @@
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 type { ReviewQueueVM } from "@/lib/types/view-models";
type ReviewQueueProps = {
error: string | null;
isLoading: boolean;
queue: ReviewQueueVM | null;
};
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
function formatTimestamp(timestamp: string) {
return timestamp.replace("T", " ").replace("Z", " UTC");
}
export function ReviewQueue({
error,
isLoading,
queue,
}: ReviewQueueProps) {
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>
) : null}
{!isLoading && error ? (
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
{error}
</div>
) : null}
{!isLoading && !error && queue?.state.kind === "business-empty" ? (
<EmptyState
eyebrow="Queue empty"
title={queue.state.title}
description={queue.state.description}
/>
) : null}
{!isLoading && !error && queue?.items.length ? (
<div className="space-y-3">
{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>
<div className="space-y-1">
<h3 className="text-base font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
#{item.orderId}
</h3>
<p className="text-sm text-[var(--ink-muted)]">
{item.workflowId}
{item.workflowType ? ` / ${item.workflowType}` : ""}
</p>
</div>
</div>
<StatusBadge status={item.status} />
</div>
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs text-[var(--ink-muted)]">
<StatusBadge variant="workflowStep" status={item.currentStep} />
<span>{item.currentStepLabel}</span>
<span>{formatTimestamp(item.createdAt)}</span>
{item.hasMockAssets ? (
<span className="rounded-full bg-[rgba(145,104,46,0.12)] px-2.5 py-1 font-[var(--font-mono)] uppercase tracking-[0.18em] text-[#7a5323]">
Mock
</span>
) : null}
{item.failureCount > 0 ? (
<span className="rounded-full bg-[rgba(140,74,67,0.12)] px-2.5 py-1 font-[var(--font-mono)] uppercase tracking-[0.18em] text-[#7f3f38]">
{item.failureCount}
</span>
) : null}
{item.pendingManualConfirm ? (
<span className="rounded-full bg-[rgba(88,106,56,0.12)] px-2.5 py-1 font-[var(--font-mono)] uppercase tracking-[0.18em] text-[#50633b]">
</span>
) : null}
</div>
</Link>
))}
</div>
) : null}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardEyebrow,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type {
AssetViewModel,
OrderDetailVM,
ReviewSubmissionVM,
RevisionRegistrationVM,
WorkflowDetailVM,
} from "@/lib/types/view-models";
type ReviewRevisionPanelProps = {
isSubmitting: boolean;
order: OrderDetailVM | null;
selectedAsset: AssetViewModel | null;
workflow: WorkflowDetailVM | null;
revisionError: string | null;
revisionResult: RevisionRegistrationVM | null;
confirmResult: ReviewSubmissionVM | null;
onRegisterRevision: (payload: { uploadedUri: string; comment: string }) => void;
onConfirmRevision: (comment: string) => void;
};
export function ReviewRevisionPanel({
isSubmitting,
order,
selectedAsset,
workflow,
revisionError,
revisionResult,
confirmResult,
onRegisterRevision,
onConfirmRevision,
}: ReviewRevisionPanelProps) {
const [uploadedUri, setUploadedUri] = useState("");
const [comment, setComment] = useState("");
const pendingManualConfirm =
order?.pendingManualConfirm || workflow?.pendingManualConfirm || false;
return (
<Card className="h-full">
<CardHeader className="gap-4">
<div className="space-y-2">
<CardEyebrow>Manual revision</CardEyebrow>
<div className="space-y-1">
<CardTitle>稿</CardTitle>
<CardDescription>
线稿稿 approve
signal 线
</CardDescription>
</div>
</div>
{selectedAsset ? (
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4 text-sm text-[var(--ink-muted)]">
<p className="text-sm font-semibold text-[var(--ink-strong)]">
{selectedAsset.label} (#{selectedAsset.id})
</p>
<p className="mt-1">
{pendingManualConfirm
? "已存在待确认修订稿,可直接确认继续流水线。"
: "登记新的人工修订稿会把当前审核任务切到待确认状态。"}
</p>
</div>
) : null}
</CardHeader>
<CardContent className="space-y-5">
<label className="block space-y-2">
<span className="text-sm font-medium text-[var(--ink-strong)]">
稿 URI
</span>
<input
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)]"
/>
</label>
<label className="block space-y-2">
<span className="text-sm font-medium text-[var(--ink-strong)]">
</span>
<textarea
value={comment}
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)]"
/>
</label>
{revisionError ? (
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 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]">
稿 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>
) : null}
<div className="grid gap-3">
<Button
variant="secondary"
size="lg"
disabled={!order || !selectedAsset || isSubmitting}
onClick={() => onRegisterRevision({ uploadedUri, comment })}
>
稿
</Button>
{pendingManualConfirm ? (
<Button
variant="primary"
size="lg"
disabled={!order || isSubmitting}
onClick={() => onConfirmRevision(comment)}
>
线
</Button>
) : null}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,125 @@
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { StatusBadge } from "@/components/ui/status-badge";
import type { WorkflowDetailVM } from "@/lib/types/view-models";
type ReviewWorkflowSummaryProps = {
error: string | null;
isLoading: boolean;
workflow: WorkflowDetailVM | null;
};
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
export function ReviewWorkflowSummary({
error,
isLoading,
workflow,
}: ReviewWorkflowSummaryProps) {
return (
<Card className="h-full">
<CardHeader>
<div className="space-y-2">
<CardEyebrow>Workflow summary</CardEyebrow>
<div className="space-y-1">
<CardTitle></CardTitle>
<CardDescription>
线
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="rounded-[24px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
</div>
) : null}
{!isLoading && error ? (
<div className="rounded-[24px] border border-[#b88472] bg-[#f8ece5] px-5 py-4 text-sm text-[#7f4b3b]">
{error}
</div>
) : null}
{!isLoading && !error && !workflow ? (
<EmptyState
eyebrow="No workflow"
title="暂无流程上下文"
description="选中待审核订单后,这里会给出当前流程卡点、失败次数和 mock 资产提示。"
/>
) : null}
{!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">
<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={workflow.currentStep} />
<StatusBadge status={workflow.status} />
</div>
</div>
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
Failure count
</p>
<p className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]">
{workflow.failureCount}
</p>
</div>
</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]">
mock
</div>
) : null}
<div className="space-y-3">
{workflow.steps.map((step) => (
<div
key={step.id}
className={joinClasses(
"rounded-[24px] border px-4 py-4",
step.isCurrent
? "border-[var(--accent-primary)] bg-[rgba(110,127,82,0.09)]"
: "border-[var(--border-soft)] bg-[var(--surface-muted)]",
)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<p className="text-sm font-semibold text-[var(--ink-strong)]">
{step.label}
</p>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge variant="stepStatus" status={step.status} />
{step.containsMockAssets ? (
<span className="rounded-full bg-[rgba(145,104,46,0.12)] px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[#7a5323]">
Mock assets
</span>
) : null}
</div>
</div>
{step.isCurrent ? (
<span className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--accent-primary-strong)]">
</span>
) : null}
</div>
{step.errorMessage ? (
<p className="mt-3 text-sm leading-6 text-[#7f3f38]">{step.errorMessage}</p>
) : null}
</div>
))}
</div>
</>
) : null}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,436 @@
"use client";
import Link from "next/link";
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 { StatusBadge } from "@/components/ui/status-badge";
import { ReviewActionPanel } from "@/features/reviews/components/review-action-panel";
import { ReviewImagePanel } from "@/features/reviews/components/review-image-panel";
import { ReviewRevisionPanel } from "@/features/reviews/components/review-revision-panel";
import { ReviewWorkflowSummary } from "@/features/reviews/components/review-workflow-summary";
import type { ReviewDecision } from "@/lib/types/backend";
import type {
AssetViewModel,
OrderDetailVM,
ReviewSubmissionVM,
RevisionRegistrationVM,
WorkflowDetailVM,
} from "@/lib/types/view-models";
type ApiEnvelope<T> = {
data?: T;
message?: string;
};
type ReviewWorkbenchDetailScreenProps = {
orderId: number;
};
const REVIEWER_ID = 1;
function isRerunDecision(decision: ReviewDecision) {
return (
decision === "rerun_scene" ||
decision === "rerun_face" ||
decision === "rerun_fusion"
);
}
function getPreferredAsset(order: OrderDetailVM) {
return order.finalAsset ?? order.assets[0] ?? null;
}
async function parseEnvelope<T>(response: Response): Promise<ApiEnvelope<T>> {
return (await response.json()) as ApiEnvelope<T>;
}
function formatTimestamp(timestamp: string) {
return timestamp.replace("T", " ").replace("Z", " UTC");
}
export function ReviewWorkbenchDetailScreen({
orderId,
}: ReviewWorkbenchDetailScreenProps) {
const router = useRouter();
const [orderDetail, setOrderDetail] = useState<OrderDetailVM | null>(null);
const [workflowDetail, setWorkflowDetail] = useState<WorkflowDetailVM | null>(
null,
);
const [selectedAssetId, setSelectedAssetId] = useState<number | null>(null);
const [contextError, setContextError] = useState<string | null>(null);
const [submissionError, setSubmissionError] = useState<string | null>(null);
const [submissionResult, setSubmissionResult] =
useState<ReviewSubmissionVM | null>(null);
const [revisionError, setRevisionError] = useState<string | null>(null);
const [revisionResult, setRevisionResult] =
useState<RevisionRegistrationVM | null>(null);
const [isLoadingContext, setIsLoadingContext] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
let active = true;
async function loadReviewContext() {
setIsLoadingContext(true);
try {
const [orderResponse, workflowResponse] = await Promise.all([
fetch(`/api/orders/${orderId}`),
fetch(`/api/workflows/${orderId}`),
]);
const [orderPayload, workflowPayload] = await Promise.all([
parseEnvelope<OrderDetailVM>(orderResponse),
parseEnvelope<WorkflowDetailVM>(workflowResponse),
]);
const nextOrder = orderPayload.data;
const nextWorkflow = workflowPayload.data;
if (
!orderResponse.ok ||
!workflowResponse.ok ||
!nextOrder ||
!nextWorkflow
) {
throw new Error("CONTEXT_LOAD_FAILED");
}
if (!active) {
return;
}
const preferredAsset = getPreferredAsset(nextOrder);
setOrderDetail(nextOrder);
setWorkflowDetail(nextWorkflow);
setContextError(null);
setSelectedAssetId((current) => {
if (
current &&
[
...(nextOrder.finalAsset ? [nextOrder.finalAsset] : []),
...nextOrder.assets,
].some((asset) => asset.id === current)
) {
return current;
}
return preferredAsset?.id ?? null;
});
} catch {
if (!active) {
return;
}
setOrderDetail(null);
setWorkflowDetail(null);
setSelectedAssetId(null);
setContextError("订单详情或流程摘要加载失败,请稍后重试。");
} finally {
if (active) {
setIsLoadingContext(false);
}
}
}
void loadReviewContext();
return () => {
active = false;
};
}, [orderId]);
const selectedAsset: AssetViewModel | null =
(orderDetail?.finalAsset?.id === selectedAssetId
? orderDetail.finalAsset
: orderDetail?.assets.find((asset) => asset.id === selectedAssetId)) ??
orderDetail?.finalAsset ??
orderDetail?.assets[0] ??
null;
const handleSubmit = async (decision: ReviewDecision, comment: string) => {
if (!orderDetail) {
return;
}
if (isRerunDecision(decision) && !comment.trim()) {
setSubmissionError("请填写审核备注");
setSubmissionResult(null);
return;
}
setIsSubmitting(true);
setSubmissionError(null);
setSubmissionResult(null);
try {
const response = await fetch(`/api/reviews/${orderDetail.orderId}/submit`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
decision,
reviewer_id: REVIEWER_ID,
selected_asset_id: selectedAsset?.id ?? null,
comment: comment.trim() ? comment : null,
}),
});
const payload = await parseEnvelope<ReviewSubmissionVM>(response);
if (!response.ok || !payload.data) {
setSubmissionError(payload.message ?? "审核动作提交失败,请稍后重试。");
return;
}
setSubmissionResult(payload.data);
router.push("/reviews/workbench");
} catch {
setSubmissionError("审核动作提交失败,请检查网络后重试。");
} finally {
setIsSubmitting(false);
}
};
const handleRegisterRevision = async (payload: {
uploadedUri: string;
comment: string;
}) => {
if (!orderDetail || !selectedAsset) {
return;
}
if (!payload.uploadedUri.trim()) {
setRevisionError("请填写修订稿 URI");
setRevisionResult(null);
return;
}
setIsSubmitting(true);
setRevisionError(null);
setRevisionResult(null);
setSubmissionResult(null);
try {
const response = await fetch(`/api/orders/${orderDetail.orderId}/revisions`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
parent_asset_id: selectedAsset.id,
uploaded_uri: payload.uploadedUri.trim(),
reviewer_id: REVIEWER_ID,
comment: payload.comment.trim() ? payload.comment : null,
}),
});
const revisionPayload = await parseEnvelope<RevisionRegistrationVM>(response);
if (!response.ok || !revisionPayload.data) {
setRevisionError(revisionPayload.message ?? "人工修订稿登记失败,请稍后重试。");
return;
}
const nextRevision = revisionPayload.data;
setRevisionResult(nextRevision);
setOrderDetail((current) =>
current
? {
...current,
currentRevisionAssetId: nextRevision.assetId,
currentRevisionVersion: nextRevision.versionNo,
latestRevisionAssetId: nextRevision.latestRevisionAssetId,
latestRevisionVersion: nextRevision.versionNo,
revisionCount: nextRevision.revisionCount,
reviewTaskStatus: nextRevision.reviewTaskStatus,
pendingManualConfirm: true,
}
: current,
);
setWorkflowDetail((current) =>
current
? {
...current,
currentRevisionAssetId: nextRevision.assetId,
currentRevisionVersion: nextRevision.versionNo,
latestRevisionAssetId: nextRevision.latestRevisionAssetId,
latestRevisionVersion: nextRevision.versionNo,
revisionCount: nextRevision.revisionCount,
reviewTaskStatus: nextRevision.reviewTaskStatus,
pendingManualConfirm: true,
}
: current,
);
} catch {
setRevisionError("人工修订稿登记失败,请检查网络后重试。");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmRevision = async (comment: string) => {
if (!orderDetail) {
return;
}
setIsSubmitting(true);
setRevisionError(null);
setSubmissionError(null);
setSubmissionResult(null);
try {
const response = await fetch(
`/api/reviews/${orderDetail.orderId}/confirm-revision`,
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
reviewer_id: REVIEWER_ID,
comment: comment.trim() ? comment : null,
}),
},
);
const payload = await parseEnvelope<ReviewSubmissionVM>(response);
if (!response.ok || !payload.data) {
setRevisionError(payload.message ?? "确认修订失败,请稍后重试。");
return;
}
setSubmissionResult(payload.data);
router.push("/reviews/workbench");
} catch {
setRevisionError("确认修订失败,请检查网络后重试。");
} finally {
setIsSubmitting(false);
}
};
if (isLoadingContext) {
return (
<section className="space-y-4">
<div className="rounded-[28px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-6 py-10 text-sm text-[var(--ink-muted)]">
</div>
</section>
);
}
if (contextError || !orderDetail || !workflowDetail) {
return (
<EmptyState
eyebrow="Review detail error"
title="审核详情暂时不可用"
description={contextError ?? "当前审核详情还无法展示,请稍后重试。"}
actions={
<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)]"
>
</Link>
}
/>
);
}
return (
<section className="space-y-8">
<PageHeader
eyebrow="Review detail"
title={`订单 #${orderDetail.orderId}`}
description="审核详情页只处理单个订单,列表筛选和切单行为统一留在审核工作台首页。"
meta={`更新于 ${formatTimestamp(orderDetail.updatedAt)}`}
actions={
<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)]"
>
</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]">
<ReviewImagePanel
error={contextError}
isLoading={isLoadingContext}
order={orderDetail}
selectedAssetId={selectedAssetId}
onSelectAsset={setSelectedAssetId}
/>
<div className="grid gap-6">
<ReviewRevisionPanel
isSubmitting={isSubmitting}
order={orderDetail}
selectedAsset={selectedAsset}
workflow={workflowDetail}
revisionError={revisionError}
revisionResult={revisionResult}
confirmResult={submissionResult}
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}
workflow={workflowDetail}
/>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import { useEffect, useState } from "react";
import { PageHeader } from "@/components/ui/page-header";
import { ReviewQueue } from "@/features/reviews/components/review-queue";
import type { ReviewQueueVM } from "@/lib/types/view-models";
type ApiEnvelope<T> = {
data?: T;
message?: string;
};
async function parseEnvelope<T>(response: Response): Promise<ApiEnvelope<T>> {
return (await response.json()) as ApiEnvelope<T>;
}
export function ReviewWorkbenchListScreen() {
const [queue, setQueue] = useState<ReviewQueueVM | null>(null);
const [queueError, setQueueError] = useState<string | null>(null);
const [isLoadingQueue, setIsLoadingQueue] = useState(true);
useEffect(() => {
let active = true;
async function loadQueue() {
setIsLoadingQueue(true);
try {
const response = await fetch("/api/reviews/pending");
const payload = await parseEnvelope<ReviewQueueVM>(response);
if (!response.ok || !payload.data) {
throw new Error(payload.message ?? "QUEUE_LOAD_FAILED");
}
if (!active) {
return;
}
setQueue(payload.data);
setQueueError(null);
} catch {
if (!active) {
return;
}
setQueue(null);
setQueueError("待审核队列加载失败,请稍后重试。");
} finally {
if (active) {
setIsLoadingQueue(false);
}
}
}
void loadQueue();
return () => {
active = false;
};
}, []);
return (
<section className="space-y-8">
<PageHeader
eyebrow="Human review queue"
title="审核工作台"
description="列表页只负责展示待审核队列,点击具体订单后再进入独立详情页处理资产、动作和流程摘要。"
meta="先看列表,再进详情"
/>
<ReviewQueue
error={queueError}
isLoading={isLoadingQueue}
queue={queue}
/>
</section>
);
}

View File

@@ -0,0 +1,43 @@
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
export function SettingsPlaceholder() {
return (
<section className="space-y-8">
<PageHeader
eyebrow="Settings"
title="系统设置"
description="首版只保留设置的信息架构,不假装已经接入用户体系、角色权限或系统级配置接口。"
meta="正式占位模块"
/>
<div className="grid gap-6 xl:grid-cols-2">
<Card>
<CardHeader>
<div className="space-y-2">
<CardEyebrow>Environment notes</CardEyebrow>
<div className="space-y-1">
<CardTitle></CardTitle>
<CardDescription>
`BACKEND_BASE_URL`
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 text-sm leading-7 text-[var(--ink-muted)]">
<p>1. Next.js `/api/*`</p>
<p>2. FastAPI </p>
<p>3. </p>
</CardContent>
</Card>
<EmptyState
eyebrow="Future settings"
title="更多设置能力待接入"
description="比如账号、审计、资源策略和自动化规则。目前先保留正式页面入口,避免后续重做导航和路由。"
/>
</div>
</section>
);
}

View File

@@ -0,0 +1,60 @@
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge";
import type { WorkflowDetailVM } from "@/lib/types/view-models";
type WorkflowStatusCardProps = {
viewModel: WorkflowDetailVM;
};
export function WorkflowStatusCard({ viewModel }: WorkflowStatusCardProps) {
return (
<Card>
<CardHeader>
<div className="space-y-2">
<CardEyebrow>Workflow status</CardEyebrow>
<div className="space-y-1">
<CardTitle></CardTitle>
<CardDescription>
Temporal
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
Workflow type
</p>
<p className="mt-3 text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
{viewModel.workflowType}
</p>
</div>
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] 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={viewModel.currentStep} />
<span className="text-sm text-[var(--ink-muted)]">{viewModel.currentStepLabel}</span>
</div>
</div>
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] 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={viewModel.status} />
</div>
</div>
<div className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4">
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.2em] text-[var(--ink-faint)]">
Failure focus
</p>
<p className="mt-3 text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
{viewModel.failureCount}
</p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,111 @@
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { StatusBadge } from "@/components/ui/status-badge";
import type { WorkflowDetailVM } from "@/lib/types/view-models";
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
type WorkflowTimelineProps = {
viewModel: WorkflowDetailVM;
};
export function WorkflowTimeline({ viewModel }: WorkflowTimelineProps) {
const timelineTitle =
viewModel.stepTimelineState.kind === "business-empty"
? viewModel.stepTimelineState.title
: "暂无流程记录";
const timelineDescription =
viewModel.stepTimelineState.kind === "business-empty"
? viewModel.stepTimelineState.description
: "当前工作流还没有可展示的步骤执行记录。";
return (
<Card>
<CardHeader>
<div className="space-y-2">
<CardEyebrow>Workflow timeline</CardEyebrow>
<div className="space-y-1">
<CardTitle>线</CardTitle>
<CardDescription>
mock 便
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{viewModel.hasMockAssets ? (
<div className="rounded-[24px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-5 py-4 text-sm text-[#7a5323]">
mock
</div>
) : null}
{viewModel.steps.length ? (
<ol className="space-y-3">
{viewModel.steps.map((step) => (
<li
key={step.id}
className={joinClasses(
"rounded-[24px] border px-4 py-4",
step.isFailed
? "border-[rgba(140,74,67,0.2)] bg-[rgba(140,74,67,0.08)]"
: step.isCurrent
? "border-[var(--accent-primary)] bg-[rgba(110,127,82,0.09)]"
: "border-[var(--border-soft)] bg-[var(--surface-muted)]",
)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<p className="text-sm font-semibold text-[var(--ink-strong)]">
{step.label}
</p>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge variant="stepStatus" status={step.status} />
{step.containsMockAssets ? (
<span className="rounded-full bg-[rgba(145,104,46,0.12)] px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[#7a5323]">
Mock assets
</span>
) : null}
{step.isCurrent ? (
<span className="rounded-full bg-[rgba(110,127,82,0.14)] px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--accent-primary-strong)]">
</span>
) : null}
</div>
</div>
<StatusBadge variant="workflowStep" status={step.name} />
</div>
{step.errorMessage ? (
<p className="mt-4 rounded-[18px] bg-[rgba(255,255,255,0.5)] px-3 py-3 text-sm leading-6 text-[#7f3f38]">
{step.errorMessage}
</p>
) : null}
{step.mockAssetUris.length ? (
<div className="mt-4 space-y-2">
{step.mockAssetUris.map((uri) => (
<code
key={uri}
className="block rounded-[18px] bg-[rgba(74,64,53,0.08)] px-3 py-3 text-xs leading-6 text-[var(--ink-muted)]"
>
{uri}
</code>
))}
</div>
) : null}
</li>
))}
</ol>
) : (
<EmptyState
eyebrow="Timeline empty"
title={timelineTitle}
description={timelineDescription}
/>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { useEffect, useState } from "react";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { WorkflowStatusCard } from "@/features/workflows/components/workflow-status-card";
import { WorkflowTimeline } from "@/features/workflows/components/workflow-timeline";
import type { WorkflowDetailVM } from "@/lib/types/view-models";
type ApiEnvelope<T> = {
data?: T;
message?: string;
};
type WorkflowDetailProps = {
viewModel: WorkflowDetailVM;
};
type WorkflowDetailScreenProps = {
orderId: number;
};
function formatTimestamp(timestamp: string) {
return timestamp.replace("T", " ").replace("Z", " UTC");
}
async function parseEnvelope<T>(response: Response): Promise<ApiEnvelope<T>> {
return (await response.json()) as ApiEnvelope<T>;
}
export function WorkflowDetail({ viewModel }: WorkflowDetailProps) {
return (
<section className="space-y-8">
<PageHeader
eyebrow="Workflow detail"
title={`流程 ${viewModel.workflowId}`}
description="流程详情页专门追踪执行链路、失败步骤和 mock 资产,不承接审核动作。"
meta={`更新于 ${formatTimestamp(viewModel.updatedAt)}`}
/>
<WorkflowStatusCard viewModel={viewModel} />
<WorkflowTimeline viewModel={viewModel} />
</section>
);
}
export function WorkflowDetailScreen({
orderId,
}: WorkflowDetailScreenProps) {
const [viewModel, setViewModel] = useState<WorkflowDetailVM | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let active = true;
async function loadWorkflowDetail() {
setIsLoading(true);
try {
const response = await fetch(`/api/workflows/${orderId}`);
const payload = await parseEnvelope<WorkflowDetailVM>(response);
if (!response.ok || !payload.data) {
throw new Error(payload.message ?? "WORKFLOW_DETAIL_LOAD_FAILED");
}
if (!active) {
return;
}
setViewModel(payload.data);
setError(null);
} catch {
if (!active) {
return;
}
setViewModel(null);
setError("流程详情加载失败,请稍后重试。");
} finally {
if (active) {
setIsLoading(false);
}
}
}
void loadWorkflowDetail();
return () => {
active = false;
};
}, [orderId]);
if (isLoading) {
return (
<section className="space-y-4">
<div className="rounded-[28px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-6 py-10 text-sm text-[var(--ink-muted)]">
</div>
</section>
);
}
if (error || !viewModel) {
return (
<EmptyState
eyebrow="Workflow detail error"
title="流程详情暂时不可用"
description={error ?? "当前流程详情还无法展示,请稍后重试。"}
/>
);
}
return <WorkflowDetail viewModel={viewModel} />;
}

View File

@@ -0,0 +1,350 @@
"use client";
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 { 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";
type FilterStatus = OrderStatus | "all";
type PaginationData = {
page: number;
limit: number;
total: number;
totalPages: number;
};
type WorkflowLookupProps = {
currentPage?: number;
isLoading?: boolean;
items: WorkflowLookupItemVM[];
message?: string;
onOpenWorkflow?: (orderId: string) => void;
onPageChange?: (page: number) => void;
onQuerySubmit?: (query: string) => void;
onStatusChange?: (status: FilterStatus) => void;
selectedQuery?: string;
selectedStatus?: FilterStatus;
totalPages?: number;
};
type WorkflowLookupEnvelope = {
data?: {
items?: WorkflowLookupItemVM[];
limit?: number;
page?: number;
total?: number;
totalPages?: number;
};
message?: string;
};
const DEFAULT_MESSAGE = "流程追踪首页当前显示真实后端最近流程。";
const DEFAULT_PAGINATION: PaginationData = {
page: 1,
limit: 8,
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,
items,
message = DEFAULT_MESSAGE,
onOpenWorkflow,
onPageChange,
onQuerySubmit,
onStatusChange,
selectedQuery = "",
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">
<PageHeader
eyebrow="Workflow lookup"
title="流程追踪"
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>
<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)]"
/>
<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)]"
/>
<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>
);
}
export function WorkflowLookupScreen() {
const router = useRouter();
const [items, setItems] = useState<WorkflowLookupItemVM[]>([]);
const [message, setMessage] = useState(DEFAULT_MESSAGE);
const [isLoading, setIsLoading] = useState(true);
const [selectedQuery, setSelectedQuery] = useState("");
const [selectedStatus, setSelectedStatus] = useState<FilterStatus>("all");
const [pagination, setPagination] = useState<PaginationData>(DEFAULT_PAGINATION);
useEffect(() => {
let active = true;
async function loadWorkflowIndex() {
setIsLoading(true);
try {
const params = new URLSearchParams({
page: String(pagination.page),
limit: String(pagination.limit),
});
if (selectedStatus !== "all") {
params.set("status", selectedStatus);
}
if (selectedQuery.length > 0) {
params.set("query", selectedQuery);
}
const response = await fetch(`/api/dashboard/workflow-lookup?${params.toString()}`);
const payload = (await response.json()) as WorkflowLookupEnvelope;
if (!active) {
return;
}
setItems(payload.data?.items ?? []);
setPagination((current) => ({
page: payload.data?.page ?? current.page,
limit: payload.data?.limit ?? current.limit,
total: payload.data?.total ?? current.total,
totalPages: payload.data?.totalPages ?? current.totalPages,
}));
setMessage(payload.message ?? DEFAULT_MESSAGE);
} catch {
if (!active) {
return;
}
setItems([]);
setPagination((current) => ({
...current,
total: 0,
totalPages: 0,
}));
setMessage("流程索引加载失败,请稍后重试。");
} finally {
if (active) {
setIsLoading(false);
}
}
}
void loadWorkflowIndex();
return () => {
active = false;
};
}, [pagination.limit, pagination.page, selectedQuery, selectedStatus]);
return (
<WorkflowLookup
currentPage={pagination.page}
isLoading={isLoading}
items={items}
message={message}
onPageChange={(page) =>
setPagination((current) => ({
...current,
page,
}))
}
onQuerySubmit={(query) => {
setSelectedQuery(query);
setPagination((current) => ({
...current,
page: 1,
}));
}}
onStatusChange={(status) => {
setSelectedStatus(status);
setPagination((current) => ({
...current,
page: 1,
}));
}}
onOpenWorkflow={(orderId) => router.push(`/workflows/${orderId}`)}
selectedQuery={selectedQuery}
selectedStatus={selectedStatus}
totalPages={pagination.totalPages}
/>
);
}