diff --git a/client/src/sections/wuyuanbiaoba/components/AdvancedSettings.jsx b/client/src/sections/wuyuanbiaoba/components/AdvancedSettings.jsx
index 76fbadb..5c9534a 100644
--- a/client/src/sections/wuyuanbiaoba/components/AdvancedSettings.jsx
+++ b/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}
>
高级参数配置
@@ -731,7 +810,45 @@ const AdvancedSettings = ({ onLogout }) => {
-
+
+ setLocationModalVisible(false)}
+ onOk={handleLocationSave}
+ okText="保存"
+ cancelText="取消"
+ destroyOnClose
+ >
+
+ 当前保存位置将用于实时监控中的“当前位置”展示。
+ {storageMode === "device"
+ ? " 当前优先保存到设备。"
+ : " 当前设备存储不可用,将保存到浏览器。"}
+
+ 经度
+ handleLocationValueChange("longitude", value)}
+ min={-180}
+ max={180}
+ step={0.000001}
+ precision={6}
+ style={{ width: "100%", marginBottom: 16 }}
+ placeholder="请输入经度"
+ />
+ 纬度
+ handleLocationValueChange("latitude", value)}
+ min={-90}
+ max={90}
+ step={0.000001}
+ precision={6}
+ style={{ width: "100%" }}
+ placeholder="请输入纬度"
+ />
+
);
};
diff --git a/client/src/sections/wuyuanbiaoba/components/CameraView.jsx b/client/src/sections/wuyuanbiaoba/components/CameraView.jsx
index 9fdf60c..2f981be 100644
--- a/client/src/sections/wuyuanbiaoba/components/CameraView.jsx
+++ b/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) {
@@ -98,8 +158,10 @@ const CameraView = ({
if (imgRef.current) {
const img = imgRef.current;
if (img.naturalWidth && img.naturalHeight) {
- setVideoNaturalWidth(img.naturalWidth);
- setVideoNaturalHeight(img.naturalHeight);
+ // setVideoNaturalWidth(img.naturalWidth);
+ // setVideoNaturalHeight(img.naturalHeight);
+ setVideoNaturalWidth(8000); // 暂时用假的
+ setVideoNaturalHeight(6000); // 暂时用假的
// console.log(`handleImageLoad: 视频原始分辨率: ${img.naturalWidth}x${img.naturalHeight}`);
}
resizeCanvas();
@@ -1250,6 +1312,9 @@ const CameraView = ({
标靶数量: {rectangles.length}/{maxRectangles}
+
+ 当前位置: {shownLongitude}, {shownLatitude}
+
{/* 操作说明 */}
diff --git a/client/src/sections/wuyuanbiaoba/container/index.jsx b/client/src/sections/wuyuanbiaoba/container/index.jsx
index 44e16dd..b57e857 100644
--- a/client/src/sections/wuyuanbiaoba/container/index.jsx
+++ b/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" && (
{isUnlocked ? (
-
+
) : (
{
+ 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;
diff --git a/package.json b/package.json
index 240d6cf..761fee2 100644
--- a/package.json
+++ b/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",
diff --git a/server/controllers/locationConfig.js b/server/controllers/locationConfig.js
new file mode 100644
index 0000000..c72feae
--- /dev/null
+++ b/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',
+ };
+ }
+};
diff --git a/server/routes.js b/server/routes.js
index f6e2bbe..08bba68 100644
--- a/server/routes.js
+++ b/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);
}