feat: rewrite orders page as dense list

This commit is contained in:
afei A
2026-03-28 00:25:45 +08:00
parent edd03b03a7
commit ae8ab2cf9c
8 changed files with 299 additions and 197 deletions

View File

@@ -0,0 +1,118 @@
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { StatusBadge } from "@/components/ui/status-badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { OrderSummaryVM } from "@/lib/types/view-models";
type OrdersTableProps = {
isLoading: boolean;
items: OrderSummaryVM[];
onOpenOrder?: (orderId: string) => void;
onOpenWorkflow?: (orderId: string) => void;
};
function formatTimestamp(timestamp: string) {
return timestamp.replace("T", " ").replace("Z", " UTC");
}
export function OrdersTable({
isLoading,
items,
onOpenOrder,
onOpenWorkflow,
}: OrdersTableProps) {
if (isLoading) {
return (
<div className="rounded-[var(--panel-radius)] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
</div>
);
}
if (!items.length) {
return (
<EmptyState
eyebrow="Orders empty"
title="当前筛选下没有订单"
description="调整关键词、状态或服务模式后再试。"
/>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>workflowId</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((order) => (
<TableRow key={order.orderId}>
<TableCell className="font-medium">#{order.orderId}</TableCell>
<TableCell>{order.workflowId ?? "未关联"}</TableCell>
<TableCell>{order.customerLevel}</TableCell>
<TableCell>{order.serviceMode}</TableCell>
<TableCell>
<StatusBadge status={order.status} />
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<StatusBadge variant="workflowStep" status={order.currentStep} />
<span className="text-xs text-[var(--ink-muted)]">
{order.currentStepLabel}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<span className="text-sm text-[var(--ink-strong)]">
{order.revisionCount}
</span>
{order.pendingManualConfirm ? (
<span className="rounded-full bg-[rgba(88,106,56,0.12)] px-2 py-0.5 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.14em] text-[#50633b]">
</span>
) : null}
</div>
</TableCell>
<TableCell className="text-xs text-[var(--ink-muted)]">
{formatTimestamp(order.updatedAt)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={() => onOpenOrder?.(String(order.orderId))}
>
</Button>
<Button
variant="ghost"
onClick={() => onOpenWorkflow?.(String(order.orderId))}
>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,97 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PageToolbar } from "@/components/ui/page-toolbar";
import { Select } from "@/components/ui/select";
import { ORDER_STATUS_META } from "@/lib/types/status";
import type { OrderStatus, ServiceMode } from "@/lib/types/backend";
export type OrderFilterStatus = OrderStatus | "all";
export type OrderFilterServiceMode = ServiceMode | "all";
type OrdersToolbarProps = {
currentPage: number;
query: string;
serviceMode: OrderFilterServiceMode;
status: OrderFilterStatus;
totalPages: number;
onPageChange?: (page: number) => void;
onQueryChange: (value: string) => void;
onQuerySubmit?: (query: string) => void;
onServiceModeChange: (value: OrderFilterServiceMode) => void;
onStatusChange?: (value: OrderFilterStatus) => void;
};
export function OrdersToolbar({
currentPage,
query,
serviceMode,
status,
totalPages,
onPageChange,
onQueryChange,
onQuerySubmit,
onServiceModeChange,
onStatusChange,
}: OrdersToolbarProps) {
const safeTotalPages = Math.max(totalPages, 1);
return (
<PageToolbar className="justify-between gap-4">
<div className="flex flex-1 flex-wrap items-center gap-3">
<Input
aria-label="订单关键词搜索"
className="min-w-[220px] flex-1 md:max-w-[320px]"
placeholder="搜索订单号或 workflow_id"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
/>
<Button onClick={() => onQuerySubmit?.(query.trim())}></Button>
<Select
aria-label="订单状态筛选"
value={status}
onChange={(event) =>
onStatusChange?.(event.target.value as OrderFilterStatus)
}
>
<option value="all"></option>
{Object.entries(ORDER_STATUS_META).map(([value, meta]) => (
<option key={value} value={value}>
{meta.label}
</option>
))}
</Select>
<Select
aria-label="服务模式筛选"
value={serviceMode}
onChange={(event) =>
onServiceModeChange(event.target.value as OrderFilterServiceMode)
}
>
<option value="all"></option>
<option value="auto_basic">auto_basic</option>
<option value="semi_pro">semi_pro</option>
</Select>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[var(--ink-muted)]">
{Math.min(currentPage, safeTotalPages)} / {safeTotalPages}
</span>
<Button
variant="secondary"
disabled={currentPage <= 1}
onClick={() => onPageChange?.(currentPage - 1)}
>
</Button>
<Button
variant="secondary"
disabled={currentPage >= safeTotalPages}
onClick={() => onPageChange?.(currentPage + 1)}
>
</Button>
</div>
</PageToolbar>
);
}

View File

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

View File

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

View File

@@ -43,10 +43,15 @@ export type AssetViewModel = {
export type OrderSummaryVM = { export type OrderSummaryVM = {
orderId: number; orderId: number;
workflowId: string | null; workflowId: string | null;
customerLevel: CustomerLevel;
serviceMode: ServiceMode;
status: OrderStatus; status: OrderStatus;
statusMeta: StatusMeta; statusMeta: StatusMeta;
currentStep: WorkflowStepName | null; currentStep: WorkflowStepName | null;
currentStepLabel: string; currentStepLabel: string;
reviewTaskStatus: ReviewTaskStatus | null;
revisionCount: number;
pendingManualConfirm: boolean;
updatedAt: string; updatedAt: string;
}; };

View File

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

View File

@@ -8,6 +8,8 @@ const RECENT_ORDERS: OrderSummaryVM[] = [
{ {
orderId: 4201, orderId: 4201,
workflowId: "wf-4201", workflowId: "wf-4201",
customerLevel: "mid",
serviceMode: "semi_pro",
status: "waiting_review", status: "waiting_review",
statusMeta: { statusMeta: {
label: "待审核", label: "待审核",
@@ -15,15 +17,20 @@ const RECENT_ORDERS: OrderSummaryVM[] = [
}, },
currentStep: "review", currentStep: "review",
currentStepLabel: "人工审核", currentStepLabel: "人工审核",
reviewTaskStatus: "revision_uploaded",
revisionCount: 1,
pendingManualConfirm: true,
updatedAt: "2026-03-27T09:15:00Z", updatedAt: "2026-03-27T09:15:00Z",
}, },
]; ];
test("shows the real recent-orders entry state", () => { test("renders orders as a high-density table with shared toolbar controls", () => {
render(<OrdersHome recentOrders={RECENT_ORDERS} />); render(<OrdersHome recentOrders={RECENT_ORDERS} />);
expect(screen.getByText("最近订单已接入真实后端接口")).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: "订单号" })).toBeInTheDocument();
expect(screen.getByText("订单 #4201")).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: "服务模式" })).toBeInTheDocument();
expect(screen.getByLabelText("订单状态筛选")).toBeInTheDocument();
expect(screen.getByText("#4201")).toBeInTheDocument();
}); });
test("supports status filtering and pagination actions", () => { test("supports status filtering and pagination actions", () => {

View File

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