You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

197 lines
6.8 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"use client";
import { useCallback, useEffect, useState } from "react";
import { AdminLayout } from "@/components/layout/admin-layout";
import { PageHeader } from "@/components/layout/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorBanner } from "@/components/ui/error-banner";
import { SecondaryButton } from "@/components/ui/primary-button";
import { SelectField } from "@/components/ui/select-field";
import { Card } from "@/components/ui/card";
import { formatDateTime } from "@/lib/frontend/format";
import { adminGetQueryLogs } from "@/lib/frontend/api-client";
import { useAuth } from "@/hooks/use-auth";
import type {
DataPageStatus,
QuoteQueryLogRecord,
QuoteQueryOutcome,
} from "@/lib/frontend/types";
const PAGE_SIZE = 20;
const OUTCOME_OPTIONS = [
{ value: "", label: "全部结果" },
{ value: "success", label: "成功" },
{ value: "failed", label: "失败" },
{ value: "stale", label: "降级缓存" },
{ value: "processing", label: "进行中" },
];
const OUTCOME_LABEL: Record<QuoteQueryOutcome, string> = {
success: "成功",
failed: "失败",
stale: "降级缓存",
processing: "进行中",
};
function outcomeClass(outcome: QuoteQueryOutcome): string {
if (outcome === "success") return "text-success";
if (outcome === "failed") return "text-error";
if (outcome === "stale") return "text-warning";
return "text-text-secondary";
}
export default function QueryLogsPage() {
const { token } = useAuth();
const [status, setStatus] = useState<DataPageStatus>("loading");
const [rows, setRows] = useState<QuoteQueryLogRecord[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [outcomeFilter, setOutcomeFilter] = useState("");
const load = useCallback(
async (p: number, outcome: string) => {
if (!token) return;
setStatus("loading");
const res = await adminGetQueryLogs(
"",
token,
p,
PAGE_SIZE,
undefined,
outcome || undefined,
);
if (res.code !== 0) {
setStatus("error");
return;
}
setRows(res.data.list);
setTotal(res.data.total);
setStatus(res.data.total === 0 ? "empty" : "success");
},
[token],
);
useEffect(() => {
void load(page, outcomeFilter);
}, [load, page, outcomeFilter]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<AdminLayout>
<PageHeader
title="查价记录"
subtitle="最近 1000 条询价流水:时间、客户、地址、货物与报价结果"
action={
<div className="w-36">
<SelectField
label=""
aria-label="查询结果"
name="outcome"
value={outcomeFilter}
options={OUTCOME_OPTIONS}
onChange={(e) => {
setPage(1);
setOutcomeFilter(e.target.value);
}}
/>
</div>
}
/>
{status === "loading" && (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{status === "error" && (
<ErrorBanner
action={<SecondaryButton onClick={() => void load(page, outcomeFilter)}></SecondaryButton>}
>
</ErrorBanner>
)}
{status === "empty" && (
<Card>
<p className="text-sm text-text-secondary"></p>
</Card>
)}
{status === "success" && (
<div className="space-y-4">
<div className="overflow-x-auto rounded-lg border border-border bg-surface">
<table className="min-w-full text-sm">
<thead className="border-b border-border bg-bg text-left text-text-secondary">
<tr>
<th className="px-3 py-3 font-medium"></th>
<th className="px-3 py-3 font-medium"></th>
<th className="px-3 py-3 font-medium"></th>
<th className="px-3 py-3 font-medium"></th>
<th className="px-3 py-3 font-medium"></th>
<th className="px-3 py-3 font-medium"></th>
<th className="px-3 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.quote_id} className="border-b border-border align-top">
<td className="px-3 py-3 whitespace-nowrap text-text-secondary">
{formatDateTime(row.created_at)}
</td>
<td className="px-3 py-3 font-mono text-xs">{row.customer_id}</td>
<td className="max-w-[180px] px-3 py-3 text-xs leading-relaxed">
{row.pickup_selected}
</td>
<td className="max-w-[180px] px-3 py-3 text-xs leading-relaxed">
{row.delivery_selected}
</td>
<td className="px-3 py-3 text-xs">{row.cargo_summary}</td>
<td className="px-3 py-3">
<span className={`text-xs font-medium ${outcomeClass(row.outcome)}`}>
{OUTCOME_LABEL[row.outcome]}
</span>
{row.failure_reason && row.outcome !== "success" && (
<p className="mt-1 max-w-[200px] text-xs text-text-secondary leading-relaxed">
{row.failure_reason}
</p>
)}
</td>
<td className="px-3 py-3 whitespace-nowrap text-text-secondary">
{row.completed_at ? formatDateTime(row.completed_at) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-secondary">
{total} 1000
</span>
<div className="flex gap-2">
<SecondaryButton
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
</SecondaryButton>
<span className="px-2 py-2 text-text-secondary">
{page} / {totalPages}
</span>
<SecondaryButton
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
</SecondaryButton>
</div>
</div>
</div>
)}
</AdminLayout>
);
}