Add a "列设置" dropdown next to the results header that lets users check/uncheck columns to show or hide them. Preferences persist across page refreshes via localStorage. Also add allowedDevOrigins to next.config.ts for LAN access. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
534 lines
25 KiB
TypeScript
534 lines
25 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback, useRef } from "react";
|
||
import {
|
||
Search, ChevronLeft, ChevronRight, ChevronUp, ChevronDown, X,
|
||
CheckCircle2, Circle, ArrowUpDown, Boxes, SlidersHorizontal, Loader2,
|
||
Settings2, Check,
|
||
} from "lucide-react";
|
||
import type { WarehouseRow, QueryResult, FilterOptions } from "@/types/warehouse";
|
||
|
||
/* ── 品牌色:沿用原 Excel 的深蓝表头 + 淡蓝隔行 ── */
|
||
const NAVY = "#1F497D";
|
||
const NAVY_DARK = "#163758";
|
||
|
||
const FONT =
|
||
'"PingFang SC","Microsoft YaHei","Noto Sans SC",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif';
|
||
|
||
/* ── 列定义(顺序与原 Excel 表头一致)── */
|
||
const COLUMNS = [
|
||
{ key: "paichan", label: "排产号", mono: true, align: "left" },
|
||
{ key: "zongpai", label: "总排号", mono: true, align: "left" },
|
||
{ key: "workshop", label: "车间", align: "left" },
|
||
{ key: "model", label: "产品型号", strong: true, align: "left" },
|
||
{ key: "range", label: "量程", align: "left" },
|
||
{ key: "qty", label: "数量", num: true, align: "right" },
|
||
{ key: "boxNo", label: "箱号", mono: true, align: "left" },
|
||
{ key: "boxQty", label: "装箱数量", num: true, align: "right" },
|
||
{ key: "workOrder", label: "工令号", mono: true, align: "left" },
|
||
{ key: "shelf", label: "货架", mono: true, align: "left" },
|
||
{ key: "handler", label: "经办人", align: "left" },
|
||
{ key: "orderNo", label: "订单号", mono: true, align: "left" },
|
||
{ key: "inbound", label: "入库时间", mono: true, align: "left" },
|
||
];
|
||
|
||
/* ── Sub-components ── */
|
||
|
||
function SortIcon({ active, dir }: { active: boolean; dir: string }) {
|
||
if (!active) return <ArrowUpDown className="h-3.5 w-3.5 opacity-40" />;
|
||
return dir === "asc" ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />;
|
||
}
|
||
|
||
function StatusBadge({ status }: { status: "IN" | "WAIT" }) {
|
||
if (status === "IN") {
|
||
return (
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-medium text-emerald-700 ring-1 ring-emerald-200">
|
||
<CheckCircle2 className="h-3.5 w-3.5" /> 已入库
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-0.5 text-xs font-medium text-amber-700 ring-1 ring-amber-200">
|
||
<Circle className="h-3.5 w-3.5" /> 待入库
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function Field({ label, children, className = "" }: { label: string; children: React.ReactNode; className?: string }) {
|
||
return (
|
||
<div className={`flex flex-col gap-1 ${className}`}>
|
||
<label className="text-xs font-medium text-slate-500">{label}</label>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SelectWrap({ value, onChange, children }: { value: string; onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void; children: React.ReactNode }) {
|
||
return (
|
||
<div className="relative">
|
||
<select
|
||
value={value}
|
||
onChange={onChange}
|
||
className="w-full appearance-none rounded-lg border border-slate-200 bg-slate-50 py-2 pl-3 pr-8 text-sm text-slate-700 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||
>
|
||
{children}
|
||
</select>
|
||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Chip({ children, onRemove }: { children: React.ReactNode; onRemove: () => void }) {
|
||
return (
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 ring-1 ring-blue-200">
|
||
{children}
|
||
<button onClick={onRemove} className="grid h-4 w-4 place-items-center rounded-full hover:bg-blue-200/70">
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/* ── Main Page Component ── */
|
||
|
||
export default function WarehouseDashboard() {
|
||
const [search, setSearch] = useState("");
|
||
const [workshop, setWorkshop] = useState("ALL");
|
||
const [handler, setHandler] = useState("ALL");
|
||
const [status, setStatus] = useState<"ALL" | "IN" | "WAIT">("ALL");
|
||
const [dateFrom, setDateFrom] = useState("");
|
||
const [dateTo, setDateTo] = useState("");
|
||
const [sortKey, setSortKey] = useState("");
|
||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(10);
|
||
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [data, setData] = useState<QueryResult | null>(null);
|
||
const [filterOptions, setFilterOptions] = useState<FilterOptions | null>(null);
|
||
|
||
/* ── Column visibility ── */
|
||
const ALL_COL_KEYS = COLUMNS.map((c) => c.key);
|
||
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(() => new Set(ALL_COL_KEYS));
|
||
const [showColumnConfig, setShowColumnConfig] = useState(false);
|
||
const columnConfigRef = useRef<HTMLDivElement>(null);
|
||
|
||
/* Hydrate from localStorage after mount to avoid SSR mismatch */
|
||
useEffect(() => {
|
||
try {
|
||
const saved = localStorage.getItem("warehouse-visible-columns");
|
||
if (saved) setVisibleKeys(new Set(JSON.parse(saved) as string[]));
|
||
} catch { /* ignore */ }
|
||
}, []);
|
||
|
||
const visibleColumns = COLUMNS.filter((c) => visibleKeys.has(c.key));
|
||
|
||
/* Persist column visibility to localStorage */
|
||
useEffect(() => {
|
||
localStorage.setItem("warehouse-visible-columns", JSON.stringify([...visibleKeys]));
|
||
}, [visibleKeys]);
|
||
|
||
/* Close column config on outside click */
|
||
useEffect(() => {
|
||
if (!showColumnConfig) return;
|
||
const handler = (e: MouseEvent) => {
|
||
if (columnConfigRef.current && !columnConfigRef.current.contains(e.target as Node)) {
|
||
setShowColumnConfig(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handler);
|
||
return () => document.removeEventListener("mousedown", handler);
|
||
}, [showColumnConfig]);
|
||
|
||
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
/* Fetch filter options on mount */
|
||
useEffect(() => {
|
||
(async () => {
|
||
try {
|
||
const res = await fetch("/api/warehouse/filters");
|
||
if (!res.ok) throw new Error("Failed to load filter options");
|
||
const json: FilterOptions = await res.json();
|
||
setFilterOptions(json);
|
||
} catch {
|
||
// Filter options are not critical — silently degrade
|
||
}
|
||
})();
|
||
}, []);
|
||
|
||
/* Build fetch callback */
|
||
const fetchData = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
const params = new URLSearchParams();
|
||
if (search) params.set("search", search);
|
||
if (workshop !== "ALL") params.set("workshop", workshop);
|
||
if (handler !== "ALL") params.set("handler", handler);
|
||
if (status !== "ALL") params.set("status", status);
|
||
if (dateFrom) params.set("dateFrom", dateFrom);
|
||
if (dateTo) params.set("dateTo", dateTo);
|
||
if (sortKey) {
|
||
params.set("sortKey", sortKey);
|
||
params.set("sortDir", sortDir);
|
||
}
|
||
params.set("page", String(page));
|
||
params.set("pageSize", String(pageSize));
|
||
|
||
try {
|
||
const res = await fetch(`/api/warehouse?${params}`);
|
||
if (!res.ok) throw new Error("Query failed");
|
||
const json: QueryResult = await res.json();
|
||
setData(json);
|
||
} catch (e) {
|
||
setError(String(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [search, workshop, handler, status, dateFrom, dateTo, sortKey, sortDir, page, pageSize]);
|
||
|
||
/* Debounced effect: fetch data when any dependency changes */
|
||
useEffect(() => {
|
||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||
searchTimeout.current = setTimeout(() => {
|
||
fetchData();
|
||
}, search ? 300 : 0);
|
||
return () => {
|
||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||
};
|
||
}, [fetchData, search]);
|
||
|
||
/* Derived values */
|
||
const total = data?.total ?? 0;
|
||
const totalPages = data?.totalPages ?? 1;
|
||
const safePage = Math.min(page, totalPages);
|
||
const start = (safePage - 1) * pageSize;
|
||
const rows: WarehouseRow[] = data?.rows ?? [];
|
||
|
||
const filtersActive = search || workshop !== "ALL" || handler !== "ALL" || status !== "ALL" || dateFrom || dateTo;
|
||
|
||
const toggleSort = (key: string) => {
|
||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||
else { setSortKey(key); setSortDir("asc"); }
|
||
setPage(1);
|
||
};
|
||
|
||
const resetFilters = () => {
|
||
setSearch(""); setWorkshop("ALL"); setHandler("ALL"); setStatus("ALL");
|
||
setDateFrom(""); setDateTo(""); setSortKey(""); setSortDir("asc");
|
||
setPage(1);
|
||
};
|
||
|
||
const STATUS_LABEL: Record<string, string> = { IN: "已入库", WAIT: "待入库" };
|
||
const dateInputCls =
|
||
"rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-200";
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-100 antialiased" style={{ fontFamily: FONT }}>
|
||
{/* ── 顶部应用栏 ── */}
|
||
<header className="text-white" style={{ background: `linear-gradient(135deg, ${NAVY} 0%, ${NAVY_DARK} 100%)` }}>
|
||
<div className="mx-auto flex max-w-screen-2xl flex-wrap items-center justify-between gap-4 px-6 py-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-white/10 ring-1 ring-white/20">
|
||
<Boxes className="h-6 w-6 text-white" />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-lg font-semibold leading-tight tracking-tight">成品仓库数据查询系统</h1>
|
||
<p className="text-xs text-white/70">Finished Goods Warehouse · 成品库存实时查询</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hidden items-center gap-2 rounded-lg bg-white/10 px-3 py-1.5 text-xs ring-1 ring-white/15 sm:flex">
|
||
<span className="relative flex h-2 w-2">
|
||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" />
|
||
</span>
|
||
<span className="text-white/90">实时数据</span>
|
||
<span className="text-white/40">·</span>
|
||
<span className="font-mono text-white/80">CompanyDB · 192.168.110.114</span>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="mx-auto max-w-screen-2xl space-y-5 px-6 py-6">
|
||
{/* ── 查询条件(核心)── */}
|
||
<section className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||
<div className="flex items-center gap-2 border-b border-slate-200 px-5 py-3">
|
||
<SlidersHorizontal className="h-4 w-4 text-slate-500" />
|
||
<h2 className="text-sm font-semibold text-slate-700">查询条件</h2>
|
||
<span className="ml-auto text-sm text-slate-500">
|
||
匹配 <span className="font-semibold tabular-nums text-slate-800">{total}</span> 条结果
|
||
</span>
|
||
</div>
|
||
|
||
<div className="space-y-4 p-5">
|
||
{/* 主搜索框 */}
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" />
|
||
<input
|
||
value={search}
|
||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||
placeholder="输入 总排号 / 工令号 / 订单号 / 产品型号 / 箱号 / 货架 进行查询…"
|
||
className="w-full rounded-xl border border-slate-200 bg-slate-50 py-3 pl-11 pr-10 text-base text-slate-800 placeholder:text-slate-400 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||
/>
|
||
{search && (
|
||
<button
|
||
onClick={() => { setSearch(""); setPage(1); }}
|
||
className="absolute right-3 top-1/2 grid h-6 w-6 -translate-y-1/2 place-items-center rounded-md text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 筛选项 */}
|
||
<div className="flex flex-wrap gap-4">
|
||
<Field label="车间" className="w-44">
|
||
<SelectWrap value={workshop} onChange={(e) => { setWorkshop(e.target.value); setPage(1); }}>
|
||
<option value="ALL">全部车间</option>
|
||
{filterOptions?.workshops.map((w) => (
|
||
<option key={w.code} value={w.code}>{w.name}</option>
|
||
))}
|
||
</SelectWrap>
|
||
</Field>
|
||
|
||
<Field label="经办人" className="w-40">
|
||
<SelectWrap value={handler} onChange={(e) => { setHandler(e.target.value); setPage(1); }}>
|
||
<option value="ALL">全部经办人</option>
|
||
{filterOptions?.handlers.map((h) => (
|
||
<option key={h} value={h}>{h}</option>
|
||
))}
|
||
</SelectWrap>
|
||
</Field>
|
||
|
||
<Field label="状态" className="w-36">
|
||
<SelectWrap value={status} onChange={(e) => { setStatus(e.target.value as "ALL" | "IN" | "WAIT"); setPage(1); }}>
|
||
<option value="ALL">全部状态</option>
|
||
<option value="IN">已入库</option>
|
||
<option value="WAIT">待入库</option>
|
||
</SelectWrap>
|
||
</Field>
|
||
|
||
<Field label="入库时间">
|
||
<div className="flex items-center gap-2">
|
||
<input type="date" value={dateFrom} onChange={(e) => { setDateFrom(e.target.value); setPage(1); }} className={dateInputCls} />
|
||
<span className="text-sm text-slate-400">至</span>
|
||
<input type="date" value={dateTo} onChange={(e) => { setDateTo(e.target.value); setPage(1); }} className={dateInputCls} />
|
||
</div>
|
||
</Field>
|
||
</div>
|
||
|
||
{/* 已选条件标签 */}
|
||
{filtersActive && (
|
||
<div className="flex flex-wrap items-center gap-2 border-t border-slate-100 pt-3">
|
||
<span className="text-xs text-slate-400">已选条件</span>
|
||
{search && <Chip onRemove={() => { setSearch(""); setPage(1); }}>关键词:{search}</Chip>}
|
||
{workshop !== "ALL" && (
|
||
<Chip onRemove={() => { setWorkshop("ALL"); setPage(1); }}>
|
||
车间:{filterOptions?.workshops.find(w => w.code === workshop)?.name ?? workshop}
|
||
</Chip>
|
||
)}
|
||
{handler !== "ALL" && <Chip onRemove={() => { setHandler("ALL"); setPage(1); }}>经办人:{handler}</Chip>}
|
||
{status !== "ALL" && <Chip onRemove={() => { setStatus("ALL"); setPage(1); }}>状态:{STATUS_LABEL[status]}</Chip>}
|
||
{dateFrom && <Chip onRemove={() => { setDateFrom(""); setPage(1); }}>入库起:{dateFrom}</Chip>}
|
||
{dateTo && <Chip onRemove={() => { setDateTo(""); setPage(1); }}>入库止:{dateTo}</Chip>}
|
||
<button onClick={resetFilters} className="ml-1 text-xs text-slate-500 hover:text-slate-700 hover:underline">
|
||
清除全部
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{/* ── 查询结果 ── */}
|
||
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-4 py-3">
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-sm font-semibold text-slate-700">查询结果</h2>
|
||
<div className="relative" ref={columnConfigRef}>
|
||
<button
|
||
onClick={() => setShowColumnConfig((v) => !v)}
|
||
className={`grid h-7 w-7 place-items-center rounded-lg border text-slate-500 transition hover:bg-slate-100 ${showColumnConfig ? "border-blue-300 bg-blue-50 text-blue-600" : "border-slate-200"}`}
|
||
title="列设置"
|
||
>
|
||
<Settings2 className="h-4 w-4" />
|
||
</button>
|
||
{showColumnConfig && (
|
||
<div className="absolute left-0 top-full z-30 mt-1 w-52 rounded-xl border border-slate-200 bg-white py-2 shadow-lg">
|
||
<div className="max-h-72 overflow-y-auto px-1">
|
||
{COLUMNS.map((col) => (
|
||
<label
|
||
key={col.key}
|
||
className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
|
||
>
|
||
<span className={`grid h-4 w-4 shrink-0 place-items-center rounded border ${visibleKeys.has(col.key) ? "border-blue-500 bg-blue-500 text-white" : "border-slate-300 bg-white"}`}>
|
||
{visibleKeys.has(col.key) && <Check className="h-3 w-3" strokeWidth={3} />}
|
||
</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={visibleKeys.has(col.key)}
|
||
onChange={() => {
|
||
setVisibleKeys((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(col.key)) next.delete(col.key);
|
||
else next.add(col.key);
|
||
return next;
|
||
});
|
||
}}
|
||
className="sr-only"
|
||
/>
|
||
{col.label}
|
||
</label>
|
||
))}
|
||
</div>
|
||
<div className="flex gap-2 border-t border-slate-100 px-3 pt-2">
|
||
<button
|
||
onClick={() => setVisibleKeys(new Set(ALL_COL_KEYS))}
|
||
className="flex-1 rounded-lg bg-slate-100 px-2 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-200"
|
||
>
|
||
全选
|
||
</button>
|
||
<button
|
||
onClick={() => setVisibleKeys(new Set())}
|
||
className="flex-1 rounded-lg bg-slate-100 px-2 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-200"
|
||
>
|
||
取消全选
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<span className="text-xs text-slate-400">数据来源:生产合同 + 装箱明细 + 库位 + 入库记录(LEFT JOIN)</span>
|
||
</div>
|
||
|
||
{/* Error banner */}
|
||
{error && (
|
||
<div className="border-b border-red-200 bg-red-50 px-5 py-3 text-sm text-red-700">
|
||
查询出错:{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Loading / Table area */}
|
||
<div className="relative">
|
||
{loading && (
|
||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-white/70">
|
||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||
<span className="mt-2 text-sm text-slate-500">加载中...</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="overflow-auto" style={{ maxHeight: "60vh" }}>
|
||
<table className="w-full border-collapse text-sm">
|
||
<thead>
|
||
<tr>
|
||
{visibleColumns.map((col) => (
|
||
<th
|
||
key={col.key}
|
||
onClick={() => toggleSort(col.key)}
|
||
className={`sticky top-0 z-10 cursor-pointer select-none whitespace-nowrap px-4 py-3 font-semibold text-white transition hover:brightness-110 ${col.align === "right" ? "text-right" : "text-left"}`}
|
||
style={{ background: NAVY }}
|
||
>
|
||
<span className={`inline-flex items-center gap-1 ${col.align === "right" ? "flex-row-reverse" : ""}`}>
|
||
{col.label}
|
||
<SortIcon active={sortKey === col.key} dir={sortDir} />
|
||
</span>
|
||
</th>
|
||
))}
|
||
<th className="sticky top-0 z-10 whitespace-nowrap px-4 py-3 text-left font-semibold text-white" style={{ background: NAVY }}>
|
||
状态
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{!loading && rows.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={visibleColumns.length + 1} className="px-4 py-16 text-center">
|
||
<div className="flex flex-col items-center gap-2 text-slate-400">
|
||
<Search className="h-8 w-8" />
|
||
<div className="text-sm">未找到匹配的记录</div>
|
||
<button onClick={resetFilters} className="text-sm text-blue-600 hover:underline">清除查询条件</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
rows.map((r, i) => (
|
||
<tr
|
||
key={`${r.zongpai}-${r.boxNo}-${i}`}
|
||
className={`transition-colors ${i % 2 === 0 ? "bg-white hover:bg-slate-50" : "bg-blue-50 hover:bg-blue-100"}`}
|
||
>
|
||
{visibleColumns.map((col) => {
|
||
const raw = r[col.key as keyof WarehouseRow];
|
||
const empty = raw === "" || raw == null;
|
||
const align = col.align === "right" ? "text-right" : "text-left";
|
||
const font = col.mono ? "font-mono" : "";
|
||
const num = col.num ? "tabular-nums" : "";
|
||
const color = col.strong
|
||
? "font-medium text-slate-900"
|
||
: col.mono ? "text-slate-600" : "text-slate-700";
|
||
return (
|
||
<td key={col.key} className={`whitespace-nowrap border-b border-slate-100 px-4 py-2.5 ${align} ${font} ${num} ${color}`}>
|
||
{empty ? <span className="text-slate-300">—</span> : String(raw)}
|
||
</td>
|
||
);
|
||
})}
|
||
<td className="whitespace-nowrap border-b border-slate-100 px-4 py-2.5 text-left">
|
||
<StatusBadge status={r.status} />
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 分页 */}
|
||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-4 py-3">
|
||
<div className="text-sm text-slate-500">
|
||
显示第 <span className="font-medium tabular-nums text-slate-700">{total === 0 ? 0 : start + 1}</span>
|
||
–<span className="font-medium tabular-nums text-slate-700">{Math.min(start + pageSize, total)}</span> 条,
|
||
共 <span className="font-medium tabular-nums text-slate-700">{total}</span> 条
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||
每页
|
||
<div className="relative">
|
||
<select
|
||
value={pageSize}
|
||
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||
className="appearance-none rounded-lg border border-slate-200 bg-white py-1.5 pl-3 pr-7 text-sm text-slate-700 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||
>
|
||
<option value={10}>10</option>
|
||
<option value={20}>20</option>
|
||
<option value={50}>50</option>
|
||
</select>
|
||
<ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||
</div>
|
||
条
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||
disabled={safePage <= 1}
|
||
className="grid h-8 w-8 place-items-center rounded-lg border border-slate-200 bg-white text-slate-600 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</button>
|
||
<span className="px-2 text-sm tabular-nums text-slate-600">{safePage} / {totalPages}</span>
|
||
<button
|
||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||
disabled={safePage >= totalPages}
|
||
className="grid h-8 w-8 place-items-center rounded-lg border border-slate-200 bg-white text-slate-600 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|