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.

327 lines
11 KiB

"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 { PrimaryButton } from "@/components/ui/primary-button";
import { SelectField } from "@/components/ui/select-field";
import { Card } from "@/components/ui/card";
import {
ALERT_TYPE_LABEL,
ALERT_TYPE_TONE,
} from "@/lib/frontend/constants";
import { formatDateTime } from "@/lib/frontend/format";
import {
adminGetAlerts,
adminResolveAlert,
} from "@/lib/frontend/api-client";
import { useAuth } from "@/hooks/use-auth";
import type { AlertRecord, AlertType, DataPageStatus } from "@/lib/frontend/types";
const PAGE_SIZE = 10;
const TYPE_OPTIONS = [
{ value: "", label: "全部类型" },
...(
Object.keys(ALERT_TYPE_LABEL) as AlertType[]
).map((t) => ({ value: t, label: ALERT_TYPE_LABEL[t] })),
];
const STATUS_OPTIONS = [
{ value: "", label: "全部状态" },
{ value: "open", label: "待处理" },
{ value: "resolved", label: "已处理" },
];
function toneClass(tone: "warning" | "error" | "neutral"): string {
if (tone === "warning") return "bg-amber-100 text-warning";
if (tone === "error") return "bg-red-100 text-error";
return "bg-slate-100 text-text-secondary";
}
function DetailRow({
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="border-b border-border py-2">
<dt className="text-xs text-text-secondary">{label}</dt>
<dd className={`mt-0.5 text-sm ${mono ? "font-mono text-xs" : ""}`}>
{value}
</dd>
</div>
);
}
export default function AlertsPage() {
const { token, user } = useAuth();
const [status, setStatus] = useState<DataPageStatus>("loading");
const [rows, setRows] = useState<AlertRecord[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("open");
const [detail, setDetail] = useState<AlertRecord | null>(null);
const [resolving, setResolving] = useState(false);
const load = useCallback(
async (p: number, type: string, st: string) => {
if (!token) return;
setStatus("loading");
const res = await adminGetAlerts("", token, p, PAGE_SIZE, type || undefined, st || 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, typeFilter, statusFilter);
}, [load, page, typeFilter, statusFilter]);
const handleResolve = async (alert: AlertRecord) => {
if (!token || !user) return;
setResolving(true);
const res = await adminResolveAlert("", token, alert.id, user.user_id);
setResolving(false);
if (res.code !== 0) return;
setRows((list) =>
list.map((a) =>
a.id === alert.id
? { ...a, status: "resolved", resolved_at: new Date().toISOString() }
: a,
),
);
setDetail(null);
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<AdminLayout>
<PageHeader
title="预警中心"
subtitle="查看查价失败根因、客户地址货物信息,便于追查问题"
action={
<div className="flex gap-3">
<div className="w-40">
<SelectField
label=""
aria-label="预警类型"
name="type"
value={typeFilter}
options={TYPE_OPTIONS}
onChange={(e) => {
setPage(1);
setTypeFilter(e.target.value);
}}
/>
</div>
<div className="w-32">
<SelectField
label=""
aria-label="预警状态"
name="status"
value={statusFilter}
options={STATUS_OPTIONS}
onChange={(e) => {
setPage(1);
setStatusFilter(e.target.value);
}}
/>
</div>
</div>
}
/>
{status === "loading" && (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{status === "error" && (
<ErrorBanner
action={
<SecondaryButton onClick={() => void load(page, typeFilter, statusFilter)}>
</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-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) => (
<tr
key={row.id}
className="cursor-pointer border-b border-border hover:bg-bg"
onClick={() => setDetail(row)}
>
<td className="px-4 py-3 text-text-secondary">
{formatDateTime(row.created_at)}
</td>
<td className="px-4 py-3">
<span
className={`rounded-sm px-2 py-0.5 text-xs ${toneClass(ALERT_TYPE_TONE[row.alert_type])}`}
>
{ALERT_TYPE_LABEL[row.alert_type]}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs">
{row.customer_id ?? "—"}
</td>
<td className="px-4 py-3 font-mono text-xs">
{row.quote_id ?? "—"}
</td>
<td className="px-4 py-3">
{row.status === "open" ? (
<span className="text-error"></span>
) : (
<span className="text-success"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-secondary"> {total} </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={() => !resolving && setDetail(null)}
>
<div
className="h-full w-full max-w-lg overflow-y-auto bg-surface p-6 shadow-modal"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold"></h3>
<dl className="mt-4">
<DetailRow label="预警编号" value={detail.id} mono />
<DetailRow
label="类型"
value={ALERT_TYPE_LABEL[detail.alert_type]}
/>
<DetailRow
label="触发时间"
value={formatDateTime(detail.created_at)}
/>
<DetailRow
label="客户"
value={detail.customer_id ?? "—"}
mono
/>
<DetailRow
label="报价单号"
value={detail.quote_id ?? "—"}
mono
/>
</dl>
<div className="mt-4 rounded-md border border-error/30 bg-error/5 p-3">
<p className="text-xs font-medium text-error"></p>
<p className="mt-1 text-sm leading-relaxed">
{detail.root_cause ?? "暂无详细说明"}
</p>
</div>
<div className="mt-4 space-y-3">
<p className="text-sm font-medium"></p>
<DetailRow
label="提货地址(客户填写)"
value={detail.pickup_customer ?? "—"}
/>
<DetailRow
label="提货地址(联想确认)"
value={detail.pickup_selected ?? "—"}
/>
<DetailRow
label="派送地址(客户填写)"
value={detail.delivery_customer ?? "—"}
/>
<DetailRow
label="派送地址(联想确认)"
value={detail.delivery_selected ?? "—"}
/>
<DetailRow
label="货物信息"
value={detail.cargo_summary ?? "—"}
/>
</div>
<div className="mt-6 flex flex-wrap justify-end gap-3">
<SecondaryButton disabled={resolving} onClick={() => setDetail(null)}>
</SecondaryButton>
{detail.status === "open" && (
<PrimaryButton
loading={resolving}
onClick={() => void handleResolve(detail)}
>
</PrimaryButton>
)}
</div>
</div>
</div>
)}
</AdminLayout>
);
}