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.
280 lines
9.6 KiB
280 lines
9.6 KiB
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
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";
|
|
}
|
|
|
|
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="处理 RPA 抓取、价格偏差与降级等系统预警"
|
|
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>
|
|
</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.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-md overflow-y-auto bg-surface p-6 shadow-modal"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-lg font-semibold">预警详情</h3>
|
|
<dl className="mt-4 space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<dt className="text-text-secondary">预警编号</dt>
|
|
<dd className="font-mono">{detail.id}</dd>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<dt className="text-text-secondary">报价单号</dt>
|
|
<dd className="font-mono">{detail.quote_id ?? "—"}</dd>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<dt className="text-text-secondary">触发时间</dt>
|
|
<dd>{formatDateTime(detail.created_at)}</dd>
|
|
</div>
|
|
</dl>
|
|
<div className="mt-4">
|
|
<p className="mb-1 text-sm font-medium text-text-secondary">
|
|
详情数据
|
|
</p>
|
|
<pre className="overflow-x-auto rounded-md bg-bg p-3 text-xs">
|
|
{JSON.stringify(detail.detail_json, null, 2)}
|
|
</pre>
|
|
</div>
|
|
<div className="mt-6 flex flex-wrap justify-end gap-3">
|
|
<SecondaryButton disabled={resolving} onClick={() => setDetail(null)}>
|
|
关闭
|
|
</SecondaryButton>
|
|
{(detail.alert_type === "RPA_FAILED" ||
|
|
detail.alert_type === "RPA_CAPTCHA") && (
|
|
<Link href="/admin/rpa">
|
|
<SecondaryButton type="button">跳转 RPA 暂停</SecondaryButton>
|
|
</Link>
|
|
)}
|
|
{detail.status === "open" && (
|
|
<PrimaryButton
|
|
loading={resolving}
|
|
onClick={() => void handleResolve(detail)}
|
|
>
|
|
标记已处理
|
|
</PrimaryButton>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</AdminLayout>
|
|
);
|
|
}
|