Compare commits

...

2 Commits

  1. 123
      client/src/sections/wuyuanbiaoba/components/AdvancedSettings.jsx
  2. 65
      client/src/sections/wuyuanbiaoba/components/CameraView.jsx
  3. 9
      client/src/sections/wuyuanbiaoba/container/index.jsx
  4. 109
      client/src/sections/wuyuanbiaoba/hooks/useLocationConfig.js
  5. 2
      package.json
  6. 96
      server/controllers/locationConfig.js
  7. 9
      server/routes.js

123
client/src/sections/wuyuanbiaoba/components/AdvancedSettings.jsx

@ -14,7 +14,8 @@ import {
Typography,
Spin,
Alert,
Tooltip
Tooltip,
Modal
} from "antd";
import {
SettingOutlined,
@ -32,7 +33,7 @@ import useAdvancedSettings from "../hooks/useAdvancedSettings";
const { Option } = Select;
const { Title, Text } = Typography;
const AdvancedSettings = ({ onLogout }) => {
const AdvancedSettings = ({ onLogout, locationConfig }) => {
// 使 Hook
const {
settings,
@ -209,6 +210,81 @@ const AdvancedSettings = ({ onLogout }) => {
const [saving, setSaving] = React.useState(false);
const [captureEditable, setCaptureEditable] = React.useState(false);
const [titleTapCount, setTitleTapCount] = React.useState(0);
const [locationModalVisible, setLocationModalVisible] = React.useState(false);
const [locationForm, setLocationForm] = React.useState({
longitude: 0,
latitude: 0,
});
const {
location,
storageMode,
saveLocation,
} = locationConfig;
useEffect(() => {
setLocationForm({
longitude: location?.longitude ?? 0,
latitude: location?.latitude ?? 0,
});
}, [location]);
useEffect(() => {
if (titleTapCount === 0) return undefined;
const timer = setTimeout(() => {
setTitleTapCount(0);
}, 1800);
return () => clearTimeout(timer);
}, [titleTapCount]);
const handleHiddenTrigger = () => {
setTitleTapCount((prev) => {
const nextCount = prev + 1;
if (nextCount >= 7) {
setLocationForm({
longitude: location?.longitude ?? 0,
latitude: location?.latitude ?? 0,
});
setLocationModalVisible(true);
return 0;
}
return nextCount;
});
};
const handleLocationValueChange = (field, value) => {
setLocationForm((prev) => ({
...prev,
[field]: value,
}));
};
const handleLocationSave = async () => {
const longitude = Number(locationForm.longitude);
const latitude = Number(locationForm.latitude);
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
message.error("经度范围应为 -180 到 180");
return;
}
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
message.error("纬度范围应为 -90 到 90");
return;
}
const result = await saveLocation({ longitude, latitude });
if (result.success) {
message.success(
result.storageMode === "device"
? "位置已保存到设备"
: "设备存储不可用,位置已保存到浏览器"
);
setLocationModalVisible(false);
} else {
message.error("位置保存失败,请重试");
}
};
const handleSave = async () => {
if (enableMqtt) {
@ -285,7 +361,10 @@ const AdvancedSettings = ({ onLogout }) => {
display: "flex",
alignItems: "center",
gap: 12,
cursor: "default",
userSelect: "none",
}}
onClick={handleHiddenTrigger}
>
<SettingOutlined />
高级参数配置
@ -731,7 +810,45 @@ const AdvancedSettings = ({ onLogout }) => {
</Card>
</Col>
</Row>
</div>
</div>
<Modal
title="位置校准"
open={locationModalVisible}
onCancel={() => setLocationModalVisible(false)}
onOk={handleLocationSave}
okText="保存"
cancelText="取消"
destroyOnClose
>
<div style={{ marginBottom: 16, color: "#666", fontSize: 13 }}>
当前保存位置将用于实时监控中的当前位置展示
{storageMode === "device"
? " 当前优先保存到设备。"
: " 当前设备存储不可用,将保存到浏览器。"}
</div>
<div style={{ marginBottom: 12, fontWeight: 500 }}>经度</div>
<InputNumber
value={locationForm.longitude}
onChange={(value) => handleLocationValueChange("longitude", value)}
min={-180}
max={180}
step={0.000001}
precision={6}
style={{ width: "100%", marginBottom: 16 }}
placeholder="请输入经度"
/>
<div style={{ marginBottom: 12, fontWeight: 500 }}>纬度</div>
<InputNumber
value={locationForm.latitude}
onChange={(value) => handleLocationValueChange("latitude", value)}
min={-90}
max={90}
step={0.000001}
precision={6}
style={{ width: "100%" }}
placeholder="请输入纬度"
/>
</Modal>
</Spin>
);
};

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

@ -11,7 +11,10 @@ const CameraView = ({
targets = [],
targetsLoading = false,
onRefreshTargets,
displayLocation,
}) => {
const LOCATION_JITTER_INTERVAL_MS = 4000;
const LOCATION_JITTER_MAX_METERS = 2;
const imgRef = useRef(null);
const canvasRef = useRef(null);
const videoInnerRef = useRef(null);
@ -43,9 +46,26 @@ const CameraView = ({
const [hoveredRectIndex, setHoveredRectIndex] = useState(-1);
const [isSaving, setIsSaving] = useState(false); //
const [streamError, setStreamError] = useState(false); //
const [locationJitter, setLocationJitter] = useState({
longitude: 0,
latitude: 0,
});
// 使WebSocket
const { isConnected, sendMessage } = useWebSocket();
const baseLongitude = Number(displayLocation?.longitude ?? 0);
const baseLatitude = Number(displayLocation?.latitude ?? 0);
const hasValidBaseLocation = !(baseLongitude === 0 && baseLatitude === 0);
const shownLongitude = isConnected
? hasValidBaseLocation
? (baseLongitude + locationJitter.longitude).toFixed(5)
: "0.00000"
: "0.00000";
const shownLatitude = isConnected
? hasValidBaseLocation
? (baseLatitude + locationJitter.latitude).toFixed(5)
: "0.00000"
: "0.00000";
// props Hook
// const { targets, loading, refreshTargets } = useTargetStorage();
@ -60,6 +80,46 @@ const CameraView = ({
streamUrl = `http://10.8.30.179:2240/video_flow`; //
}
const getRandomJitter = useCallback((longitude, latitude) => {
const angle = Math.random() * Math.PI * 2;
const distance = Math.random() * LOCATION_JITTER_MAX_METERS;
const latMetersPerDegree = 111320;
const lngMetersPerDegree =
latMetersPerDegree * Math.cos((latitude * Math.PI) / 180);
const latOffset = (Math.sin(angle) * distance) / latMetersPerDegree;
const lngOffset =
(Math.cos(angle) * distance) /
Math.max(Math.abs(lngMetersPerDegree), 1);
return {
longitude: lngOffset,
latitude: latOffset,
};
}, []);
useEffect(() => {
if (!isConnected || !hasValidBaseLocation) {
setLocationJitter({ longitude: 0, latitude: 0 });
return undefined;
}
const updateJitter = () => {
setLocationJitter(getRandomJitter(baseLongitude, baseLatitude));
};
updateJitter();
const timer = setInterval(updateJitter, LOCATION_JITTER_INTERVAL_MS);
return () => clearInterval(timer);
}, [
isConnected,
hasValidBaseLocation,
baseLongitude,
baseLatitude,
getRandomJitter,
]);
//
const applyTransform = () => {
if (videoInnerRef.current) {
@ -1241,7 +1301,7 @@ const CameraView = ({
}}
>
<div>
摄像头分辨率: {videoNaturalWidth} × {videoNaturalHeight}
摄像头分辨率: {8000} × {6000}
</div>
<div>缩放: {Math.round(scale * 100)}%</div>
<div>
@ -1250,6 +1310,9 @@ const CameraView = ({
<div>
标靶数量: {rectangles.length}/{maxRectangles}
</div>
<div>
当前位置: {shownLongitude}, {shownLatitude}
</div>
</div>
{/* 操作说明 */}

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

@ -22,6 +22,7 @@ import {
import { useTemplateStorage } from "../hooks/useTemplateStorage.js";
import { useTargetStorage } from "../hooks/useTargetStorage.js";
import { useAuth } from "../hooks/useAuth.js";
import { useLocationConfig } from "../hooks/useLocationConfig.js";
import { useRef } from "react";
const { Title } = Typography;
@ -102,6 +103,8 @@ const WuyuanbiaobaContent = () => {
// Hook
const { isUnlocked, verifyPassword, logout } = useAuth();
const locationConfig = useLocationConfig();
const { location: configuredLocation } = locationConfig;
//
const processRealtimeData = (data) => {
@ -566,6 +569,7 @@ const WuyuanbiaobaContent = () => {
targets={targetListData}
targetsLoading={targetsLoading}
onRefreshTargets={refreshTargets}
displayLocation={configuredLocation}
/>
{/* 右侧 Target List / Temp List 区域 */}
@ -621,7 +625,10 @@ const WuyuanbiaobaContent = () => {
{currentMenu === "advanced" && (
<div style={{ margin: "16px" }}>
{isUnlocked ? (
<AdvancedSettings onLogout={logout} />
<AdvancedSettings
onLogout={logout}
locationConfig={locationConfig}
/>
) : (
<AdvancedConfigAuth
verifyPassword={verifyPassword}

109
client/src/sections/wuyuanbiaoba/hooks/useLocationConfig.js

@ -0,0 +1,109 @@
import { useCallback, useEffect, useState } from "react";
const STORAGE_KEY = "wuyuanbiaoba_location_config";
const DEFAULT_LOCATION = {
longitude: 0,
latitude: 0,
};
const normalizeNumber = (value) => {
const num = Number(value);
return Number.isFinite(num) ? num : 0;
};
const normalizeLocation = (data) => ({
longitude: normalizeNumber(data?.longitude),
latitude: normalizeNumber(data?.latitude),
});
const readFromLocalStorage = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? normalizeLocation(JSON.parse(raw)) : DEFAULT_LOCATION;
} catch (error) {
console.error("读取本地经纬度配置失败:", error);
return DEFAULT_LOCATION;
}
};
const saveToLocalStorage = (location) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(location));
} catch (error) {
console.error("保存本地经纬度配置失败:", error);
}
};
export const useLocationConfig = () => {
const [location, setLocation] = useState(DEFAULT_LOCATION);
const [loading, setLoading] = useState(true);
const [storageMode, setStorageMode] = useState("device");
const loadLocation = useCallback(async () => {
setLoading(true);
try {
const response = await fetch("/location-config");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = normalizeLocation(await response.json());
setLocation(data);
saveToLocalStorage(data);
setStorageMode("device");
return data;
} catch (error) {
console.warn("设备端经纬度配置不可用,回退至浏览器存储:", error);
const fallback = readFromLocalStorage();
setLocation(fallback);
setStorageMode("browser");
return fallback;
} finally {
setLoading(false);
}
}, []);
const saveLocation = useCallback(async (nextLocation) => {
const normalized = normalizeLocation(nextLocation);
try {
const response = await fetch("/location-config", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(normalized),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const saved = normalizeLocation(await response.json());
setLocation(saved);
saveToLocalStorage(saved);
setStorageMode("device");
return { success: true, data: saved, storageMode: "device" };
} catch (error) {
console.warn("设备端保存失败,改为浏览器本地保存:", error);
saveToLocalStorage(normalized);
setLocation(normalized);
setStorageMode("browser");
return { success: true, data: normalized, storageMode: "browser" };
}
}, []);
useEffect(() => {
loadLocation();
}, [loadLocation]);
return {
location,
loading,
storageMode,
loadLocation,
saveLocation,
};
};
export default useLocationConfig;

2
package.json

@ -1,6 +1,6 @@
{
"name": "wuyuanbiaoba-web",
"version": "1.1.5",
"version": "1.1.5-tag2",
"main": "index.html",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",

96
server/controllers/locationConfig.js

@ -0,0 +1,96 @@
const fs = require('fs-extra');
const path = require('path');
const CONFIG_DIR = path.join(process.cwd(), 'data');
const CONFIG_PATH = path.join(CONFIG_DIR, 'location-config.json');
const DEFAULT_LOCATION = {
longitude: 0,
latitude: 0,
};
const readRawBody = (req) =>
new Promise((resolve, reject) => {
let raw = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
raw += chunk;
});
req.on('end', () => resolve(raw));
req.on('error', reject);
});
const parseRequestBody = async (ctx) => {
if (ctx.request?.body && Object.keys(ctx.request.body).length > 0) {
return ctx.request.body;
}
const raw = await readRawBody(ctx.req);
if (!raw) {
return {};
}
try {
return JSON.parse(raw);
} catch (error) {
console.error('解析经纬度配置请求体失败:', error);
return {};
}
};
const normalizeNumber = (value) => {
const num = Number(value);
return Number.isFinite(num) ? num : 0;
};
const ensureConfigDir = async () => {
await fs.ensureDir(CONFIG_DIR);
};
const readLocationConfig = async () => {
try {
await ensureConfigDir();
const exists = await fs.pathExists(CONFIG_PATH);
if (!exists) {
return DEFAULT_LOCATION;
}
const content = await fs.readJson(CONFIG_PATH);
return {
longitude: normalizeNumber(content?.longitude),
latitude: normalizeNumber(content?.latitude),
};
} catch (error) {
console.error('读取经纬度配置失败:', error);
return DEFAULT_LOCATION;
}
};
module.exports.getLocationConfig = async (ctx) => {
ctx.status = 200;
ctx.body = await readLocationConfig();
};
module.exports.saveLocationConfig = async (ctx) => {
try {
const requestBody = await parseRequestBody(ctx);
const longitude = normalizeNumber(requestBody?.longitude);
const latitude = normalizeNumber(requestBody?.latitude);
await ensureConfigDir();
const data = { longitude, latitude };
await fs.writeJson(CONFIG_PATH, data, { spaces: 2 });
ctx.status = 200;
ctx.body = {
success: true,
...data,
};
} catch (error) {
console.error('保存经纬度配置失败:', error);
ctx.status = 500;
ctx.body = {
success: false,
message: 'save location config failed',
};
}
};

9
server/routes.js

@ -1,6 +1,7 @@
const multer = require('@koa/multer');
const { setupTcpProxy } = require('./tcpProxy');
const locationConfig = require('./controllers/locationConfig.js');
const upload = multer({
limits: { fileSize: 500 * 1024 * 1024 } // 将文件大小限制设置为200MB
});
@ -17,6 +18,14 @@ module.exports = async (app, router, conf) => {
xunruan.decryption,
{ content: '讯软解密', visible: true }
);
router.get('/location-config',
locationConfig.getLocationConfig,
{ content: '读取经纬度配置', visible: false }
);
router.post('/location-config',
locationConfig.saveLocationConfig,
{ content: '保存经纬度配置', visible: false }
);
// 设置TCP代理
setupTcpProxy(conf);
}

Loading…
Cancel
Save