[feat]:[FL-178][雷电流幅值统计页面一致性优化]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-22 23:32:20 +08:00
parent 80dec2185e
commit 69c6c8c05e
+276 -51
View File
@@ -4,6 +4,8 @@ import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Card,
Col,
Descriptions,
Dropdown,
Empty,
@@ -12,23 +14,25 @@ import {
InputNumber,
Modal,
Popconfirm,
Row,
Space,
Table,
Tag,
Tooltip as AntTooltip,
Typography,
type CardProps,
type MenuProps,
} from "antd";
import { MoreOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import { MoreOutlined, QuestionCircleOutlined, EditOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { useAuth } from "@/components/auth-provider";
import { AdminPageLoading } from "@/components/admin-page-loading";
import { Card } from "@/components/ui-antd";
import { useToastFeedback } from "@/hooks/use-toast-feedback";
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { useMobileDetection } from "@/hooks/use-mobile-detection";
import { readApiError } from "@/lib/api";
import type {
LightningCurrentEventListResponse,
@@ -39,7 +43,8 @@ import type {
LightningCurrentSampleItem,
LightningPolarity,
} from "@/types/auth";
import type { CSSProperties } from "react";
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
type ImportFormValues = {
sample_interval_us: number;
@@ -87,9 +92,12 @@ const LIGHTNING_TABLE_FALLBACK_RESERVE = 220;
export default function AdminLightningCurrentsPage() {
const { user, initializing, hasPermission, fetchWithAuth } = useAuth();
const queryClient = useQueryClient();
const isMobile = useMobileDetection();
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const [importForm] = Form.useForm<ImportFormValues>();
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null);
const pageCardRef = useRef<HTMLDivElement | null>(null);
const keywordDebounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
@@ -103,20 +111,26 @@ export default function AdminLightningCurrentsPage() {
const [samplePageSize, setSamplePageSize] = useState(50);
const [keywordInput, setKeywordInput] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
const viewMode: "table" | "card" = isMobile ? "card" : "table";
const [cardViewPage, setCardViewPage] = useState(1);
const [allLoadedEvents, setAllLoadedEvents] = useState<LightningCurrentEventSummary[]>([]);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const canRead = hasPermission("lightning.read") || hasPermission("lightning.manage");
const canManage = hasPermission("lightning.manage");
const { current: paginationCurrent, pageSize: paginationPageSize } = pagination;
const trimmedKeyword = searchKeyword.trim();
const eventListParams = useMemo(() => {
const params = new URLSearchParams();
params.set("limit", "200");
params.set("offset", "0");
params.set("limit", String(paginationPageSize));
params.set("offset", String((paginationCurrent - 1) * paginationPageSize));
if (trimmedKeyword) {
params.set("keyword", trimmedKeyword);
}
return params.toString();
}, [trimmedKeyword]);
}, [paginationCurrent, paginationPageSize, trimmedKeyword]);
const eventListPath = `/api/v1/lightning-currents?${eventListParams}`;
const eventsQuery = useQuery({
@@ -231,6 +245,72 @@ export default function AdminLightningCurrentsPage() {
window.requestAnimationFrame(updateTableScrollY);
}, [error, listError, sampleError, statsError, events.length, eventsQuery.isFetching, updateTableScrollY]);
useEffect(() => {
return () => {
if (keywordDebounceTimeoutRef.current) {
clearTimeout(keywordDebounceTimeoutRef.current);
}
};
}, []);
useEffect(() => {
if (viewMode !== "card" || eventsQuery.isLoading) {
return;
}
const frameId = window.requestAnimationFrame(() => {
if (cardViewPage === 1) {
setAllLoadedEvents(() => events);
} else {
setAllLoadedEvents((prev) => {
if (events.length === 0) {
return prev;
}
const existingIds = new Set(prev.map(e => e.id));
const newEvents = events.filter(e => !existingIds.has(e.id));
return [...prev, ...newEvents];
});
}
setIsLoadingMore(false);
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [events, eventsQuery.isLoading, viewMode, cardViewPage]);
useEffect(() => {
if (viewMode !== "card") return;
const pageCard = pageCardRef.current;
if (!pageCard) return;
const cardBody = pageCard.querySelector<HTMLElement>(".ant-card-body");
if (!cardBody) return;
const handleScroll = () => {
if (isLoadingMore || eventsQuery.isLoading) return;
const scrollTop = cardBody.scrollTop;
const scrollHeight = cardBody.scrollHeight;
const clientHeight = cardBody.clientHeight;
if (scrollTop + clientHeight >= scrollHeight - 100) {
const total = eventsQuery.data?.total ?? 0;
const loadedCount = allLoadedEvents.length;
if (loadedCount < total) {
setIsLoadingMore(true);
setCardViewPage((prev) => prev + 1);
setPagination((prev) => ({ ...prev, current: prev.current + 1 }));
}
}
};
cardBody.addEventListener("scroll", handleScroll);
return () => cardBody.removeEventListener("scroll", handleScroll);
}, [viewMode, isLoadingMore, eventsQuery.isLoading, eventsQuery.data?.total, allLoadedEvents.length]);
useEffect(() => {
if (typeof window === "undefined") {
return;
@@ -346,13 +426,103 @@ export default function AdminLightningCurrentsPage() {
setSampleModalOpen(true);
};
const handleSearch = () => {
setSearchKeyword(keywordInput);
const handleKeywordChange = (value: string) => {
setKeywordInput(value);
if (keywordDebounceTimeoutRef.current) {
clearTimeout(keywordDebounceTimeoutRef.current);
}
keywordDebounceTimeoutRef.current = setTimeout(() => {
setSearchKeyword(value);
setPagination((prev) => ({ ...prev, current: 1 }));
setCardViewPage(1);
setAllLoadedEvents([]);
}, 500);
};
const handleResetSearch = () => {
setKeywordInput("");
setSearchKeyword("");
const renderLightningEventCard = (event: LightningCurrentEventSummary) => {
const moreMenuItems: MenuProps["items"] = [
{
key: "sample",
label: "采样预览",
onClick: () => openSampleModal(event),
},
{
type: "divider",
},
{
key: "delete",
label: "删除",
danger: true,
disabled: !canManage,
onClick: () => {
Modal.confirm({
title: "删除事件",
content: `确认删除事件 ${event.event_id} 吗?`,
okText: "删除",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: async () => {
await deleteMutation.mutateAsync(event.id);
},
});
},
},
];
return (
<AntCard
key={event.id}
className="admin-lightning-events-card"
size="small"
title={
<Space className="min-w-0" size={8}>
<Typography.Text strong ellipsis>
{event.source_file_name || "-"}
</Typography.Text>
</Space>
}
extra={
<Dropdown menu={{ items: moreMenuItems }} trigger={["click"]}>
<Button type="text" size="small" icon={<MoreOutlined />} />
</Dropdown>
}
>
<Space direction="vertical" size={10} style={{ width: "100%" }}>
<div className="admin-lightning-events-card-field">
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text>{event.city || "-"}</Typography.Text>
</div>
<div className="admin-lightning-events-card-field">
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text>{event.install_position || "-"}</Typography.Text>
</div>
<div className="admin-lightning-events-card-field">
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text>{event.sensor_model || "-"}</Typography.Text>
</div>
<div className="admin-lightning-events-card-field">
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text>{event.sample_count}</Typography.Text>
</div>
<Space wrap style={{ width: "100%", justifyContent: "flex-start" }}>
<Button
size="small"
onClick={() => openExceedanceModal(event)}
>
</Button>
<Button
size="small"
onClick={() => openDetailModal(event)}
>
</Button>
</Space>
</Space>
</AntCard>
);
};
const eventColumns = useMemo<ColumnsType<LightningCurrentEventSummary>>(
@@ -511,8 +681,10 @@ export default function AdminLightningCurrentsPage() {
}));
return (
<Space direction="vertical" size={16} className="w-full h-full">
<Card
<div className="flex min-h-0 flex-1 flex-col">
<AntCard
ref={pageCardRef}
className="admin-lightning-currents-page-card"
title="雷电流幅值统计"
extra={
canManage && (
@@ -522,43 +694,96 @@ export default function AdminLightningCurrentsPage() {
)
}
>
<Form layout="inline" style={{ rowGap: 12, marginBottom: 16 }}>
<Form.Item label="文件名关键词" className="min-w-[240px]">
<Input
allowClear
placeholder="按文件名搜索"
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onPressEnter={handleSearch}
{viewMode === "card" ? (
<Form layout="vertical" style={{ marginBottom: 16 }}>
<Form.Item style={{ marginBottom: 0 }}>
<Input
allowClear
placeholder="按文件名搜索"
value={keywordInput}
onChange={(event) => handleKeywordChange(event.target.value)}
/>
</Form.Item>
</Form>
) : (
<Form layout="inline" style={{ rowGap: 12, marginBottom: 16 }}>
<Form.Item label="关键词" style={{ width: 260 }}>
<Input
allowClear
placeholder="按文件名搜索"
value={keywordInput}
onChange={(event) => handleKeywordChange(event.target.value)}
/>
</Form.Item>
</Form>
)}
{viewMode === "table" ? (
<div
ref={tableScrollAnchorRef}
className="lightning-table-anchor"
style={{ "--lightning-table-body-min-height": `${tableScrollY}px` } as CSSProperties}
>
<Table<LightningCurrentEventSummary>
rowKey={(row) => row.id}
columns={eventColumns}
dataSource={events}
loading={eventsQuery.isFetching}
tableLayout="fixed"
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: eventsQuery.data?.total ?? 0,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: (total) => `${total}`,
hideOnSinglePage: false,
onChange: (page, pageSize) => {
setPagination({ current: page, pageSize });
},
}}
scroll={{ x: 1200, y: tableScrollY }}
/>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={handleSearch}>
</Button>
</Form.Item>
<Form.Item>
<Button onClick={handleResetSearch}></Button>
</Form.Item>
</Form>
<div
ref={tableScrollAnchorRef}
className="lightning-table-anchor"
style={{ "--lightning-table-body-min-height": `${tableScrollY}px` } as CSSProperties}
>
<Table<LightningCurrentEventSummary>
rowKey={(row) => row.id}
columns={eventColumns}
dataSource={events}
loading={eventsQuery.isFetching}
pagination={{ pageSize: 20, showSizeChanger: true, hideOnSinglePage: false, showTotal: (total) => `${total}` }}
scroll={{ x: 1200, y: tableScrollY }}
/>
</div>
</Card>
</div>
) : (
<div className="admin-lightning-events-card-view">
{eventsQuery.isLoading && allLoadedEvents.length === 0 ? (
<div className="admin-lightning-events-card-view-state">
<AdminPageLoading tip="加载中..." minHeightClassName="min-h-[180px]" />
</div>
) : allLoadedEvents.length === 0 ? (
<div className="admin-lightning-events-card-view-state">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的事件。"
/>
</div>
) : (
<div className="admin-lightning-events-card-view-content">
<Row gutter={[12, 12]}>
{allLoadedEvents.map((eventItem) => (
<Col key={eventItem.id} xs={24} sm={24} md={12} lg={8} xl={6}>
{renderLightningEventCard(eventItem)}
</Col>
))}
</Row>
{isLoadingMore && (
<div style={{ textAlign: "center", padding: "20px 0" }}>
<AdminPageLoading tip="加载更多..." minHeightClassName="min-h-[60px]" />
</div>
)}
{allLoadedEvents.length >= (eventsQuery.data?.total ?? 0) && allLoadedEvents.length > 0 && (
<div style={{ textAlign: "center", padding: "20px 0" }}>
<Typography.Text type="secondary">
{allLoadedEvents.length}
</Typography.Text>
</div>
)}
</div>
)}
</div>
)}
</AntCard>
<Modal
title="导入雷电流数据"
@@ -941,6 +1166,6 @@ export default function AdminLightningCurrentsPage() {
)}
</Space>
</Modal>
</Space>
</div>
);
}