Browse Source

feat:添加数据导出功能

master
qinjian 2 weeks ago
parent
commit
ca160814fd
  1. 2
      client/src/sections/wuyuanbiaoba/components/CameraView.jsx
  2. 144
      client/src/sections/wuyuanbiaoba/components/RealtimeCharts.jsx
  3. 139
      client/src/sections/wuyuanbiaoba/container/index.jsx
  4. 932
      package-lock.json
  5. 1
      package.json

2
client/src/sections/wuyuanbiaoba/components/CameraView.jsx

@ -575,7 +575,7 @@ const CameraView = ({
// //
if ((!rectangles || rectangles.length === 0) && !currentDrawingRect) { if ((!rectangles || rectangles.length === 0) && !currentDrawingRect) {
console.log("redrawAllRectangles: 没有矩形框数据且没有预览矩形"); // console.log("redrawAllRectangles: ");
return; return;
} }

144
client/src/sections/wuyuanbiaoba/components/RealtimeCharts.jsx

@ -1,5 +1,12 @@
import React, { useMemo, useEffect, useRef, useState, useCallback } from "react"; import React, {
import { Typography, Badge, Button, Space } from "antd"; useMemo,
useEffect,
useRef,
useState,
useCallback,
} from "react";
import { Typography, Badge, Button, Space,InputNumber } from "antd";
import { DownloadOutlined } from "@ant-design/icons";
import { import {
Chart as ChartJS, Chart as ChartJS,
CategoryScale, CategoryScale,
@ -25,7 +32,7 @@ ChartJS.register(
const { Title } = Typography; const { Title } = Typography;
const RealtimeCharts = ({ tableData, lastUpdateTime }) => { const RealtimeCharts = ({ tableData, lastUpdateTime, onDataExport,exportCount = 200, setExportCount }) => {
const xChartRef = useRef(null); const xChartRef = useRef(null);
const yChartRef = useRef(null); const yChartRef = useRef(null);
@ -119,7 +126,7 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
// deviceId desc // deviceId desc
const deviceDescMap = useMemo(() => { const deviceDescMap = useMemo(() => {
const map = {}; const map = {};
tableData.forEach(item => { tableData.forEach((item) => {
if (item.deviceId && item.desc) { if (item.deviceId && item.desc) {
map[item.deviceId] = item.desc; map[item.deviceId] = item.desc;
} }
@ -130,18 +137,18 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
useEffect(() => { useEffect(() => {
if (deviceIds.length > 0) { if (deviceIds.length > 0) {
const initialVisible = {}; const initialVisible = {};
deviceIds.forEach(deviceId => { deviceIds.forEach((deviceId) => {
initialVisible[deviceId] = true; // initialVisible[deviceId] = true; //
}); });
setVisibleTargets(prev => ({ ...initialVisible, ...prev })); setVisibleTargets((prev) => ({ ...initialVisible, ...prev }));
} }
}, [deviceIds.join(',')]); }, [deviceIds.join(",")]);
// //
const toggleTargetVisibility = useCallback((deviceId) => { const toggleTargetVisibility = useCallback((deviceId) => {
setVisibleTargets(prev => ({ setVisibleTargets((prev) => ({
...prev, ...prev,
[deviceId]: !prev[deviceId] [deviceId]: !prev[deviceId],
})); }));
// //
@ -149,8 +156,12 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
const yChart = yChartRef.current; const yChart = yChartRef.current;
if (xChart && yChart) { if (xChart && yChart) {
const xDatasetIndex = xChart.data.datasets.findIndex(dataset => dataset.label === deviceId); const xDatasetIndex = xChart.data.datasets.findIndex(
const yDatasetIndex = yChart.data.datasets.findIndex(dataset => dataset.label === deviceId); (dataset) => dataset.label === deviceId
);
const yDatasetIndex = yChart.data.datasets.findIndex(
(dataset) => dataset.label === deviceId
);
if (xDatasetIndex !== -1 && yDatasetIndex !== -1) { if (xDatasetIndex !== -1 && yDatasetIndex !== -1) {
const isVisible = xChart.isDatasetVisible(xDatasetIndex); const isVisible = xChart.isDatasetVisible(xDatasetIndex);
@ -167,9 +178,10 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
}, []); }, []);
// / // /
const toggleAllTargets = useCallback((visible) => { const toggleAllTargets = useCallback(
(visible) => {
const newVisibleTargets = {}; const newVisibleTargets = {};
deviceIds.forEach(deviceId => { deviceIds.forEach((deviceId) => {
newVisibleTargets[deviceId] = visible; newVisibleTargets[deviceId] = visible;
}); });
setVisibleTargets(newVisibleTargets); setVisibleTargets(newVisibleTargets);
@ -189,7 +201,9 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
xChart.update(); xChart.update();
yChart.update(); yChart.update();
} }
}, [deviceIds]); },
[deviceIds]
);
// X // X
const xChartData = useMemo(() => { const xChartData = useMemo(() => {
@ -229,7 +243,10 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
return { labels, datasets }; return { labels, datasets };
} catch (error) { } catch (error) {
console.error(`${new Date().toLocaleString()} - 准备X轴图表数据出错:`, error); console.error(
`${new Date().toLocaleString()} - 准备X轴图表数据出错:`,
error
);
return { labels: [], datasets: [] }; return { labels: [], datasets: [] };
} }
}, [tableData, visibleTargets]); }, [tableData, visibleTargets]);
@ -272,13 +289,17 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
return { labels, datasets }; return { labels, datasets };
} catch (error) { } catch (error) {
console.error(`${new Date().toLocaleString()} - 准备Y轴图表数据出错:`, error); console.error(
`${new Date().toLocaleString()} - 准备Y轴图表数据出错:`,
error
);
return { labels: [], datasets: [] }; return { labels: [], datasets: [] };
} }
}, [tableData, visibleTargets]); }, [tableData, visibleTargets]);
// Chart.js - 访 deviceDescMap // Chart.js - 访 deviceDescMap
const chartOptions = useMemo(() => ({ const chartOptions = useMemo(
() => ({
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { interaction: {
@ -294,16 +315,16 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
return tooltipItem.parsed.y !== null; return tooltipItem.parsed.y !== null;
}, },
callbacks: { callbacks: {
title: function(context) { title: function (context) {
return context[0].label; // return context[0].label; //
}, },
label: function(context) { label: function (context) {
const deviceId = context.dataset.label; const deviceId = context.dataset.label;
const deviceDesc = deviceDescMap[deviceId] || deviceId; const deviceDesc = deviceDescMap[deviceId] || deviceId;
const value = context.parsed.y; const value = context.parsed.y;
return `${deviceDesc}: ${value}`; return `${deviceDesc}: ${value}`;
} },
} },
}, },
}, },
scales: { scales: {
@ -345,10 +366,13 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
tension: 0, tension: 0,
}, },
}, },
}), [deviceDescMap]); }),
[deviceDescMap]
);
// X // X
const xChartOptions = useMemo(() => ({ const xChartOptions = useMemo(
() => ({
...chartOptions, ...chartOptions,
plugins: { plugins: {
...chartOptions.plugins, ...chartOptions.plugins,
@ -370,10 +394,13 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
}, },
}, },
}, },
}), [chartOptions]); }),
[chartOptions]
);
// Y // Y
const yChartOptions = useMemo(() => ({ const yChartOptions = useMemo(
() => ({
...chartOptions, ...chartOptions,
plugins: { plugins: {
...chartOptions.plugins, ...chartOptions.plugins,
@ -395,7 +422,9 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
}, },
}, },
}, },
}), [chartOptions]); }),
[chartOptions]
);
// //
if (!tableData || tableData.length === 0) { if (!tableData || tableData.length === 0) {
@ -442,7 +471,14 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
flexDirection: "column", flexDirection: "column",
}} }}
> >
<Title level={4} style={{ marginBottom: "16px" }}> <Title
level={4}
style={{
marginBottom: "16px",
display: "flex",
alignItems: "center",
}}
>
实时数据图 实时数据图
<Badge <Badge
status="processing" status="processing"
@ -450,15 +486,50 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
style={{ marginLeft: "16px", fontSize: "12px" }} style={{ marginLeft: "16px", fontSize: "12px" }}
/> />
<Badge <Badge
status="default" status="success"
text={`图表数据采样频率:1Hz`} text={`图表数据采样频率:1Hz`}
style={{ marginLeft: "16px", fontSize: "12px" }} style={{ marginLeft: "16px", fontSize: "12px" }}
/> />
<Badge
status="default"
text={`导出数据条数配置`}
style={{ marginLeft: "16px", fontSize: "12px" }}
/>
<InputNumber
min={1}
max={1000}
value={exportCount}
onChange={setExportCount}
style={{ marginLeft: 16, width: 80 }}
step={1}
/>
<Button
style={{ marginLeft: 8 }}
icon={<DownloadOutlined />}
onClick={() => onDataExport(exportCount)}
>
导出
</Button>
</Title> </Title>
{/* 统一的图例控制器 */} {/* 统一的图例控制器 */}
<div style={{ marginBottom: "16px", padding: "12px", backgroundColor: "#fafafa", borderRadius: "6px" }}> <div
<div style={{ marginBottom: "8px", fontSize: "14px", fontWeight: "500" }}>显示控制</div> style={{
marginBottom: "16px",
padding: "12px",
backgroundColor: "#fafafa",
borderRadius: "6px",
}}
>
<div
style={{
marginBottom: "8px",
fontSize: "14px",
fontWeight: "500",
}}
>
显示控制
</div>
<Space wrap> <Space wrap>
<Button <Button
size="small" size="small"
@ -467,10 +538,7 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
> >
全部显示 全部显示
</Button> </Button>
<Button <Button size="small" onClick={() => toggleAllTargets(false)}>
size="small"
onClick={() => toggleAllTargets(false)}
>
全部隐藏 全部隐藏
</Button> </Button>
{deviceIds.map((deviceId, index) => ( {deviceIds.map((deviceId, index) => (
@ -480,8 +548,12 @@ const RealtimeCharts = ({ tableData, lastUpdateTime }) => {
type={visibleTargets[deviceId] ? "primary" : "default"} type={visibleTargets[deviceId] ? "primary" : "default"}
style={{ style={{
borderColor: getDeviceColor(index), borderColor: getDeviceColor(index),
color: visibleTargets[deviceId] ? "#fff" : getDeviceColor(index), color: visibleTargets[deviceId]
backgroundColor: visibleTargets[deviceId] ? getDeviceColor(index) : "#fff" ? "#fff"
: getDeviceColor(index),
backgroundColor: visibleTargets[deviceId]
? getDeviceColor(index)
: "#fff",
}} }}
onClick={() => toggleTargetVisibility(deviceId)} onClick={() => toggleTargetVisibility(deviceId)}
> >

139
client/src/sections/wuyuanbiaoba/container/index.jsx

@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Tabs, Typography } from "antd"; import { Tabs, Typography } from "antd";
import { EyeOutlined } from "@ant-design/icons"; import ExcelJS from "exceljs";
import { import {
CameraView, CameraView,
TargetList, TargetList,
@ -17,7 +18,7 @@ import {
} from "../actions/websocket.jsx"; } from "../actions/websocket.jsx";
import { useTemplateStorage } from "../hooks/useTemplateStorage.js"; import { useTemplateStorage } from "../hooks/useTemplateStorage.js";
import { useTargetStorage } from "../hooks/useTargetStorage.js"; import { useTargetStorage } from "../hooks/useTargetStorage.js";
import { useRef } from "react";
const { Title } = Typography; const { Title } = Typography;
// 使WebSocket hook // 使WebSocket hook
@ -26,7 +27,29 @@ const WuyuanbiaobaContent = () => {
// //
const realtimeDataSubscription = useWebSocketSubscription("dev", "data"); const realtimeDataSubscription = useWebSocketSubscription("dev", "data");
const realtimeBufferRef = useRef([]);
const exportCountRef = useRef(200);
//
const setExportCount = (count) => {
exportCountRef.current = count;
};
//
const pushToRealtimeBuffer = (newDataGroup) => {
// newDataGroup: { time: "2025-09-29 01:40:57.091", data: [...] }
const now = Date.now();
const group = {
...newDataGroup,
_bufferTimestamp: now,
};
//
realtimeBufferRef.current = [...realtimeBufferRef.current, group];
// 200
if (realtimeBufferRef.current.length > exportCountRef.current) {
realtimeBufferRef.current = realtimeBufferRef.current.slice(-exportCountRef.current);
}
};
const { const {
templates: tempListData, templates: tempListData,
loading: templatesLoading, loading: templatesLoading,
@ -78,7 +101,7 @@ const WuyuanbiaobaContent = () => {
desc: item.desc, // desc desc: item.desc, // desc
xValue: item.x, xValue: item.x,
yValue: item.y, yValue: item.y,
updateTime: data.time || new Date().toLocaleString(), updateTime: data.time,
})); }));
}; };
@ -156,23 +179,120 @@ const WuyuanbiaobaContent = () => {
})), })),
]; ];
// 2525 // 25
return updatedData.slice(-75); // 3 * 25 return updatedData.slice(-75); // 3 * 25
}); });
// 使 // 使
setRealtimeData(newRealtimeData.map((point) => ({ setRealtimeData(
newRealtimeData.map((point) => ({
...point, ...point,
key: `realtime_${point.key}`, key: `realtime_${point.key}`,
updateTime: new Date(currentTime).toLocaleString(), updateTime: new Date(currentTime).toLocaleString(),
}))); }))
);
} }
//
pushToRealtimeBuffer(realtimeDataSubscription.latest.values);
setLastUpdateTime(new Date()); setLastUpdateTime(new Date());
} }
} }
}, [realtimeDataSubscription.latest, lastSampleTime]); }, [realtimeDataSubscription.latest]);
const dataExport = async () => {
const dataToExport = realtimeBufferRef.current;
if (dataToExport.length === 0) {
console.warn("没有数据可导出");
return;
}
// console.log(':', dataToExport);
// desc
const descSet = [];
dataToExport.forEach((group) => {
if (Array.isArray(group.data)) {
group.data.forEach((point) => {
if (point && !descSet.includes(point.desc)) {
descSet.push(point.desc);
}
});
}
});
//
const columns = [{ header: "采集时间", key: "time", width: 24 }];
descSet.forEach((desc) => {
columns.push(
{ header: `标靶${desc}X`, key: `x_${desc}`, width: 12 },
{ header: `标靶${desc}Y`, key: `y_${desc}`, width: 12 }
);
});
// 簿
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("两分钟内数据");
worksheet.columns = columns;
//
dataToExport.forEach((group) => {
const row = { time: group.time };
// desc
descSet.forEach((desc) => {
row[`x_${desc}`] = "";
row[`y_${desc}`] = "";
});
//
group.data.forEach((point) => {
if (point && descSet.includes(point.desc)) {
row[`x_${point.desc}`] = point.x;
row[`y_${point.desc}`] = point.y;
}
});
worksheet.addRow(row);
});
//
worksheet.getRow(1).font = { bold: true, color: { argb: "FF1F497D" } };
worksheet.getRow(1).alignment = {
vertical: "middle",
horizontal: "center",
};
//
worksheet.eachRow((row, rowNumber) => {
row.eachCell((cell) => {
cell.border = {
top: { style: "thin" },
left: { style: "thin" },
bottom: { style: "thin" },
right: { style: "thin" },
};
cell.alignment = { vertical: "middle", horizontal: "center" };
});
});
//
const now = new Date();
const pad = (n) => n.toString().padStart(2, "0");
const localFileName = `${now.getFullYear()}-${pad(
now.getMonth() + 1
)}-${pad(now.getDate())}-${pad(now.getHours())}-${pad(
now.getMinutes()
)}-${pad(now.getSeconds())}.xlsx`;
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = localFileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
};
// //
const handleEditTarget = (target) => { const handleEditTarget = (target) => {
setCurrentEditTarget(target); setCurrentEditTarget(target);
@ -431,6 +551,9 @@ const WuyuanbiaobaContent = () => {
<RealtimeCharts <RealtimeCharts
tableData={tableData} tableData={tableData}
lastUpdateTime={lastUpdateTime} lastUpdateTime={lastUpdateTime}
onDataExport={dataExport}
exportCount={exportCountRef.current}
setExportCount={setExportCount}
/> />
{/* Table 区域 - 使用采样数据显示 */} {/* Table 区域 - 使用采样数据显示 */}

932
package-lock.json

File diff suppressed because it is too large

1
package.json

@ -27,6 +27,7 @@
"dependencies": { "dependencies": {
"@koa/multer": "^3.0.2", "@koa/multer": "^3.0.2",
"@peace/react_client": "^5.0.1", "@peace/react_client": "^5.0.1",
"exceljs": "^4.4.0",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"ws": "^8.18.3" "ws": "^8.18.3"
} }

Loading…
Cancel
Save