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.

501 lines
17 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 {
buildQueryLogCargoCopy,
buildQueryLogDeliveryCopy,
buildQueryLogFullCopy,
buildQueryLogPickupCopy,
buildQueryLogQuotesCopy,
formatQueryLogQuoteLine,
preferredAdminAddress,
} from "@/lib/frontend/query-log-copy";
import type {
DataPageStatus,
QuoteQueryLogQuotePreview,
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 outcomeBadgeClass(outcome: QuoteQueryOutcome): string {
if (outcome === "success") return "bg-emerald-50 text-success";
if (outcome === "failed") return "bg-red-50 text-error";
if (outcome === "stale") return "bg-amber-50 text-warning";
return "bg-slate-100 text-text-secondary";
}
function formatQuotedTotal(value: number | null | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value)) {
return "—";
}
return `USD ${value.toFixed(2)}`;
}
function quotesOf(row: QuoteQueryLogRecord): QuoteQueryLogQuotePreview[] {
return Array.isArray(row.quotes) ? row.quotes : [];
}
function formatQuotedSummary(row: QuoteQueryLogRecord): {
primary: string;
secondary: string | null;
} {
const quotes = quotesOf(row);
if (quotes.length > 1) {
return {
primary: `${quotes.length} 档`,
secondary: `最低 ${formatQuotedTotal(row.quoted_total)}`,
};
}
if (quotes.length === 1) {
return {
primary: formatQuotedTotal(quotes[0]?.final_total ?? row.quoted_total),
secondary: quotes[0]?.carrier?.trim() || null,
};
}
return { primary: formatQuotedTotal(row.quoted_total), secondary: null };
}
function displayOrDash(value: string | null | undefined): string {
const t = (value ?? "").trim();
return t || "—";
}
async function copyText(value: string): Promise<boolean> {
const text = value.trim();
if (!text) return false;
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
function CopyButton({
text,
label = "复制",
}: {
text: string;
label?: string;
}) {
const [copied, setCopied] = useState(false);
const disabled = !text.trim();
return (
<button
type="button"
disabled={disabled}
onClick={() => {
void copyText(text).then((ok) => {
if (!ok) return;
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
});
}}
className="inline-flex h-8 shrink-0 items-center rounded-md border border-border bg-surface px-2.5 text-xs font-medium text-text-primary hover:bg-bg disabled:cursor-not-allowed disabled:opacity-40"
>
{copied ? "已复制" : label}
</button>
);
}
function DetailField({
label,
value,
copyTextValue,
copyLabel,
mono,
}: {
label: string;
value: string;
copyTextValue?: string;
copyLabel?: string;
mono?: boolean;
}) {
return (
<div className="border-b border-border py-3">
<div className="flex items-start justify-between gap-3">
<dt className="text-xs text-text-secondary">{label}</dt>
{copyTextValue ? (
<CopyButton text={copyTextValue} label={copyLabel ?? "复制"} />
) : null}
</div>
<dd
className={`mt-1 break-words text-sm leading-relaxed text-text-primary ${
mono ? "font-mono text-xs" : ""
}`}
>
{value}
</dd>
</div>
);
}
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 [detail, setDetail] = useState<QuoteQueryLogRecord | null>(null);
const load = useCallback(
async (p: number, outcome: string) => {
if (!token) return;
setStatus("loading");
const res = await adminGetQueryLogs(
"",
token,
p,
PAGE_SIZE,
undefined,
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-16 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-hidden rounded-lg border border-border bg-surface">
<table className="w-full table-fixed text-sm">
<colgroup>
<col className="w-[10.5rem]" />
<col className="w-[7.5rem]" />
<col />
<col className="w-[5.75rem]" />
<col className="w-[7.25rem]" />
<col className="w-[4.75rem]" />
</colgroup>
<thead className="border-b border-border bg-bg text-left text-text-secondary">
<tr>
<th className="px-4 py-3 font-medium">查询时间</th>
<th className="px-4 py-3 font-medium">客户</th>
<th className="px-4 py-3 font-medium">线路</th>
<th className="px-4 py-3 font-medium">结果</th>
<th className="px-4 py-3 font-medium">报价</th>
<th className="px-4 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const pickup = preferredAdminAddress(
row.pickup_selected,
row.pickup_customer,
);
const delivery = preferredAdminAddress(
row.delivery_selected,
row.delivery_customer,
);
return (
<tr
key={row.quote_id}
className="border-b border-border last:border-b-0"
>
<td className="px-4 py-3 align-middle text-text-secondary">
{formatDateTime(row.created_at)}
</td>
<td className="px-4 py-3 align-middle font-mono text-xs">
<span className="block truncate" title={row.customer_id}>
{row.customer_id}
</span>
</td>
<td className="px-4 py-3 align-middle">
<p
className="truncate text-text-primary"
title={pickup || "—"}
>
提 {pickup || "—"}
</p>
<p
className="mt-0.5 truncate text-text-secondary"
title={delivery || "—"}
>
送 {delivery || "—"}
</p>
</td>
<td className="px-4 py-3 align-middle">
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${outcomeBadgeClass(row.outcome)}`}
>
{OUTCOME_LABEL[row.outcome]}
</span>
</td>
<td className="px-4 py-3 align-middle text-text-primary">
{(() => {
const summary = formatQuotedSummary(row);
return (
<>
<p>{summary.primary}</p>
{summary.secondary ? (
<p className="mt-0.5 truncate text-xs text-text-secondary">
{summary.secondary}
</p>
) : null}
</>
);
})()}
</td>
<td className="px-4 py-3 align-middle">
<button
type="button"
onClick={() => setDetail(row)}
className="text-sm font-medium text-primary hover:underline"
>
详情
</button>
</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>
)}
{detail && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => setDetail(null)}
>
<div
className="flex h-full w-full max-w-lg flex-col bg-surface shadow-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal
aria-labelledby="query-log-detail-title"
>
<div className="flex items-start justify-between gap-3 border-b border-border px-6 py-4">
<div>
<h3
id="query-log-detail-title"
className="text-lg font-semibold text-text-primary"
>
询价详情
</h3>
<p className="mt-1 text-xs text-text-secondary">
可复制完整地址与货物参数
</p>
</div>
<CopyButton text={buildQueryLogFullCopy(detail)} label="复制全部" />
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
<dl>
<DetailField
label="报价单号"
value={detail.quote_id}
copyTextValue={detail.quote_id}
mono
/>
<DetailField
label="客户"
value={detail.customer_id}
copyTextValue={detail.customer_id}
mono
/>
<DetailField
label="查询时间"
value={formatDateTime(detail.created_at)}
/>
<DetailField
label="完成时间"
value={
detail.completed_at
? formatDateTime(detail.completed_at)
: "—"
}
/>
<DetailField
label="结果"
value={
detail.failure_reason && detail.outcome !== "success"
? `${OUTCOME_LABEL[detail.outcome]}:${detail.failure_reason}`
: OUTCOME_LABEL[detail.outcome]
}
/>
<DetailField
label="提货地址"
value={displayOrDash(
preferredAdminAddress(
detail.pickup_selected,
detail.pickup_customer,
),
)}
copyTextValue={buildQueryLogPickupCopy(detail)}
copyLabel="复制地址"
/>
<DetailField
label="派送地址"
value={displayOrDash(
preferredAdminAddress(
detail.delivery_selected,
detail.delivery_customer,
),
)}
copyTextValue={buildQueryLogDeliveryCopy(detail)}
copyLabel="复制地址"
/>
<DetailField
label="货物参数"
value={displayOrDash(detail.cargo_summary)}
copyTextValue={buildQueryLogCargoCopy(detail)}
copyLabel="复制参数"
/>
<div className="border-b border-border py-3">
<div className="flex items-start justify-between gap-3">
<dt className="text-xs text-text-secondary">
报价列表
{quotesOf(detail).length > 0
? `(${quotesOf(detail).length} 档)`
: ""}
</dt>
{quotesOf(detail).length > 0 ? (
<CopyButton
text={buildQueryLogQuotesCopy(quotesOf(detail))}
label="复制报价"
/>
) : null}
</div>
<dd className="mt-2 space-y-1.5">
{quotesOf(detail).length === 0 ? (
<p className="text-sm text-text-secondary">
{detail.quoted_total != null
? `${displayOrDash(detail.quoted_carrier)} · ${formatQuotedTotal(detail.quoted_total)}`
: "—"}
</p>
) : (
quotesOf(detail).map((q, i) => (
<p
key={`${q.carrier}-${q.service_level}-${q.rate_option}-${i}`}
className="text-sm leading-relaxed text-text-primary"
>
{formatQueryLogQuoteLine(q)}
</p>
))
)}
</dd>
</div>
</dl>
</div>
<div className="border-t border-border px-6 py-4">
<SecondaryButton onClick={() => setDetail(null)}>
关闭
</SecondaryButton>
</div>
</div>
</div>
)}
</AdminLayout>
);
}