|
|
"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>
|
|
|
);
|
|
|
}
|