feat: bootstrap auto virtual tryon admin frontend
This commit is contained in:
35
src/features/auth/login-placeholder.tsx
Normal file
35
src/features/auth/login-placeholder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
src/features/libraries/library-page.tsx
Normal file
194
src/features/libraries/library-page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
201
src/features/orders/components/create-order-form.tsx
Normal file
201
src/features/orders/components/create-order-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/features/orders/components/order-assets-panel.tsx
Normal file
109
src/features/orders/components/order-assets-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/features/orders/components/order-detail-header.tsx
Normal file
80
src/features/orders/components/order-detail-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/features/orders/components/order-summary-card.tsx
Normal file
124
src/features/orders/components/order-summary-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/features/orders/components/order-workflow-card.tsx
Normal file
56
src/features/orders/components/order-workflow-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/features/orders/components/resource-picker-card.tsx
Normal file
96
src/features/orders/components/resource-picker-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/features/orders/order-detail.tsx
Normal file
107
src/features/orders/order-detail.tsx
Normal 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} />;
|
||||
}
|
||||
377
src/features/orders/orders-home.tsx
Normal file
377
src/features/orders/orders-home.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
104
src/features/orders/resource-picker-options.ts
Normal file
104
src/features/orders/resource-picker-options.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
263
src/features/orders/submit-workbench.tsx
Normal file
263
src/features/orders/submit-workbench.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
src/features/reviews/components/review-action-panel.tsx
Normal file
115
src/features/reviews/components/review-action-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
src/features/reviews/components/review-image-panel.tsx
Normal file
181
src/features/reviews/components/review-image-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/features/reviews/components/review-queue.tsx
Normal file
112
src/features/reviews/components/review-queue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
src/features/reviews/components/review-revision-panel.tsx
Normal file
144
src/features/reviews/components/review-revision-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
src/features/reviews/components/review-workflow-summary.tsx
Normal file
125
src/features/reviews/components/review-workflow-summary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
436
src/features/reviews/review-workbench-detail.tsx
Normal file
436
src/features/reviews/review-workbench-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/features/reviews/review-workbench-list.tsx
Normal file
80
src/features/reviews/review-workbench-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/features/settings/settings-placeholder.tsx
Normal file
43
src/features/settings/settings-placeholder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/features/workflows/components/workflow-status-card.tsx
Normal file
60
src/features/workflows/components/workflow-status-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/features/workflows/components/workflow-timeline.tsx
Normal file
111
src/features/workflows/components/workflow-timeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/features/workflows/workflow-detail.tsx
Normal file
116
src/features/workflows/workflow-detail.tsx
Normal 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} />;
|
||||
}
|
||||
350
src/features/workflows/workflow-lookup.tsx
Normal file
350
src/features/workflows/workflow-lookup.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user