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.
606 lines
19 KiB
606 lines
19 KiB
import React, { useState, useEffect } from "react";
|
|
import { Tabs, Typography } from "antd";
|
|
import ExcelJS from "exceljs";
|
|
|
|
import {
|
|
CameraView,
|
|
TargetList,
|
|
TemplateList,
|
|
RealtimeCharts,
|
|
RealtimeDataTable,
|
|
TemplateModal,
|
|
TargetDetailModal,
|
|
} from "../components";
|
|
import {
|
|
WebSocketProvider,
|
|
useWebSocket,
|
|
useWebSocketSubscription,
|
|
} from "../actions/websocket.jsx";
|
|
import { useTemplateStorage } from "../hooks/useTemplateStorage.js";
|
|
import { useTargetStorage } from "../hooks/useTargetStorage.js";
|
|
import { useRef } from "react";
|
|
const { Title } = Typography;
|
|
|
|
// 内部组件,使用WebSocket hook
|
|
const WuyuanbiaobaContent = () => {
|
|
const { isConnected, sendMessage } = useWebSocket();
|
|
|
|
// 订阅实时数据
|
|
const realtimeDataSubscription = useWebSocketSubscription("dev", "data");
|
|
const realtimeBufferRef = useRef([]);
|
|
const exportCountRef = useRef(200);
|
|
const MAX_BUFFER_SIZE = 1000;
|
|
|
|
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];
|
|
|
|
// 只保留最大MAX_BUFFER_SIZE条数据
|
|
if (realtimeBufferRef.current.length > MAX_BUFFER_SIZE) {
|
|
realtimeBufferRef.current = realtimeBufferRef.current.slice(
|
|
-MAX_BUFFER_SIZE
|
|
);
|
|
}
|
|
};
|
|
const {
|
|
templates: tempListData,
|
|
loading: templatesLoading,
|
|
addTemplate,
|
|
updateTemplate,
|
|
deleteTemplate,
|
|
getTemplateByKey,
|
|
} = useTemplateStorage();
|
|
|
|
const {
|
|
targets: targetListData,
|
|
loading: targetsLoading,
|
|
addTarget,
|
|
updateTarget,
|
|
deleteTarget,
|
|
refreshTargets,
|
|
} = useTargetStorage();
|
|
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
|
const [tableData, setTableData] = useState([]);
|
|
const [realtimeData, setRealtimeData] = useState([]);
|
|
const [lastUpdateTime, setLastUpdateTime] = useState(new Date());
|
|
|
|
// 数据采样相关状态
|
|
const [lastSampleTime, setLastSampleTime] = useState(0);
|
|
|
|
// 模板相关状态
|
|
const [templateModalVisible, setTemplateModalVisible] = useState(false);
|
|
const [templateModalMode, setTemplateModalMode] = useState("add"); // 'add' | 'edit'
|
|
const [currentEditTemplate, setCurrentEditTemplate] = useState(null);
|
|
|
|
// 标靶详情模态框相关状态
|
|
const [targetDetailModalVisible, setTargetDetailModalVisible] =
|
|
useState(false);
|
|
const [targetDetailModalMode, setTargetDetailModalMode] = useState("edit"); // 'edit'
|
|
const [currentEditTarget, setCurrentEditTarget] = useState(null);
|
|
|
|
// 添加选中标靶的状态
|
|
const [selectedTargetId, setSelectedTargetId] = useState(null);
|
|
|
|
// 处理实时数据并转换为表格格式
|
|
const processRealtimeData = (data) => {
|
|
if (!data || !data.data || !Array.isArray(data.data)) {
|
|
return [];
|
|
}
|
|
|
|
return data.data.map((item) => ({
|
|
key: item.pos,
|
|
deviceId: item.pos, // 使用 pos 作为 deviceId
|
|
desc: item.desc, // 添加 desc 字段
|
|
xValue: item.x,
|
|
yValue: item.y,
|
|
updateTime: data.time,
|
|
}));
|
|
};
|
|
|
|
// 初始化数据
|
|
useEffect(() => {
|
|
// 初始化空的实时数据表格
|
|
setRealtimeData([]);
|
|
setTableData([]);
|
|
// console.log("数据已初始化,等待实时数据...", import.meta.env.MODE);
|
|
}, []);
|
|
|
|
// 模板数据加载完成后,默认选中内置模板
|
|
useEffect(() => {
|
|
if (!templatesLoading && tempListData.length > 0 && !selectedTemplate) {
|
|
// 查找内置模板
|
|
const builtinTemplate = tempListData.find(
|
|
(template) => template.key === "builtin_1"
|
|
);
|
|
if (builtinTemplate) {
|
|
setSelectedTemplate("builtin_1");
|
|
// console.log("默认选中内置模板:", builtinTemplate.name);
|
|
} else {
|
|
// 如果没有内置模板,选择第一个模板
|
|
setSelectedTemplate(tempListData[0].key);
|
|
// console.log("默认选中第一个模板:", tempListData[0].name);
|
|
}
|
|
}
|
|
}, [templatesLoading, tempListData, selectedTemplate]);
|
|
|
|
// WebSocket连接成功后的处理
|
|
useEffect(() => {
|
|
if (isConnected) {
|
|
// console.log("WebSocket已连接,等待实时数据...");
|
|
} else {
|
|
// console.log("WebSocket未连接");
|
|
}
|
|
}, [isConnected]);
|
|
|
|
// 调试实时数据订阅状态
|
|
useEffect(() => {
|
|
// console.log('实时数据订阅状态:', {
|
|
// hasData: !!realtimeDataSubscription.latest,
|
|
// dataCount: realtimeDataSubscription.data?.length || 0,
|
|
// latestTimestamp: realtimeDataSubscription.latest?.timestamp,
|
|
// });
|
|
}, [realtimeDataSubscription]);
|
|
|
|
// 处理实时数据更新
|
|
useEffect(() => {
|
|
if (
|
|
realtimeDataSubscription.latest &&
|
|
realtimeDataSubscription.latest.values
|
|
) {
|
|
const newRealtimeData = processRealtimeData(
|
|
realtimeDataSubscription.latest.values
|
|
);
|
|
|
|
if (newRealtimeData.length > 0) {
|
|
const currentTime = Date.now();
|
|
const currentSecond = Math.floor(currentTime / 1000);
|
|
|
|
// 每秒采样一次数据用于图表和表格显示
|
|
if (currentSecond > lastSampleTime) {
|
|
setLastSampleTime(currentSecond);
|
|
|
|
// 更新采样后的历史数据(用于图表显示)
|
|
setTableData((prevData) => {
|
|
const updatedData = [
|
|
...prevData,
|
|
...newRealtimeData.map((point) => ({
|
|
...point,
|
|
key: `${currentTime}_${point.key}`,
|
|
timestamp: currentTime,
|
|
updateTime: new Date(currentTime).toLocaleString(),
|
|
})),
|
|
];
|
|
|
|
// 只保留最近25个数据点
|
|
return updatedData.slice(-75); // 3个设备 * 25个时间点
|
|
});
|
|
|
|
// 更新实时数据表格(使用最新的采样数据)
|
|
setRealtimeData(
|
|
newRealtimeData.map((point) => ({
|
|
...point,
|
|
key: `realtime_${point.key}`,
|
|
updateTime: new Date(currentTime).toLocaleString(),
|
|
}))
|
|
);
|
|
}
|
|
//维护两分钟的缓冲区
|
|
pushToRealtimeBuffer(realtimeDataSubscription.latest.values);
|
|
setLastUpdateTime(new Date());
|
|
}
|
|
}
|
|
}, [realtimeDataSubscription.latest]);
|
|
|
|
const dataExport = async () => {
|
|
const count = exportCountRef.current || 200;
|
|
const buf = realtimeBufferRef.current;
|
|
if (buf.length === 0) {
|
|
console.warn("没有数据可导出");
|
|
return;
|
|
}
|
|
// 只取最新的 count 条
|
|
const dataToExport = buf.slice(-count);
|
|
|
|
// 收集所有出现过的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) => {
|
|
setCurrentEditTarget(target);
|
|
setTargetDetailModalMode("edit");
|
|
setTargetDetailModalVisible(true);
|
|
// console.log("编辑标靶:", target);
|
|
};
|
|
|
|
// 选中标靶的处理函数
|
|
const handleSelectTarget = (target) => {
|
|
setSelectedTargetId(target.id);
|
|
// console.log("选中标靶:", target);
|
|
};
|
|
|
|
// 清除选中标靶的处理函数
|
|
const handleClearSelection = () => {
|
|
setSelectedTargetId(null);
|
|
// console.log("清除标靶选中状态");
|
|
};
|
|
|
|
// 处理矩形框点击事件
|
|
const handleRectangleClick = (targetData) => {
|
|
// console.log("矩形框被点击,打开标靶详情:", targetData);
|
|
setCurrentEditTarget(targetData);
|
|
setTargetDetailModalMode("edit");
|
|
setTargetDetailModalVisible(true);
|
|
};
|
|
|
|
// 选择模板的处理函数
|
|
const handleTemplateSelect = (templateKey) => {
|
|
setSelectedTemplate(templateKey);
|
|
// console.log("选中模板:", templateKey);
|
|
};
|
|
|
|
// 添加新模板的处理函数
|
|
const handleAddTemplate = () => {
|
|
setTemplateModalMode("add");
|
|
setCurrentEditTemplate(null);
|
|
setTemplateModalVisible(true);
|
|
};
|
|
|
|
// 编辑模板的处理函数
|
|
const handleEditTemplate = (template) => {
|
|
setTemplateModalMode("edit");
|
|
setCurrentEditTemplate(template);
|
|
setTemplateModalVisible(true);
|
|
};
|
|
|
|
// 模态框确认处理函数
|
|
const handleTemplateModalOk = (templateInfo) => {
|
|
// console.log(templateInfo, "templateInfo");
|
|
|
|
let success = false;
|
|
if (templateModalMode === "add") {
|
|
success = addTemplate(templateInfo);
|
|
} else {
|
|
success = updateTemplate(templateInfo);
|
|
}
|
|
|
|
if (success) {
|
|
setTemplateModalVisible(false);
|
|
} else {
|
|
console.error("模板操作失败");
|
|
// 可以添加用户提示
|
|
}
|
|
};
|
|
|
|
// 模态框取消处理函数
|
|
const handleTemplateModalCancel = () => {
|
|
setTemplateModalVisible(false);
|
|
setCurrentEditTemplate(null);
|
|
};
|
|
|
|
// 删除模板处理函数
|
|
const handleDeleteTemplate = (template) => {
|
|
const success = deleteTemplate(template.key);
|
|
|
|
if (success) {
|
|
setTemplateModalVisible(false);
|
|
|
|
// 如果删除的是当前选中的模板,则清空选中状态
|
|
if (selectedTemplate === template.key) {
|
|
setSelectedTemplate(null);
|
|
}
|
|
} else {
|
|
console.error("删除模板失败");
|
|
// 可以添加用户提示
|
|
}
|
|
};
|
|
|
|
// 标靶详情模态框确认处理函数
|
|
const handleTargetDetailModalOk = (targetInfo) => {
|
|
const success = updateTarget(targetInfo);
|
|
|
|
if (success) {
|
|
setTargetDetailModalVisible(false);
|
|
setCurrentEditTarget(null);
|
|
// console.log("更新标靶信息:", targetInfo);
|
|
} else {
|
|
console.error("更新标靶失败");
|
|
// 可以添加用户提示
|
|
}
|
|
};
|
|
|
|
// 标靶详情模态框取消处理函数
|
|
const handleTargetDetailModalCancel = () => {
|
|
setTargetDetailModalVisible(false);
|
|
setCurrentEditTarget(null);
|
|
};
|
|
|
|
// 删除标靶处理函数
|
|
const handleDeleteTarget = (targetKey) => {
|
|
// console.log("开始删除标靶:", targetKey);
|
|
const success = deleteTarget(targetKey);
|
|
|
|
if (success) {
|
|
setTargetDetailModalVisible(false);
|
|
setCurrentEditTarget(null);
|
|
// 如果删除的标靶是当前选中的,清除选中状态
|
|
if (selectedTargetId === targetKey) {
|
|
setSelectedTargetId(null);
|
|
}
|
|
// console.log("删除标靶成功:", targetKey);
|
|
|
|
// 强制刷新标靶数据,确保UI立即更新
|
|
setTimeout(() => {
|
|
refreshTargets();
|
|
}, 500);
|
|
} else {
|
|
console.error("删除标靶失败");
|
|
// 可以添加用户提示
|
|
}
|
|
};
|
|
// 标靶-一键清零
|
|
const onClickClearAll = () => {
|
|
// console.log("一键清零操作");
|
|
sendMessage(
|
|
JSON.stringify({
|
|
_from: "setup",
|
|
cmd: "clearZero",
|
|
values: {},
|
|
})
|
|
);
|
|
};
|
|
|
|
// Tabs items 配置
|
|
const tabItems = [
|
|
{
|
|
key: "target-list",
|
|
label: "标靶列表",
|
|
children: (
|
|
<TargetList
|
|
targetListData={targetListData}
|
|
selectedTargetId={selectedTargetId}
|
|
onEditTarget={handleEditTarget}
|
|
onSelectTarget={handleSelectTarget}
|
|
onClickClearAll={onClickClearAll}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: "temp-list",
|
|
label: "模板列表",
|
|
children: (
|
|
<TemplateList
|
|
tempListData={tempListData}
|
|
selectedTemplate={selectedTemplate}
|
|
onTemplateSelect={handleTemplateSelect}
|
|
onAddTemplate={handleAddTemplate}
|
|
onEditTemplate={handleEditTemplate}
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
minHeight: "100vh",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
backgroundColor: "#f0f2f5",
|
|
}}
|
|
>
|
|
{/* Header 区域 */}
|
|
<div
|
|
style={{
|
|
height: "60px",
|
|
backgroundColor: "white",
|
|
color: "#333",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
padding: "0 24px",
|
|
flexShrink: 0,
|
|
borderBottom: "1px solid #d9d9d9",
|
|
}}
|
|
>
|
|
<Title level={3} style={{ color: "#333", margin: 0 }}>
|
|
视觉位移计配置工具
|
|
</Title>
|
|
</div>
|
|
|
|
{/* 中间区域 - 固定视口剩余高度 */}
|
|
<div
|
|
style={{
|
|
height: "calc(100vh - 60px)",
|
|
display: "flex",
|
|
margin: "16px",
|
|
}}
|
|
>
|
|
{/* Camera 区域 */}
|
|
<CameraView
|
|
selectedTargetId={selectedTargetId}
|
|
onClearSelection={handleClearSelection}
|
|
onRectangleClick={handleRectangleClick}
|
|
selectedTemplate={
|
|
selectedTemplate ? getTemplateByKey(selectedTemplate) : null
|
|
}
|
|
// 传递标靶数据,避免重复调用 Hook
|
|
targets={targetListData}
|
|
targetsLoading={targetsLoading}
|
|
onRefreshTargets={refreshTargets}
|
|
/>
|
|
|
|
{/* 右侧 Target List / Temp List 区域 */}
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: "white",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
<Tabs
|
|
defaultActiveKey="target-list"
|
|
items={tabItems}
|
|
style={{
|
|
height: "100%",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
centered
|
|
size="large"
|
|
tabBarGutter={64}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 底部区域 - 在视口下方,需要滚动查看 */}
|
|
<div
|
|
style={{
|
|
margin: "16px",
|
|
marginTop: 0,
|
|
minHeight: "600px",
|
|
display: "flex",
|
|
backgroundColor: "white",
|
|
}}
|
|
>
|
|
{/* Charts 区域 */}
|
|
<RealtimeCharts
|
|
tableData={tableData}
|
|
lastUpdateTime={lastUpdateTime}
|
|
onDataExport={dataExport}
|
|
exportCount={exportCountRef.current}
|
|
setExportCount={setExportCount}
|
|
/>
|
|
|
|
{/* Table 区域 - 使用采样数据显示 */}
|
|
<RealtimeDataTable realtimeData={realtimeData} />
|
|
</div>
|
|
|
|
{/* 模板编辑模态框 */}
|
|
<TemplateModal
|
|
visible={templateModalVisible}
|
|
mode={templateModalMode}
|
|
templateData={currentEditTemplate}
|
|
tempListData={tempListData}
|
|
onOk={handleTemplateModalOk}
|
|
onCancel={handleTemplateModalCancel}
|
|
onDelete={handleDeleteTemplate}
|
|
/>
|
|
|
|
{/* 标靶详情模态框 */}
|
|
<TargetDetailModal
|
|
visible={targetDetailModalVisible}
|
|
mode={targetDetailModalMode}
|
|
targetData={currentEditTarget}
|
|
onOk={handleTargetDetailModalOk}
|
|
onCancel={handleTargetDetailModalCancel}
|
|
onDelete={handleDeleteTarget}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 主组件,使用WebSocket Provider包装
|
|
const Wuyuanbiaoba = () => {
|
|
return (
|
|
<WebSocketProvider>
|
|
<WuyuanbiaobaContent />
|
|
</WebSocketProvider>
|
|
);
|
|
};
|
|
|
|
export default Wuyuanbiaoba;
|
|
|