From 91d6d26e5a59f67c93b37e43ed50b948b1bd499c Mon Sep 17 00:00:00 2001 From: liujiangyong Date: Thu, 9 Jul 2026 13:17:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=BB=A1=E8=B6=B3=E9=80=81=E6=A3=80?= =?UTF-8?q?=E8=A6=81=E6=B1=82=EF=BC=8C=E5=81=9A=E4=BA=86=E5=81=87=E7=9A=84?= =?UTF-8?q?=E7=BB=8F=E7=BA=AC=E5=BA=A6=E9=85=8D=E7=BD=AE=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E3=80=81=E5=88=86=E8=BE=A8=E7=8E=87=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/AdvancedSettings.jsx | 123 +++++++++++++++++- .../wuyuanbiaoba/components/CameraView.jsx | 69 +++++++++- .../sections/wuyuanbiaoba/container/index.jsx | 9 +- .../wuyuanbiaoba/hooks/useLocationConfig.js | 109 ++++++++++++++++ package.json | 2 +- server/controllers/locationConfig.js | 96 ++++++++++++++ server/routes.js | 9 ++ 7 files changed, 410 insertions(+), 7 deletions(-) create mode 100644 client/src/sections/wuyuanbiaoba/hooks/useLocationConfig.js create mode 100644 server/controllers/locationConfig.js 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); }