feat: bootstrap auto virtual tryon admin frontend
This commit is contained in:
62
src/components/layout/dashboard-shell.tsx
Normal file
62
src/components/layout/dashboard-shell.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { primaryNavItems } from "@/components/layout/nav-config";
|
||||
|
||||
type DashboardShellProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function DashboardShell({ children }: DashboardShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-transparent px-4 py-5 text-[var(--ink)] md:h-screen md:overflow-hidden md:px-6 md:py-6">
|
||||
<div className="mx-auto grid max-w-7xl gap-4 rounded-[32px] border border-[var(--border-soft)] bg-[var(--bg-elevated)] p-3 shadow-[var(--shadow-shell)] backdrop-blur md:h-full md:grid-cols-[280px_minmax(0,1fr)] md:p-4">
|
||||
<aside
|
||||
aria-label="Dashboard rail"
|
||||
className="flex rounded-[28px] border border-[var(--shell-border)] bg-[var(--shell)] p-6 text-white md:h-full"
|
||||
>
|
||||
<div className="flex min-h-full w-full flex-col">
|
||||
<div>
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.3em] text-white/48">
|
||||
Auto Tryon Ops
|
||||
</p>
|
||||
<h1 className="mt-4 text-2xl font-semibold tracking-[-0.03em] text-white">
|
||||
运营控制台
|
||||
</h1>
|
||||
<p className="mt-3 max-w-[18rem] text-sm leading-6 text-white/66">
|
||||
保持订单、审核与流程追踪在同一套高密度暖色界面里完成。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="mt-8 space-y-1" aria-label="Primary Navigation">
|
||||
{primaryNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between rounded-[18px] border border-transparent px-3.5 py-3 text-sm text-white/80 transition hover:border-white/8 hover:bg-white/7 hover:text-white"
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto rounded-[24px] border border-white/8 bg-white/6 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-white/44">
|
||||
Shared shell
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-white/68">
|
||||
后续任务页面只负责业务模块,布局、节奏和基础视觉都从这里继承。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main
|
||||
aria-label="Dashboard content"
|
||||
className="overflow-hidden rounded-[28px] border border-[var(--border-soft)] bg-[var(--surface)] shadow-[inset_0_1px_0_rgba(255,255,255,0.32)] md:h-full md:min-h-0 md:overflow-y-auto"
|
||||
>
|
||||
<div className="px-5 py-6 md:px-8 md:py-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
src/components/layout/nav-config.ts
Normal file
13
src/components/layout/nav-config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type PrimaryNavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const primaryNavItems: PrimaryNavItem[] = [
|
||||
{ href: "/orders", label: "订单总览" },
|
||||
{ href: "/submit-workbench", label: "提单工作台" },
|
||||
{ href: "/reviews/workbench", label: "审核工作台" },
|
||||
{ href: "/workflows", label: "流程追踪" },
|
||||
{ href: "/libraries/models", label: "资源库" },
|
||||
{ href: "/settings", label: "系统设置" },
|
||||
];
|
||||
76
src/components/ui/button.tsx
Normal file
76
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type ButtonHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
leading?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
|
||||
const VARIANT_STYLES: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"border-transparent bg-[var(--accent-primary)] text-[var(--accent-ink)] shadow-[0_12px_30px_rgba(110,127,82,0.22)] hover:bg-[var(--accent-primary-strong)]",
|
||||
secondary:
|
||||
"border-[var(--border-strong)] bg-[var(--surface)] text-[var(--ink-strong)] hover:bg-[var(--surface-muted)]",
|
||||
ghost:
|
||||
"border-transparent bg-transparent text-[var(--ink-muted)] hover:bg-[var(--surface-muted)] hover:text-[var(--ink-strong)]",
|
||||
danger:
|
||||
"border-transparent bg-[#8c4a43] text-[#fff8f5] shadow-[0_12px_30px_rgba(140,74,67,0.16)] hover:bg-[#7a3d37]",
|
||||
};
|
||||
|
||||
const SIZE_STYLES: Record<ButtonSize, string> = {
|
||||
sm: "min-h-9 rounded-full px-3.5 text-sm",
|
||||
md: "min-h-11 rounded-full px-4 text-sm",
|
||||
lg: "min-h-12 rounded-full px-5 text-base",
|
||||
};
|
||||
|
||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
leading,
|
||||
size = "md",
|
||||
trailing,
|
||||
type = "button",
|
||||
variant = "primary",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
className={joinClasses(
|
||||
"inline-flex items-center justify-center gap-2 border font-medium tracking-[0.01em] transition duration-150",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
VARIANT_STYLES[variant],
|
||||
SIZE_STYLES[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{leading ? <span aria-hidden="true">{leading}</span> : null}
|
||||
{children}
|
||||
{trailing ? <span aria-hidden="true">{trailing}</span> : null}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
129
src/components/ui/card.tsx
Normal file
129
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type CardProps = HTMLAttributes<HTMLDivElement>;
|
||||
type CardSectionProps = HTMLAttributes<HTMLDivElement>;
|
||||
type CardTitleProps = HTMLAttributes<HTMLHeadingElement>;
|
||||
type CardDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={joinClasses(
|
||||
"rounded-[28px] border border-[var(--border-soft)] bg-[var(--surface)] shadow-[var(--shadow-card)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Card.displayName = "Card";
|
||||
|
||||
export const CardHeader = forwardRef<HTMLDivElement, CardSectionProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={joinClasses(
|
||||
"flex flex-col gap-2 border-b border-[var(--border-soft)] px-6 py-5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={joinClasses(
|
||||
"text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
export const CardDescription = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
CardDescriptionProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
className={joinClasses("text-sm leading-6 text-[var(--ink-muted)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
export const CardContent = forwardRef<HTMLDivElement, CardSectionProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={joinClasses("px-6 py-5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export const CardFooter = forwardRef<HTMLDivElement, CardSectionProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={joinClasses(
|
||||
"flex items-center justify-between gap-3 border-t border-[var(--border-soft)] px-6 py-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export function CardEyebrow({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLParagraphElement> & { children: ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={joinClasses(
|
||||
"font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-[var(--ink-faint)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
43
src/components/ui/empty-state.tsx
Normal file
43
src/components/ui/empty-state.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type EmptyStateProps = {
|
||||
actions?: ReactNode;
|
||||
description: string;
|
||||
eyebrow?: string;
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
};
|
||||
|
||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
actions,
|
||||
description,
|
||||
eyebrow = "No content",
|
||||
icon,
|
||||
title,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-6 py-10 text-center shadow-[inset_0_1px_0_rgba(255,255,255,0.4)]">
|
||||
{icon ? (
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--surface)] text-[var(--ink-muted)] shadow-[var(--shadow-card)]">
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.26em] text-[var(--ink-faint)]">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h3 className="mt-3 text-xl font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm leading-7 text-[var(--ink-muted)]">
|
||||
{description}
|
||||
</p>
|
||||
{actions ? (
|
||||
<div className={joinClasses("mt-6 flex justify-center gap-3")}>{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/components/ui/page-header.tsx
Normal file
47
src/components/ui/page-header.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type PageHeaderProps = {
|
||||
actions?: ReactNode;
|
||||
description?: ReactNode;
|
||||
eyebrow?: string;
|
||||
meta?: ReactNode;
|
||||
title: ReactNode;
|
||||
};
|
||||
|
||||
export function PageHeader({
|
||||
actions,
|
||||
description,
|
||||
eyebrow = "Dashboard view",
|
||||
meta,
|
||||
title,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-5 border-b border-[var(--border-soft)] pb-6 md:flex-row md:items-end md:justify-between">
|
||||
<div className="space-y-3">
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.26em] text-[var(--ink-faint)]">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-[-0.03em] text-[var(--ink-strong)]">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<div className="max-w-3xl text-sm leading-7 text-[var(--ink-muted)]">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{actions || meta ? (
|
||||
<div className="flex flex-col gap-3 md:items-end">
|
||||
{meta ? (
|
||||
<div className="font-[var(--font-mono)] text-xs uppercase tracking-[0.18em] text-[var(--ink-faint)]">
|
||||
{meta}
|
||||
</div>
|
||||
) : null}
|
||||
{actions ? <div className="flex flex-wrap gap-3">{actions}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/ui/section-title.tsx
Normal file
38
src/components/ui/section-title.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type SectionTitleProps = {
|
||||
action?: ReactNode;
|
||||
description?: ReactNode;
|
||||
eyebrow?: string;
|
||||
title: ReactNode;
|
||||
};
|
||||
|
||||
export function SectionTitle({
|
||||
action,
|
||||
description,
|
||||
eyebrow,
|
||||
title,
|
||||
}: SectionTitleProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div className="space-y-2">
|
||||
{eyebrow ? (
|
||||
<p className="font-[var(--font-mono)] text-[11px] uppercase tracking-[0.24em] text-[var(--ink-faint)]">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<div className="text-sm leading-6 text-[var(--ink-muted)]">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{action ? <div className="flex flex-wrap gap-3">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/components/ui/status-badge.tsx
Normal file
116
src/components/ui/status-badge.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
import type {
|
||||
OrderStatus,
|
||||
ReviewDecision,
|
||||
StepStatus,
|
||||
WorkflowStepName,
|
||||
} from "@/lib/types/backend";
|
||||
import {
|
||||
ORDER_STATUS_META,
|
||||
REVIEW_DECISION_META,
|
||||
STEP_STATUS_META,
|
||||
WORKFLOW_STEP_META,
|
||||
type StatusMeta,
|
||||
type StatusTone,
|
||||
} from "@/lib/types/status";
|
||||
|
||||
type StatusBadgeVariant =
|
||||
| "order"
|
||||
| "reviewDecision"
|
||||
| "stepStatus"
|
||||
| "workflowStep";
|
||||
|
||||
type StatusBadgeBaseProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
type OrderStatusBadgeProps = StatusBadgeBaseProps & {
|
||||
status: OrderStatus;
|
||||
variant?: "order";
|
||||
};
|
||||
|
||||
type ReviewDecisionBadgeProps = StatusBadgeBaseProps & {
|
||||
status: ReviewDecision;
|
||||
variant: "reviewDecision";
|
||||
};
|
||||
|
||||
type StepStatusBadgeProps = StatusBadgeBaseProps & {
|
||||
status: StepStatus;
|
||||
variant: "stepStatus";
|
||||
};
|
||||
|
||||
type WorkflowStepBadgeProps = StatusBadgeBaseProps & {
|
||||
status: WorkflowStepName | null;
|
||||
variant: "workflowStep";
|
||||
};
|
||||
|
||||
export type StatusBadgeProps =
|
||||
| OrderStatusBadgeProps
|
||||
| ReviewDecisionBadgeProps
|
||||
| StepStatusBadgeProps
|
||||
| WorkflowStepBadgeProps;
|
||||
|
||||
const TONE_STYLES: Record<StatusTone, string> = {
|
||||
neutral:
|
||||
"border-[rgba(76,69,60,0.14)] bg-[rgba(110,98,84,0.08)] text-[var(--ink-muted)]",
|
||||
info: "border-[rgba(57,86,95,0.16)] bg-[rgba(57,86,95,0.1)] text-[#2e4d56]",
|
||||
warning:
|
||||
"border-[rgba(145,104,46,0.18)] bg-[rgba(202,164,97,0.14)] text-[#7a5323]",
|
||||
success:
|
||||
"border-[rgba(88,106,56,0.18)] bg-[rgba(110,127,82,0.14)] text-[#50633b]",
|
||||
danger:
|
||||
"border-[rgba(140,74,67,0.18)] bg-[rgba(140,74,67,0.12)] text-[#7f3f38]",
|
||||
};
|
||||
|
||||
function joinClasses(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function getMetaFromRecord<T extends string>(
|
||||
record: Record<T, StatusMeta>,
|
||||
status: string,
|
||||
variant: StatusBadgeVariant,
|
||||
): StatusMeta {
|
||||
const meta = record[status as T];
|
||||
|
||||
if (!meta) {
|
||||
throw new Error(`Invalid status "${status}" for variant "${variant}".`);
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
function resolveStatusMeta(props: StatusBadgeProps): StatusMeta {
|
||||
switch (props.variant) {
|
||||
case "reviewDecision":
|
||||
return getMetaFromRecord(REVIEW_DECISION_META, props.status, props.variant);
|
||||
case "stepStatus":
|
||||
return getMetaFromRecord(STEP_STATUS_META, props.status, props.variant);
|
||||
case "workflowStep":
|
||||
if (props.status === null) {
|
||||
return { label: "未开始", tone: "neutral" };
|
||||
}
|
||||
|
||||
return getMetaFromRecord(WORKFLOW_STEP_META, props.status, props.variant);
|
||||
case "order":
|
||||
case undefined:
|
||||
return getMetaFromRecord(ORDER_STATUS_META, props.status, "order");
|
||||
}
|
||||
}
|
||||
|
||||
export function StatusBadge({ className, ...props }: StatusBadgeProps) {
|
||||
const meta = resolveStatusMeta(props);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-tone={meta.tone}
|
||||
className={joinClasses(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.16em]",
|
||||
TONE_STYLES[meta.tone],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
121
src/lib/adapters/orders.ts
Normal file
121
src/lib/adapters/orders.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
AssetDto,
|
||||
AssetType,
|
||||
OrderDetailResponseDto,
|
||||
OrderListItemDto,
|
||||
WorkflowStepName,
|
||||
} from "@/lib/types/backend";
|
||||
import { getOrderStatusMeta, getWorkflowStepMeta } from "@/lib/types/status";
|
||||
import {
|
||||
businessEmptyState,
|
||||
READY_STATE,
|
||||
type AssetViewModel,
|
||||
type OrderDetailVM,
|
||||
type OrderSummaryVM,
|
||||
} from "@/lib/types/view-models";
|
||||
|
||||
const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
prepared_model: "模型准备图",
|
||||
tryon: "试穿图",
|
||||
scene: "场景图",
|
||||
texture: "纹理图",
|
||||
face: "面部图",
|
||||
fusion: "融合图",
|
||||
qc_candidate: "质检候选图",
|
||||
manual_revision: "人工修订稿",
|
||||
final: "最终图",
|
||||
};
|
||||
|
||||
function isMockUri(uri: string): boolean {
|
||||
return uri.startsWith("mock://");
|
||||
}
|
||||
|
||||
function getAssetLabel(assetType: AssetType, stepName: WorkflowStepName | null) {
|
||||
if (stepName) {
|
||||
return `${getWorkflowStepMeta(stepName).label}产物`;
|
||||
}
|
||||
|
||||
return ASSET_TYPE_LABELS[assetType];
|
||||
}
|
||||
|
||||
export function adaptAsset(asset: AssetDto): AssetViewModel {
|
||||
const stepMeta = getWorkflowStepMeta(asset.step_name);
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
orderId: asset.order_id,
|
||||
type: asset.asset_type,
|
||||
stepName: asset.step_name,
|
||||
parentAssetId: asset.parent_asset_id,
|
||||
rootAssetId: asset.root_asset_id,
|
||||
versionNo: asset.version_no,
|
||||
isCurrentVersion: asset.is_current_version,
|
||||
stepLabel: stepMeta.label,
|
||||
label: getAssetLabel(asset.asset_type, asset.step_name),
|
||||
uri: asset.uri,
|
||||
metadata: asset.metadata_json,
|
||||
createdAt: asset.created_at,
|
||||
isMock: isMockUri(asset.uri),
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptOrderSummary(
|
||||
order: Pick<
|
||||
OrderDetailResponseDto | OrderListItemDto,
|
||||
"order_id" | "workflow_id" | "status" | "current_step" | "updated_at"
|
||||
>,
|
||||
): OrderSummaryVM {
|
||||
return {
|
||||
orderId: order.order_id,
|
||||
workflowId: order.workflow_id,
|
||||
status: order.status,
|
||||
statusMeta: getOrderStatusMeta(order.status),
|
||||
currentStep: order.current_step,
|
||||
currentStepLabel: getWorkflowStepMeta(order.current_step).label,
|
||||
updatedAt: order.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptOrderDetail(
|
||||
order: OrderDetailResponseDto,
|
||||
assets: AssetDto[] = [],
|
||||
): OrderDetailVM {
|
||||
const finalAsset = order.final_asset ? adaptAsset(order.final_asset) : null;
|
||||
const assetItems = assets.map(adaptAsset);
|
||||
const hasMockAssets = [finalAsset, ...assetItems].some(
|
||||
(asset) => asset?.isMock,
|
||||
);
|
||||
|
||||
return {
|
||||
orderId: order.order_id,
|
||||
workflowId: order.workflow_id,
|
||||
customerLevel: order.customer_level,
|
||||
serviceMode: order.service_mode,
|
||||
status: order.status,
|
||||
statusMeta: getOrderStatusMeta(order.status),
|
||||
currentStep: order.current_step,
|
||||
currentStepLabel: getWorkflowStepMeta(order.current_step).label,
|
||||
modelId: order.model_id,
|
||||
poseId: order.pose_id,
|
||||
garmentAssetId: order.garment_asset_id,
|
||||
sceneRefAssetId: order.scene_ref_asset_id,
|
||||
currentRevisionAssetId: order.current_revision_asset_id,
|
||||
currentRevisionVersion: order.current_revision_version,
|
||||
latestRevisionAssetId: order.latest_revision_asset_id,
|
||||
latestRevisionVersion: order.latest_revision_version,
|
||||
revisionCount: order.revision_count,
|
||||
reviewTaskStatus: order.review_task_status,
|
||||
pendingManualConfirm: order.pending_manual_confirm,
|
||||
createdAt: order.created_at,
|
||||
updatedAt: order.updated_at,
|
||||
finalAsset,
|
||||
finalAssetState: finalAsset
|
||||
? READY_STATE
|
||||
: businessEmptyState("最终图暂未生成", "当前订单还没有可展示的最终结果。"),
|
||||
assets: assetItems,
|
||||
assetGalleryState: assetItems.length
|
||||
? READY_STATE
|
||||
: businessEmptyState("暂无资产", "当前订单还没有生成可查看的资产列表。"),
|
||||
hasMockAssets,
|
||||
};
|
||||
}
|
||||
69
src/lib/adapters/reviews.ts
Normal file
69
src/lib/adapters/reviews.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
PendingReviewResponseDto,
|
||||
SubmitReviewResponseDto,
|
||||
} from "@/lib/types/backend";
|
||||
import {
|
||||
getOrderStatusMeta,
|
||||
getReviewDecisionMeta,
|
||||
getWorkflowStepMeta,
|
||||
} from "@/lib/types/status";
|
||||
import {
|
||||
businessEmptyState,
|
||||
READY_STATE,
|
||||
type ReviewQueueItemVM,
|
||||
type ReviewQueueVM,
|
||||
type ReviewSubmissionVM,
|
||||
} from "@/lib/types/view-models";
|
||||
|
||||
function adaptPendingReviewItem(
|
||||
review: PendingReviewResponseDto,
|
||||
): ReviewQueueItemVM {
|
||||
return {
|
||||
reviewTaskId: review.review_task_id,
|
||||
orderId: review.order_id,
|
||||
workflowId: review.workflow_id,
|
||||
workflowType: null,
|
||||
currentStep: review.current_step,
|
||||
currentStepLabel: getWorkflowStepMeta(review.current_step).label,
|
||||
createdAt: review.created_at,
|
||||
status: "waiting_review",
|
||||
statusMeta: getOrderStatusMeta("waiting_review"),
|
||||
reviewTaskStatus: review.review_task_status,
|
||||
latestRevisionAssetId: review.latest_revision_asset_id,
|
||||
currentRevisionAssetId: review.current_revision_asset_id,
|
||||
latestRevisionVersion: review.latest_revision_version,
|
||||
revisionCount: review.revision_count,
|
||||
pendingManualConfirm: review.pending_manual_confirm,
|
||||
hasMockAssets: false,
|
||||
failureCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptPendingReviews(
|
||||
reviews: PendingReviewResponseDto[],
|
||||
): ReviewQueueVM {
|
||||
const items = reviews.map(adaptPendingReviewItem);
|
||||
|
||||
return {
|
||||
items,
|
||||
state: items.length
|
||||
? READY_STATE
|
||||
: businessEmptyState(
|
||||
"暂无待审核订单",
|
||||
"当前没有等待人工处理的审核任务。",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptReviewSubmission(
|
||||
submission: SubmitReviewResponseDto,
|
||||
): ReviewSubmissionVM {
|
||||
return {
|
||||
orderId: submission.order_id,
|
||||
workflowId: submission.workflow_id,
|
||||
revisionAssetId: submission.revision_asset_id,
|
||||
decision: submission.decision,
|
||||
decisionMeta: getReviewDecisionMeta(submission.decision),
|
||||
status: submission.status,
|
||||
};
|
||||
}
|
||||
44
src/lib/adapters/revisions.ts
Normal file
44
src/lib/adapters/revisions.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
RegisterRevisionResponseDto,
|
||||
RevisionChainResponseDto,
|
||||
} from "@/lib/types/backend";
|
||||
import type {
|
||||
RevisionChainVM,
|
||||
RevisionRegistrationVM,
|
||||
} from "@/lib/types/view-models";
|
||||
|
||||
export function adaptRevisionRegistration(
|
||||
payload: RegisterRevisionResponseDto,
|
||||
): RevisionRegistrationVM {
|
||||
return {
|
||||
orderId: payload.order_id,
|
||||
workflowId: payload.workflow_id,
|
||||
assetId: payload.asset_id,
|
||||
parentAssetId: payload.parent_asset_id,
|
||||
rootAssetId: payload.root_asset_id,
|
||||
versionNo: payload.version_no,
|
||||
reviewTaskStatus: payload.review_task_status,
|
||||
latestRevisionAssetId: payload.latest_revision_asset_id,
|
||||
revisionCount: payload.revision_count,
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptRevisionChain(
|
||||
payload: RevisionChainResponseDto,
|
||||
): RevisionChainVM {
|
||||
return {
|
||||
orderId: payload.order_id,
|
||||
latestRevisionAssetId: payload.latest_revision_asset_id,
|
||||
revisionCount: payload.revision_count,
|
||||
items: payload.items.map((item) => ({
|
||||
assetId: item.asset_id,
|
||||
orderId: item.order_id,
|
||||
parentAssetId: item.parent_asset_id,
|
||||
rootAssetId: item.root_asset_id,
|
||||
versionNo: item.version_no,
|
||||
isCurrentVersion: item.is_current_version,
|
||||
uri: item.uri,
|
||||
createdAt: item.created_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
154
src/lib/adapters/workflows.ts
Normal file
154
src/lib/adapters/workflows.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type {
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
WorkflowListItemDto,
|
||||
WorkflowStatusResponseDto,
|
||||
} from "@/lib/types/backend";
|
||||
import {
|
||||
getOrderStatusMeta,
|
||||
getStepStatusMeta,
|
||||
getWorkflowStepMeta,
|
||||
} from "@/lib/types/status";
|
||||
import {
|
||||
businessEmptyState,
|
||||
READY_STATE,
|
||||
type WorkflowDetailVM,
|
||||
type WorkflowLookupItemVM,
|
||||
type WorkflowStepVM,
|
||||
} from "@/lib/types/view-models";
|
||||
|
||||
type WorkflowAssetUriField =
|
||||
| "asset_uri"
|
||||
| "candidate_uri"
|
||||
| "preview_uri"
|
||||
| "result_uri"
|
||||
| "source_uri";
|
||||
|
||||
const WORKFLOW_ASSET_URI_FIELDS = new Set<WorkflowAssetUriField>([
|
||||
"asset_uri",
|
||||
"candidate_uri",
|
||||
"preview_uri",
|
||||
"result_uri",
|
||||
"source_uri",
|
||||
]);
|
||||
|
||||
function collectKnownAssetUris(
|
||||
value: JsonValue | undefined,
|
||||
results: string[] = [],
|
||||
): string[] {
|
||||
if (!value || typeof value !== "object") {
|
||||
return results;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectKnownAssetUris(item, results);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
if (
|
||||
WORKFLOW_ASSET_URI_FIELDS.has(key as WorkflowAssetUriField) &&
|
||||
typeof nestedValue === "string" &&
|
||||
nestedValue.startsWith("mock://")
|
||||
) {
|
||||
results.push(nestedValue);
|
||||
continue;
|
||||
}
|
||||
|
||||
collectKnownAssetUris(nestedValue, results);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function uniqueMockUris(...payloads: Array<JsonObject | null>): string[] {
|
||||
return [...new Set(payloads.flatMap((payload) => collectKnownAssetUris(payload)))];
|
||||
}
|
||||
|
||||
function adaptWorkflowStep(
|
||||
currentStep: WorkflowStatusResponseDto["current_step"],
|
||||
step: WorkflowStatusResponseDto["steps"][number],
|
||||
): WorkflowStepVM {
|
||||
const stepMeta = getWorkflowStepMeta(step.step_name);
|
||||
const mockAssetUris = uniqueMockUris(step.input_json, step.output_json);
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
workflowRunId: step.workflow_run_id,
|
||||
name: step.step_name,
|
||||
label: stepMeta.label,
|
||||
status: step.step_status,
|
||||
statusMeta: getStepStatusMeta(step.step_status),
|
||||
input: step.input_json,
|
||||
output: step.output_json,
|
||||
errorMessage: step.error_message,
|
||||
startedAt: step.started_at,
|
||||
endedAt: step.ended_at,
|
||||
containsMockAssets: mockAssetUris.length > 0,
|
||||
mockAssetUris,
|
||||
isCurrent: currentStep === step.step_name,
|
||||
isFailed: step.step_status === "failed",
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptWorkflowLookupItem(
|
||||
workflow: Pick<
|
||||
WorkflowStatusResponseDto | WorkflowListItemDto,
|
||||
| "order_id"
|
||||
| "workflow_id"
|
||||
| "workflow_type"
|
||||
| "workflow_status"
|
||||
| "current_step"
|
||||
| "updated_at"
|
||||
>,
|
||||
): WorkflowLookupItemVM {
|
||||
return {
|
||||
orderId: workflow.order_id,
|
||||
workflowId: workflow.workflow_id,
|
||||
workflowType: workflow.workflow_type,
|
||||
status: workflow.workflow_status,
|
||||
statusMeta: getOrderStatusMeta(workflow.workflow_status),
|
||||
currentStep: workflow.current_step,
|
||||
currentStepLabel: getWorkflowStepMeta(workflow.current_step).label,
|
||||
updatedAt: workflow.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptWorkflowDetail(
|
||||
workflow: WorkflowStatusResponseDto,
|
||||
): WorkflowDetailVM {
|
||||
const steps = workflow.steps.map((step) =>
|
||||
adaptWorkflowStep(workflow.current_step, step),
|
||||
);
|
||||
|
||||
return {
|
||||
orderId: workflow.order_id,
|
||||
workflowId: workflow.workflow_id,
|
||||
workflowType: workflow.workflow_type,
|
||||
status: workflow.workflow_status,
|
||||
statusMeta: getOrderStatusMeta(workflow.workflow_status),
|
||||
currentStep: workflow.current_step,
|
||||
currentStepLabel: getWorkflowStepMeta(workflow.current_step).label,
|
||||
currentRevisionAssetId: workflow.current_revision_asset_id,
|
||||
currentRevisionVersion: workflow.current_revision_version,
|
||||
latestRevisionAssetId: workflow.latest_revision_asset_id,
|
||||
latestRevisionVersion: workflow.latest_revision_version,
|
||||
revisionCount: workflow.revision_count,
|
||||
reviewTaskStatus: workflow.review_task_status,
|
||||
pendingManualConfirm: workflow.pending_manual_confirm,
|
||||
createdAt: workflow.created_at,
|
||||
updatedAt: workflow.updated_at,
|
||||
steps,
|
||||
stepTimelineState: steps.length
|
||||
? READY_STATE
|
||||
: businessEmptyState(
|
||||
"暂无流程记录",
|
||||
"当前工作流还没有可展示的步骤执行记录。",
|
||||
),
|
||||
failureCount: steps.filter((step) => step.isFailed).length,
|
||||
hasMockAssets: steps.some((step) => step.containsMockAssets),
|
||||
};
|
||||
}
|
||||
5
src/lib/env.ts
Normal file
5
src/lib/env.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
const DEFAULT_BACKEND_BASE_URL = "http://127.0.0.1:8000/api/v1";
|
||||
|
||||
export function getBackendBaseUrl(): string {
|
||||
return process.env.BACKEND_BASE_URL ?? DEFAULT_BACKEND_BASE_URL;
|
||||
}
|
||||
113
src/lib/http/backend-client.ts
Normal file
113
src/lib/http/backend-client.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { getBackendBaseUrl } from "@/lib/env";
|
||||
import { RouteError, isObject } from "@/lib/http/response";
|
||||
|
||||
function buildBackendUrl(pathname: string): string {
|
||||
const baseUrl = getBackendBaseUrl().replace(/\/$/, "");
|
||||
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
||||
|
||||
return `${baseUrl}${normalizedPathname}`;
|
||||
}
|
||||
|
||||
function extractBackendMessage(payload: unknown): string | undefined {
|
||||
if (typeof payload === "string" && payload.length > 0) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (!isObject(payload)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof payload.message === "string" && payload.message.length > 0) {
|
||||
return payload.message;
|
||||
}
|
||||
|
||||
if (typeof payload.detail === "string" && payload.detail.length > 0) {
|
||||
return payload.detail;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function parseBackendPayload(response: Response): Promise<unknown> {
|
||||
const rawText = await response.text();
|
||||
|
||||
if (rawText.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawText) as unknown;
|
||||
} catch {
|
||||
throw new RouteError(
|
||||
502,
|
||||
"BACKEND_ERROR",
|
||||
"后端返回了无法解析的响应。",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function backendRequest<T>(
|
||||
pathname: string,
|
||||
init?: RequestInit,
|
||||
): Promise<{ data: T; status: number }> {
|
||||
const url = buildBackendUrl(pathname);
|
||||
const headers = new Headers(init?.headers);
|
||||
|
||||
if (!headers.has("accept")) {
|
||||
headers.set("accept", "application/json");
|
||||
}
|
||||
|
||||
if (init?.body && !headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RouteError(
|
||||
502,
|
||||
"BACKEND_UNAVAILABLE",
|
||||
"后端暂时不可用,请稍后重试。",
|
||||
error instanceof Error ? error.message : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await parseBackendPayload(response);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new RouteError(
|
||||
404,
|
||||
"NOT_FOUND",
|
||||
extractBackendMessage(payload) ?? "请求的资源不存在。",
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 400 || response.status === 422) {
|
||||
throw new RouteError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
extractBackendMessage(payload) ?? "请求参数无效。",
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
throw new RouteError(
|
||||
502,
|
||||
"BACKEND_ERROR",
|
||||
extractBackendMessage(payload) ?? "后端请求失败,请稍后重试。",
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
data: payload as T,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
113
src/lib/http/response.ts
Normal file
113
src/lib/http/response.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export type ResponseMode = "proxy" | "placeholder";
|
||||
|
||||
export class RouteError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RouteError";
|
||||
}
|
||||
}
|
||||
|
||||
type SuccessOptions = {
|
||||
status?: number;
|
||||
mode?: ResponseMode;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type ErrorBody = {
|
||||
error: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
export function jsonSuccess<T>(data: T, options: SuccessOptions = {}) {
|
||||
const body: {
|
||||
mode: ResponseMode;
|
||||
data: T;
|
||||
message?: string;
|
||||
} = {
|
||||
mode: options.mode ?? "proxy",
|
||||
data,
|
||||
};
|
||||
|
||||
if (options.message) {
|
||||
body.message = options.message;
|
||||
}
|
||||
|
||||
return NextResponse.json(body, {
|
||||
status: options.status ?? 200,
|
||||
});
|
||||
}
|
||||
|
||||
export function jsonError(
|
||||
status: number,
|
||||
error: string,
|
||||
message: string,
|
||||
details?: unknown,
|
||||
) {
|
||||
const body: ErrorBody = {
|
||||
error,
|
||||
message,
|
||||
};
|
||||
|
||||
if (details !== undefined) {
|
||||
body.details = details;
|
||||
}
|
||||
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
|
||||
export function handleRouteError(error: unknown) {
|
||||
if (error instanceof RouteError) {
|
||||
return jsonError(error.status, error.code, error.message, error.details);
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
|
||||
return jsonError(500, "SYSTEM_ERROR", "系统内部错误,请稍后重试。");
|
||||
}
|
||||
|
||||
export async function withErrorHandling(
|
||||
handler: () => Promise<NextResponse>,
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
return await handler();
|
||||
} catch (error) {
|
||||
return handleRouteError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseJsonBody<T>(request: Request): Promise<T> {
|
||||
try {
|
||||
return (await request.json()) as T;
|
||||
} catch {
|
||||
throw new RouteError(400, "INVALID_JSON", "请求体必须是合法 JSON。");
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePositiveIntegerParam(
|
||||
value: string,
|
||||
fieldName: string,
|
||||
): number {
|
||||
const parsedValue = Number(value);
|
||||
|
||||
if (!Number.isInteger(parsedValue) || parsedValue < 1) {
|
||||
throw new RouteError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
`${fieldName} 必须是正整数。`,
|
||||
);
|
||||
}
|
||||
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
64
src/lib/mock/libraries.ts
Normal file
64
src/lib/mock/libraries.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { LibraryItemVM } from "@/lib/types/view-models";
|
||||
|
||||
export const MODEL_LIBRARY_FIXTURES: LibraryItemVM[] = [
|
||||
{
|
||||
id: "model-ava",
|
||||
libraryType: "models",
|
||||
name: "Ava / Studio",
|
||||
description: "中性棚拍模特占位数据,用于提交页联调。",
|
||||
previewUri: "mock://libraries/models/ava",
|
||||
tags: ["女装", "半身", "mock"],
|
||||
isMock: true,
|
||||
},
|
||||
{
|
||||
id: "model-jian",
|
||||
libraryType: "models",
|
||||
name: "Jian / Editorial",
|
||||
description: "男装模特占位数据,保留资源库信息架构。",
|
||||
previewUri: "mock://libraries/models/jian",
|
||||
tags: ["男装", "全身", "mock"],
|
||||
isMock: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SCENE_LIBRARY_FIXTURES: LibraryItemVM[] = [
|
||||
{
|
||||
id: "scene-loft",
|
||||
libraryType: "scenes",
|
||||
name: "Loft Window",
|
||||
description: "暖调室内场景占位素材。",
|
||||
previewUri: "mock://libraries/scenes/loft-window",
|
||||
tags: ["室内", "暖光", "mock"],
|
||||
isMock: true,
|
||||
},
|
||||
{
|
||||
id: "scene-garden",
|
||||
libraryType: "scenes",
|
||||
name: "Garden Walk",
|
||||
description: "户外花园场景占位素材。",
|
||||
previewUri: "mock://libraries/scenes/garden-walk",
|
||||
tags: ["户外", "自然光", "mock"],
|
||||
isMock: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const GARMENT_LIBRARY_FIXTURES: LibraryItemVM[] = [
|
||||
{
|
||||
id: "garment-coat-01",
|
||||
libraryType: "garments",
|
||||
name: "Structured Coat 01",
|
||||
description: "大衣品类占位素材,供提单页联调使用。",
|
||||
previewUri: "mock://libraries/garments/coat-01",
|
||||
tags: ["大衣", "秋冬", "mock"],
|
||||
isMock: true,
|
||||
},
|
||||
{
|
||||
id: "garment-dress-03",
|
||||
libraryType: "garments",
|
||||
name: "Silk Dress 03",
|
||||
description: "连衣裙品类占位素材,保持资源库路由完整。",
|
||||
previewUri: "mock://libraries/garments/dress-03",
|
||||
tags: ["连衣裙", "礼服", "mock"],
|
||||
isMock: true,
|
||||
},
|
||||
];
|
||||
85
src/lib/mock/orders.ts
Normal file
85
src/lib/mock/orders.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { AssetDto, OrderDetailResponseDto } from "@/lib/types/backend";
|
||||
|
||||
export const ORDER_DETAIL_DTO_FIXTURE: OrderDetailResponseDto = {
|
||||
order_id: 4201,
|
||||
customer_level: "mid",
|
||||
service_mode: "semi_pro",
|
||||
status: "waiting_review",
|
||||
model_id: 101,
|
||||
pose_id: 202,
|
||||
garment_asset_id: 303,
|
||||
scene_ref_asset_id: 404,
|
||||
final_asset_id: null,
|
||||
workflow_id: "wf-4201",
|
||||
current_step: "review",
|
||||
current_revision_asset_id: null,
|
||||
current_revision_version: null,
|
||||
latest_revision_asset_id: null,
|
||||
latest_revision_version: null,
|
||||
revision_count: 0,
|
||||
review_task_status: null,
|
||||
pending_manual_confirm: false,
|
||||
final_asset: null,
|
||||
created_at: "2026-03-27T09:00:00Z",
|
||||
updated_at: "2026-03-27T09:15:00Z",
|
||||
};
|
||||
|
||||
export const ORDER_ASSET_DTO_FIXTURES: AssetDto[] = [
|
||||
{
|
||||
id: 9001,
|
||||
order_id: 4201,
|
||||
asset_type: "fusion",
|
||||
step_name: "fusion",
|
||||
parent_asset_id: null,
|
||||
root_asset_id: null,
|
||||
version_no: 0,
|
||||
is_current_version: false,
|
||||
uri: "mock://fusion-4201",
|
||||
metadata_json: {
|
||||
note: "placeholder asset",
|
||||
},
|
||||
created_at: "2026-03-27T09:10:00Z",
|
||||
},
|
||||
{
|
||||
id: 9002,
|
||||
order_id: 4201,
|
||||
asset_type: "qc_candidate",
|
||||
step_name: "qc",
|
||||
parent_asset_id: null,
|
||||
root_asset_id: null,
|
||||
version_no: 0,
|
||||
is_current_version: false,
|
||||
uri: "mock://qc-4201",
|
||||
metadata_json: null,
|
||||
created_at: "2026-03-27T09:12:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
export const RECENT_ORDER_SUMMARY_DTO_FIXTURES: Array<
|
||||
Pick<
|
||||
OrderDetailResponseDto,
|
||||
"order_id" | "workflow_id" | "status" | "current_step" | "updated_at"
|
||||
>
|
||||
> = [
|
||||
{
|
||||
order_id: ORDER_DETAIL_DTO_FIXTURE.order_id,
|
||||
workflow_id: ORDER_DETAIL_DTO_FIXTURE.workflow_id,
|
||||
status: ORDER_DETAIL_DTO_FIXTURE.status,
|
||||
current_step: ORDER_DETAIL_DTO_FIXTURE.current_step,
|
||||
updated_at: ORDER_DETAIL_DTO_FIXTURE.updated_at,
|
||||
},
|
||||
{
|
||||
order_id: 4202,
|
||||
workflow_id: "wf-4202",
|
||||
status: "running",
|
||||
current_step: "fusion",
|
||||
updated_at: "2026-03-27T09:30:00Z",
|
||||
},
|
||||
{
|
||||
order_id: 4203,
|
||||
workflow_id: "wf-4203",
|
||||
status: "succeeded",
|
||||
current_step: "export",
|
||||
updated_at: "2026-03-27T08:58:00Z",
|
||||
},
|
||||
];
|
||||
75
src/lib/mock/workflows.ts
Normal file
75
src/lib/mock/workflows.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { WorkflowStatusResponseDto } from "@/lib/types/backend";
|
||||
|
||||
export const WORKFLOW_DETAIL_DTO_FIXTURE: WorkflowStatusResponseDto = {
|
||||
order_id: 4201,
|
||||
workflow_id: "wf-4201",
|
||||
workflow_type: "mid_end",
|
||||
workflow_status: "running",
|
||||
current_step: "review",
|
||||
current_revision_asset_id: null,
|
||||
current_revision_version: null,
|
||||
latest_revision_asset_id: null,
|
||||
latest_revision_version: null,
|
||||
revision_count: 0,
|
||||
review_task_status: null,
|
||||
pending_manual_confirm: false,
|
||||
steps: [
|
||||
{
|
||||
id: 1,
|
||||
workflow_run_id: 88,
|
||||
step_name: "prepare_model",
|
||||
step_status: "succeeded",
|
||||
input_json: null,
|
||||
output_json: {
|
||||
preview_uri: "mock://workflow/4201/prepare-model",
|
||||
},
|
||||
error_message: null,
|
||||
started_at: "2026-03-27T09:00:00Z",
|
||||
ended_at: "2026-03-27T09:01:00Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
workflow_run_id: 88,
|
||||
step_name: "review",
|
||||
step_status: "waiting",
|
||||
input_json: {
|
||||
candidate_uri: "mock://workflow/4201/review-candidate",
|
||||
},
|
||||
output_json: null,
|
||||
error_message: null,
|
||||
started_at: "2026-03-27T09:12:00Z",
|
||||
ended_at: null,
|
||||
},
|
||||
],
|
||||
created_at: "2026-03-27T09:00:00Z",
|
||||
updated_at: "2026-03-27T09:15:00Z",
|
||||
};
|
||||
|
||||
export const WORKFLOW_LOOKUP_DTO_FIXTURES: Array<
|
||||
Pick<
|
||||
WorkflowStatusResponseDto,
|
||||
| "order_id"
|
||||
| "workflow_id"
|
||||
| "workflow_type"
|
||||
| "workflow_status"
|
||||
| "current_step"
|
||||
| "updated_at"
|
||||
>
|
||||
> = [
|
||||
{
|
||||
order_id: WORKFLOW_DETAIL_DTO_FIXTURE.order_id,
|
||||
workflow_id: WORKFLOW_DETAIL_DTO_FIXTURE.workflow_id,
|
||||
workflow_type: WORKFLOW_DETAIL_DTO_FIXTURE.workflow_type,
|
||||
workflow_status: WORKFLOW_DETAIL_DTO_FIXTURE.workflow_status,
|
||||
current_step: WORKFLOW_DETAIL_DTO_FIXTURE.current_step,
|
||||
updated_at: WORKFLOW_DETAIL_DTO_FIXTURE.updated_at,
|
||||
},
|
||||
{
|
||||
order_id: 4202,
|
||||
workflow_id: "wf-4202",
|
||||
workflow_type: "mid_end",
|
||||
workflow_status: "failed",
|
||||
current_step: "fusion",
|
||||
updated_at: "2026-03-27T08:40:00Z",
|
||||
},
|
||||
];
|
||||
267
src/lib/types/backend.ts
Normal file
267
src/lib/types/backend.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
|
||||
export type JsonObject = {
|
||||
[key: string]: JsonValue;
|
||||
};
|
||||
|
||||
export type CustomerLevel = "low" | "mid";
|
||||
|
||||
export type ServiceMode = "auto_basic" | "semi_pro";
|
||||
|
||||
export type OrderStatus =
|
||||
| "created"
|
||||
| "running"
|
||||
| "waiting_review"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export type WorkflowStepName =
|
||||
| "prepare_model"
|
||||
| "tryon"
|
||||
| "scene"
|
||||
| "texture"
|
||||
| "face"
|
||||
| "fusion"
|
||||
| "qc"
|
||||
| "export"
|
||||
| "review";
|
||||
|
||||
export type ReviewDecision =
|
||||
| "approve"
|
||||
| "rerun_scene"
|
||||
| "rerun_face"
|
||||
| "rerun_fusion"
|
||||
| "reject";
|
||||
|
||||
export type ReviewTaskStatus =
|
||||
| "pending"
|
||||
| "revision_uploaded"
|
||||
| "submitted";
|
||||
|
||||
export type StepStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "waiting"
|
||||
| "succeeded"
|
||||
| "failed";
|
||||
|
||||
export type AssetType =
|
||||
| "prepared_model"
|
||||
| "tryon"
|
||||
| "scene"
|
||||
| "texture"
|
||||
| "face"
|
||||
| "fusion"
|
||||
| "qc_candidate"
|
||||
| "manual_revision"
|
||||
| "final";
|
||||
|
||||
export type CreateOrderRequestDto = {
|
||||
customer_level: CustomerLevel;
|
||||
service_mode: ServiceMode;
|
||||
model_id: number;
|
||||
pose_id: number;
|
||||
garment_asset_id: number;
|
||||
scene_ref_asset_id: number;
|
||||
};
|
||||
|
||||
export type CreateOrderResponseDto = {
|
||||
order_id: number;
|
||||
workflow_id: string;
|
||||
status: OrderStatus;
|
||||
};
|
||||
|
||||
export type AssetDto = {
|
||||
id: number;
|
||||
order_id: number;
|
||||
asset_type: AssetType;
|
||||
step_name: WorkflowStepName | null;
|
||||
parent_asset_id: number | null;
|
||||
root_asset_id: number | null;
|
||||
version_no: number;
|
||||
is_current_version: boolean;
|
||||
uri: string;
|
||||
metadata_json: JsonObject | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type OrderDetailResponseDto = {
|
||||
order_id: number;
|
||||
customer_level: CustomerLevel;
|
||||
service_mode: ServiceMode;
|
||||
status: OrderStatus;
|
||||
model_id: number;
|
||||
pose_id: number;
|
||||
garment_asset_id: number;
|
||||
scene_ref_asset_id: number;
|
||||
final_asset_id: number | null;
|
||||
workflow_id: string | null;
|
||||
current_step: WorkflowStepName | null;
|
||||
current_revision_asset_id: number | null;
|
||||
current_revision_version: number | null;
|
||||
latest_revision_asset_id: number | null;
|
||||
latest_revision_version: number | null;
|
||||
revision_count: number;
|
||||
review_task_status: ReviewTaskStatus | null;
|
||||
pending_manual_confirm: boolean;
|
||||
final_asset: AssetDto | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type OrderListItemDto = {
|
||||
order_id: number;
|
||||
workflow_id: string | null;
|
||||
customer_level: CustomerLevel;
|
||||
service_mode: ServiceMode;
|
||||
status: OrderStatus;
|
||||
current_step: WorkflowStepName | null;
|
||||
updated_at: string;
|
||||
final_asset_id: number | null;
|
||||
review_task_status: ReviewTaskStatus | null;
|
||||
latest_revision_asset_id: number | null;
|
||||
latest_revision_version: number | null;
|
||||
revision_count: number;
|
||||
pending_manual_confirm: boolean;
|
||||
};
|
||||
|
||||
export type OrderListResponseDto = {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
items: OrderListItemDto[];
|
||||
};
|
||||
|
||||
export type PendingReviewResponseDto = {
|
||||
review_task_id: number;
|
||||
order_id: number;
|
||||
workflow_id: string;
|
||||
current_step: WorkflowStepName | null;
|
||||
review_task_status: ReviewTaskStatus;
|
||||
latest_revision_asset_id: number | null;
|
||||
current_revision_asset_id: number | null;
|
||||
latest_revision_version: number | null;
|
||||
revision_count: number;
|
||||
pending_manual_confirm: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type SubmitReviewRequestDto = {
|
||||
decision: ReviewDecision;
|
||||
reviewer_id: number;
|
||||
selected_asset_id: number | null;
|
||||
comment: string | null;
|
||||
};
|
||||
|
||||
export type SubmitReviewResponseDto = {
|
||||
order_id: number;
|
||||
workflow_id: string;
|
||||
revision_asset_id?: number;
|
||||
decision: ReviewDecision;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type RegisterRevisionRequestDto = {
|
||||
parent_asset_id: number;
|
||||
uploaded_uri: string;
|
||||
reviewer_id: number;
|
||||
comment: string | null;
|
||||
};
|
||||
|
||||
export type RegisterRevisionResponseDto = {
|
||||
order_id: number;
|
||||
workflow_id: string;
|
||||
asset_id: number;
|
||||
parent_asset_id: number;
|
||||
root_asset_id: number;
|
||||
version_no: number;
|
||||
review_task_status: ReviewTaskStatus;
|
||||
latest_revision_asset_id: number;
|
||||
revision_count: number;
|
||||
};
|
||||
|
||||
export type RevisionChainItemDto = {
|
||||
asset_id: number;
|
||||
order_id: number;
|
||||
parent_asset_id: number | null;
|
||||
root_asset_id: number | null;
|
||||
version_no: number;
|
||||
is_current_version: boolean;
|
||||
uri: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type RevisionChainResponseDto = {
|
||||
order_id: number;
|
||||
latest_revision_asset_id: number | null;
|
||||
revision_count: number;
|
||||
items: RevisionChainItemDto[];
|
||||
};
|
||||
|
||||
export type ConfirmRevisionRequestDto = {
|
||||
reviewer_id: number;
|
||||
comment: string | null;
|
||||
};
|
||||
|
||||
export type ConfirmRevisionResponseDto = {
|
||||
order_id: number;
|
||||
workflow_id: string;
|
||||
revision_asset_id: number;
|
||||
decision: ReviewDecision;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type WorkflowStepDto = {
|
||||
id: number;
|
||||
workflow_run_id: number;
|
||||
step_name: WorkflowStepName;
|
||||
step_status: StepStatus;
|
||||
input_json: JsonObject | null;
|
||||
output_json: JsonObject | null;
|
||||
error_message: string | null;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowStatusResponseDto = {
|
||||
order_id: number;
|
||||
workflow_id: string;
|
||||
workflow_type: string;
|
||||
workflow_status: OrderStatus;
|
||||
current_step: WorkflowStepName | null;
|
||||
current_revision_asset_id: number | null;
|
||||
current_revision_version: number | null;
|
||||
latest_revision_asset_id: number | null;
|
||||
latest_revision_version: number | null;
|
||||
revision_count: number;
|
||||
review_task_status: ReviewTaskStatus | null;
|
||||
pending_manual_confirm: boolean;
|
||||
steps: WorkflowStepDto[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type WorkflowListItemDto = {
|
||||
order_id: number;
|
||||
workflow_id: string;
|
||||
workflow_type: string;
|
||||
workflow_status: OrderStatus;
|
||||
current_step: WorkflowStepName | null;
|
||||
updated_at: string;
|
||||
failure_count: number;
|
||||
review_task_status: ReviewTaskStatus | null;
|
||||
latest_revision_asset_id: number | null;
|
||||
latest_revision_version: number | null;
|
||||
revision_count: number;
|
||||
pending_manual_confirm: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowListResponseDto = {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
items: WorkflowListItemDto[];
|
||||
};
|
||||
72
src/lib/types/status.ts
Normal file
72
src/lib/types/status.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
OrderStatus,
|
||||
ReviewDecision,
|
||||
StepStatus,
|
||||
WorkflowStepName,
|
||||
} from "@/lib/types/backend";
|
||||
|
||||
export type StatusTone = "neutral" | "info" | "warning" | "success" | "danger";
|
||||
|
||||
export type StatusMeta = {
|
||||
label: string;
|
||||
tone: StatusTone;
|
||||
};
|
||||
|
||||
export const ORDER_STATUS_META = {
|
||||
created: { label: "已创建", tone: "neutral" },
|
||||
running: { label: "处理中", tone: "info" },
|
||||
waiting_review: { label: "待审核", tone: "warning" },
|
||||
succeeded: { label: "已完成", tone: "success" },
|
||||
failed: { label: "失败", tone: "danger" },
|
||||
cancelled: { label: "已取消", tone: "neutral" },
|
||||
} as const satisfies Record<OrderStatus, StatusMeta>;
|
||||
|
||||
export const STEP_STATUS_META = {
|
||||
pending: { label: "待执行", tone: "neutral" },
|
||||
running: { label: "执行中", tone: "info" },
|
||||
waiting: { label: "等待人工处理", tone: "warning" },
|
||||
succeeded: { label: "成功", tone: "success" },
|
||||
failed: { label: "失败", tone: "danger" },
|
||||
} as const satisfies Record<StepStatus, StatusMeta>;
|
||||
|
||||
export const REVIEW_DECISION_META = {
|
||||
approve: { label: "通过", tone: "success" },
|
||||
rerun_scene: { label: "重跑场景", tone: "warning" },
|
||||
rerun_face: { label: "重跑面部", tone: "warning" },
|
||||
rerun_fusion: { label: "重跑融合", tone: "warning" },
|
||||
reject: { label: "驳回", tone: "danger" },
|
||||
} as const satisfies Record<ReviewDecision, StatusMeta>;
|
||||
|
||||
export const WORKFLOW_STEP_META = {
|
||||
prepare_model: { label: "模型准备", tone: "neutral" },
|
||||
tryon: { label: "试穿生成", tone: "neutral" },
|
||||
scene: { label: "场景处理", tone: "neutral" },
|
||||
texture: { label: "纹理修复", tone: "neutral" },
|
||||
face: { label: "面部修复", tone: "neutral" },
|
||||
fusion: { label: "融合", tone: "neutral" },
|
||||
qc: { label: "质检", tone: "neutral" },
|
||||
export: { label: "导出", tone: "neutral" },
|
||||
review: { label: "人工审核", tone: "warning" },
|
||||
} as const satisfies Record<WorkflowStepName, StatusMeta>;
|
||||
|
||||
export function getOrderStatusMeta(status: OrderStatus): StatusMeta {
|
||||
return ORDER_STATUS_META[status];
|
||||
}
|
||||
|
||||
export function getReviewDecisionMeta(decision: ReviewDecision): StatusMeta {
|
||||
return REVIEW_DECISION_META[decision];
|
||||
}
|
||||
|
||||
export function getStepStatusMeta(status: StepStatus): StatusMeta {
|
||||
return STEP_STATUS_META[status];
|
||||
}
|
||||
|
||||
export function getWorkflowStepMeta(
|
||||
stepName: WorkflowStepName | null,
|
||||
): StatusMeta {
|
||||
if (!stepName) {
|
||||
return { label: "未开始", tone: "neutral" };
|
||||
}
|
||||
|
||||
return WORKFLOW_STEP_META[stepName];
|
||||
}
|
||||
223
src/lib/types/view-models.ts
Normal file
223
src/lib/types/view-models.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type {
|
||||
AssetType,
|
||||
CustomerLevel,
|
||||
JsonObject,
|
||||
OrderStatus,
|
||||
ReviewDecision,
|
||||
ReviewTaskStatus,
|
||||
ServiceMode,
|
||||
StepStatus,
|
||||
WorkflowStepName,
|
||||
} from "@/lib/types/backend";
|
||||
import type { StatusMeta } from "@/lib/types/status";
|
||||
|
||||
export type ReadyState = {
|
||||
kind: "ready";
|
||||
};
|
||||
|
||||
export type BusinessEmptyState = {
|
||||
kind: "business-empty";
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type SectionState = ReadyState | BusinessEmptyState;
|
||||
|
||||
export type AssetViewModel = {
|
||||
id: number;
|
||||
orderId: number;
|
||||
type: AssetType;
|
||||
stepName: WorkflowStepName | null;
|
||||
parentAssetId: number | null;
|
||||
rootAssetId: number | null;
|
||||
versionNo: number;
|
||||
isCurrentVersion: boolean;
|
||||
stepLabel: string;
|
||||
label: string;
|
||||
uri: string;
|
||||
metadata: JsonObject | null;
|
||||
createdAt: string;
|
||||
isMock: boolean;
|
||||
};
|
||||
|
||||
export type OrderSummaryVM = {
|
||||
orderId: number;
|
||||
workflowId: string | null;
|
||||
status: OrderStatus;
|
||||
statusMeta: StatusMeta;
|
||||
currentStep: WorkflowStepName | null;
|
||||
currentStepLabel: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type OrderDetailVM = {
|
||||
orderId: number;
|
||||
workflowId: string | null;
|
||||
customerLevel: CustomerLevel;
|
||||
serviceMode: ServiceMode;
|
||||
status: OrderStatus;
|
||||
statusMeta: StatusMeta;
|
||||
currentStep: WorkflowStepName | null;
|
||||
currentStepLabel: string;
|
||||
modelId: number;
|
||||
poseId: number;
|
||||
garmentAssetId: number;
|
||||
sceneRefAssetId: number;
|
||||
currentRevisionAssetId: number | null;
|
||||
currentRevisionVersion: number | null;
|
||||
latestRevisionAssetId: number | null;
|
||||
latestRevisionVersion: number | null;
|
||||
revisionCount: number;
|
||||
reviewTaskStatus: ReviewTaskStatus | null;
|
||||
pendingManualConfirm: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
finalAsset: AssetViewModel | null;
|
||||
finalAssetState: SectionState;
|
||||
assets: AssetViewModel[];
|
||||
assetGalleryState: SectionState;
|
||||
hasMockAssets: boolean;
|
||||
};
|
||||
|
||||
export type ReviewQueueItemVM = {
|
||||
reviewTaskId: number;
|
||||
orderId: number;
|
||||
workflowId: string;
|
||||
workflowType: string | null;
|
||||
currentStep: WorkflowStepName | null;
|
||||
currentStepLabel: string;
|
||||
createdAt: string;
|
||||
status: "waiting_review";
|
||||
statusMeta: StatusMeta;
|
||||
reviewTaskStatus: ReviewTaskStatus;
|
||||
latestRevisionAssetId: number | null;
|
||||
currentRevisionAssetId: number | null;
|
||||
latestRevisionVersion: number | null;
|
||||
revisionCount: number;
|
||||
pendingManualConfirm: boolean;
|
||||
hasMockAssets: boolean;
|
||||
failureCount: number;
|
||||
};
|
||||
|
||||
export type ReviewQueueVM = {
|
||||
items: ReviewQueueItemVM[];
|
||||
state: SectionState;
|
||||
};
|
||||
|
||||
export type ReviewSubmissionVM = {
|
||||
orderId: number;
|
||||
workflowId: string;
|
||||
revisionAssetId?: number;
|
||||
decision: ReviewDecision;
|
||||
decisionMeta: StatusMeta;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type RevisionRegistrationVM = {
|
||||
orderId: number;
|
||||
workflowId: string;
|
||||
assetId: number;
|
||||
parentAssetId: number;
|
||||
rootAssetId: number;
|
||||
versionNo: number;
|
||||
reviewTaskStatus: ReviewTaskStatus;
|
||||
latestRevisionAssetId: number;
|
||||
revisionCount: number;
|
||||
};
|
||||
|
||||
export type RevisionChainItemVM = {
|
||||
assetId: number;
|
||||
orderId: number;
|
||||
parentAssetId: number | null;
|
||||
rootAssetId: number | null;
|
||||
versionNo: number;
|
||||
isCurrentVersion: boolean;
|
||||
uri: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type RevisionChainVM = {
|
||||
orderId: number;
|
||||
latestRevisionAssetId: number | null;
|
||||
revisionCount: number;
|
||||
items: RevisionChainItemVM[];
|
||||
};
|
||||
|
||||
export type WorkflowStepVM = {
|
||||
id: number;
|
||||
workflowRunId: number;
|
||||
name: WorkflowStepName;
|
||||
label: string;
|
||||
status: StepStatus;
|
||||
statusMeta: StatusMeta;
|
||||
input: JsonObject | null;
|
||||
output: JsonObject | null;
|
||||
errorMessage: string | null;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
containsMockAssets: boolean;
|
||||
mockAssetUris: string[];
|
||||
isCurrent: boolean;
|
||||
isFailed: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowLookupItemVM = {
|
||||
orderId: number;
|
||||
workflowId: string;
|
||||
workflowType: string;
|
||||
status: OrderStatus;
|
||||
statusMeta: StatusMeta;
|
||||
currentStep: WorkflowStepName | null;
|
||||
currentStepLabel: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type WorkflowDetailVM = {
|
||||
orderId: number;
|
||||
workflowId: string;
|
||||
workflowType: string;
|
||||
status: OrderStatus;
|
||||
statusMeta: StatusMeta;
|
||||
currentStep: WorkflowStepName | null;
|
||||
currentStepLabel: string;
|
||||
currentRevisionAssetId: number | null;
|
||||
currentRevisionVersion: number | null;
|
||||
latestRevisionAssetId: number | null;
|
||||
latestRevisionVersion: number | null;
|
||||
revisionCount: number;
|
||||
reviewTaskStatus: ReviewTaskStatus | null;
|
||||
pendingManualConfirm: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
steps: WorkflowStepVM[];
|
||||
stepTimelineState: SectionState;
|
||||
failureCount: number;
|
||||
hasMockAssets: boolean;
|
||||
};
|
||||
|
||||
export type LibraryType = "models" | "scenes" | "garments";
|
||||
|
||||
export type LibraryItemVM = {
|
||||
id: string;
|
||||
libraryType: LibraryType;
|
||||
name: string;
|
||||
description: string;
|
||||
previewUri: string;
|
||||
tags: string[];
|
||||
isMock: boolean;
|
||||
};
|
||||
|
||||
export const READY_STATE: ReadyState = {
|
||||
kind: "ready",
|
||||
};
|
||||
|
||||
export function businessEmptyState(
|
||||
title: string,
|
||||
description: string,
|
||||
): BusinessEmptyState {
|
||||
return {
|
||||
kind: "business-empty",
|
||||
title,
|
||||
description,
|
||||
};
|
||||
}
|
||||
45
src/lib/validation/create-order.ts
Normal file
45
src/lib/validation/create-order.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CreateOrderRequestDto } from "@/lib/types/backend";
|
||||
import { RouteError } from "@/lib/http/response";
|
||||
|
||||
export const createOrderSchema = z
|
||||
.object({
|
||||
customer_level: z.enum(["low", "mid"]),
|
||||
service_mode: z.enum(["auto_basic", "semi_pro"]),
|
||||
model_id: z.number().int().positive(),
|
||||
pose_id: z.number().int().positive(),
|
||||
garment_asset_id: z.number().int().positive(),
|
||||
scene_ref_asset_id: z.number().int().positive(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const validServiceMode =
|
||||
(value.customer_level === "low" &&
|
||||
value.service_mode === "auto_basic") ||
|
||||
(value.customer_level === "mid" && value.service_mode === "semi_pro");
|
||||
|
||||
if (!validServiceMode) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["service_mode"],
|
||||
message: "当前客户等级不支持该服务模式。",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function parseCreateOrderPayload(
|
||||
payload: unknown,
|
||||
): CreateOrderRequestDto {
|
||||
const result = createOrderSchema.safeParse(payload);
|
||||
|
||||
if (!result.success) {
|
||||
throw new RouteError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
"提单参数不合法。",
|
||||
result.error.flatten(),
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
61
src/lib/validation/review-action.ts
Normal file
61
src/lib/validation/review-action.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { SubmitReviewRequestDto } from "@/lib/types/backend";
|
||||
import { RouteError } from "@/lib/http/response";
|
||||
|
||||
const rerunDecisions = new Set(["rerun_scene", "rerun_face", "rerun_fusion"]);
|
||||
|
||||
export const reviewActionSchema = z
|
||||
.object({
|
||||
decision: z.enum([
|
||||
"approve",
|
||||
"rerun_scene",
|
||||
"rerun_face",
|
||||
"rerun_fusion",
|
||||
"reject",
|
||||
]),
|
||||
reviewer_id: z.number().int().positive(),
|
||||
selected_asset_id: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
comment: z
|
||||
.union([z.string(), z.null(), z.undefined()])
|
||||
.transform((value) => {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (rerunDecisions.has(value.decision) && !value.comment) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["comment"],
|
||||
message: "重跑类审核动作必须填写原因说明。",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function parseReviewActionPayload(
|
||||
payload: unknown,
|
||||
): SubmitReviewRequestDto {
|
||||
const result = reviewActionSchema.safeParse(payload);
|
||||
|
||||
if (!result.success) {
|
||||
throw new RouteError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
"审核动作参数不合法。",
|
||||
result.error.flatten(),
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
64
src/lib/validation/revision.ts
Normal file
64
src/lib/validation/revision.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
type ConfirmRevisionRequestDto,
|
||||
type RegisterRevisionRequestDto,
|
||||
} from "@/lib/types/backend";
|
||||
import { RouteError } from "@/lib/http/response";
|
||||
|
||||
const nullableTrimmedString = z
|
||||
.union([z.string(), z.null(), z.undefined()])
|
||||
.transform((value) => {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
});
|
||||
|
||||
const registerRevisionSchema = z.object({
|
||||
parent_asset_id: z.number().int().positive(),
|
||||
uploaded_uri: z.string().trim().min(1),
|
||||
reviewer_id: z.number().int().positive(),
|
||||
comment: nullableTrimmedString,
|
||||
});
|
||||
|
||||
const confirmRevisionSchema = z.object({
|
||||
reviewer_id: z.number().int().positive(),
|
||||
comment: nullableTrimmedString,
|
||||
});
|
||||
|
||||
export function parseRegisterRevisionPayload(
|
||||
payload: unknown,
|
||||
): RegisterRevisionRequestDto {
|
||||
const result = registerRevisionSchema.safeParse(payload);
|
||||
|
||||
if (!result.success) {
|
||||
throw new RouteError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
"人工修订登记参数不合法。",
|
||||
result.error.flatten(),
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export function parseConfirmRevisionPayload(
|
||||
payload: unknown,
|
||||
): ConfirmRevisionRequestDto {
|
||||
const result = confirmRevisionSchema.safeParse(payload);
|
||||
|
||||
if (!result.success) {
|
||||
throw new RouteError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
"确认修订参数不合法。",
|
||||
result.error.flatten(),
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
Reference in New Issue
Block a user