Compare commits
4 Commits
6543c526ca
...
37066e2184
| Author | SHA1 | Date |
|---|---|---|
|
|
37066e2184 | 3 weeks ago |
|
|
505aa3f199 | 3 weeks ago |
|
|
f542142645 | 1 month ago |
|
|
b7182c7066 | 1 month ago |
12 changed files with 1724 additions and 98 deletions
@ -0,0 +1,114 @@ |
|||
import React, { useState } from 'react'; |
|||
import { Card, Input, Button, message } from 'antd'; |
|||
import { LockOutlined } from '@ant-design/icons'; |
|||
|
|||
/** |
|||
* 高级配置密码验证组件 |
|||
*/ |
|||
const AdvancedConfigAuth = ({ verifyPassword, onUnlock }) => { |
|||
const [password, setPassword] = useState(''); |
|||
const [loading, setLoading] = useState(false); |
|||
|
|||
const handleVerify = async () => { |
|||
if (!password) { |
|||
message.warning('请输入密码'); |
|||
return; |
|||
} |
|||
|
|||
setLoading(true); |
|||
try { |
|||
const isValid = await verifyPassword(password); |
|||
|
|||
if (isValid) { |
|||
message.success('密码正确,已解锁高级配置'); |
|||
onUnlock && onUnlock(); |
|||
} else { |
|||
message.error('密码错误,请重试'); |
|||
setPassword(''); |
|||
} |
|||
} catch (error) { |
|||
console.error('密码验证失败:', error); |
|||
message.error('验证过程出错,请重试'); |
|||
} finally { |
|||
setLoading(false); |
|||
} |
|||
}; |
|||
|
|||
const handleKeyPress = (e) => { |
|||
if (e.key === 'Enter') { |
|||
handleVerify(); |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<div |
|||
style={{ |
|||
display: 'flex', |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
minHeight: 'calc(100vh - 92px)', |
|||
padding: '24px', |
|||
}} |
|||
> |
|||
<Card |
|||
style={{ |
|||
width: 400, |
|||
textAlign: 'center', |
|||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)', |
|||
}} |
|||
> |
|||
<div |
|||
style={{ |
|||
width: 64, |
|||
height: 64, |
|||
borderRadius: '50%', |
|||
backgroundColor: '#e6f4ff', |
|||
display: 'flex', |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
margin: '0 auto 24px', |
|||
}} |
|||
> |
|||
<LockOutlined style={{ fontSize: 32, color: '#1890ff' }} /> |
|||
</div> |
|||
|
|||
<h2 style={{ marginBottom: 8 }}>内部高级配置</h2> |
|||
<p style={{ color: '#999', marginBottom: 24 }}> |
|||
请输入管理员访问密码以继续 |
|||
</p> |
|||
|
|||
<Input.Password |
|||
size="large" |
|||
placeholder="请输入密码" |
|||
value={password} |
|||
onChange={(e) => setPassword(e.target.value)} |
|||
onKeyPress={handleKeyPress} |
|||
prefix={<LockOutlined style={{ color: '#999' }} />} |
|||
style={{ marginBottom: 16 }} |
|||
/> |
|||
|
|||
<Button |
|||
type="primary" |
|||
size="large" |
|||
block |
|||
loading={loading} |
|||
onClick={handleVerify} |
|||
> |
|||
解锁配置 |
|||
</Button> |
|||
|
|||
<div |
|||
style={{ |
|||
marginTop: 24, |
|||
fontSize: 12, |
|||
color: '#999', |
|||
}} |
|||
> |
|||
视觉位移计配置工具 v{window.env?.FS_VERSION || ''} Build {new Date().getFullYear()} |
|||
</div> |
|||
</Card> |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
export default AdvancedConfigAuth; |
|||
@ -0,0 +1,630 @@ |
|||
import React, { useEffect } from "react"; |
|||
import { |
|||
Card, |
|||
Input, |
|||
Slider, |
|||
InputNumber, |
|||
Switch, |
|||
Select, |
|||
Row, |
|||
Col, |
|||
Button, |
|||
message, |
|||
Space, |
|||
Typography, |
|||
Spin, |
|||
Alert, |
|||
} from "antd"; |
|||
import { |
|||
SettingOutlined, |
|||
SaveOutlined, |
|||
DatabaseOutlined, |
|||
ClockCircleOutlined, |
|||
BellOutlined, |
|||
FilterOutlined, |
|||
CloudUploadOutlined, |
|||
SyncOutlined, |
|||
} from "@ant-design/icons"; |
|||
import useAdvancedSettings from "../hooks/useAdvancedSettings"; |
|||
|
|||
const { Option } = Select; |
|||
const { Title, Text } = Typography; |
|||
|
|||
const AdvancedSettings = ({ onLogout }) => { |
|||
// 使用高级配置 Hook |
|||
const { |
|||
settings, |
|||
loading, |
|||
isConnected, |
|||
isReady, |
|||
fetchAllSettings, |
|||
saveAllSettings, |
|||
resetSettings, |
|||
updateLocalSettings, |
|||
} = useAdvancedSettings(); |
|||
|
|||
// 只在首次 isReady 变为 true 时自动拉取配置 |
|||
const hasFetchedRef = React.useRef(false); |
|||
useEffect(() => { |
|||
if (isReady && !hasFetchedRef.current) { |
|||
fetchAllSettings(); |
|||
hasFetchedRef.current = true; |
|||
} |
|||
}, [isReady, fetchAllSettings]); |
|||
|
|||
// 从 settings 中提取配置 |
|||
const deviceId = settings.deviceId; |
|||
const fps = settings.dataFps; |
|||
const enableOfflineAlert = settings.alertConfig?.enable ?? false; |
|||
const offlineThreshold = settings.alertConfig?.intervalSec ?? 60; |
|||
const enableFiltering = settings.filterConfig?.enable ?? true; |
|||
const filterMethod = settings.filterConfig?.method ?? 'median'; |
|||
const windowSize = settings.filterConfig?.size ?? 5; |
|||
const filterThreshold = settings.filterConfig?.threshold ?? 0.1; |
|||
const flowThreshold = settings.filterConfig?.imgThreshold ?? 10.0; |
|||
const enableMqtt = settings.mqttConfig?.enable ?? true; |
|||
const brokerAddress = settings.mqttConfig?.mqtt?.broker ?? ''; |
|||
const mqttPort = settings.mqttConfig?.mqtt?.port ?? 1883; |
|||
const mqttTopic = settings.mqttConfig?.mqtt?.topic ?? ''; |
|||
const mqttClientId = settings.mqttConfig?.mqtt?.client_id ?? ''; |
|||
const mqttUsername = settings.mqttConfig?.mqtt?.username ?? ''; |
|||
const mqttPassword = settings.mqttConfig?.mqtt?.password ?? ''; |
|||
|
|||
// 更新本地状态的辅助函数 |
|||
const setDeviceId = (value) => updateLocalSettings({ deviceId: value }); |
|||
const setFps = (value) => updateLocalSettings({ dataFps: value }); |
|||
const setEnableOfflineAlert = (value) => |
|||
updateLocalSettings({ |
|||
alertConfig: { |
|||
enable: value, |
|||
intervalSec: settings.alertConfig?.intervalSec ?? 60 |
|||
}, |
|||
}); |
|||
const setOfflineThreshold = (value) => |
|||
updateLocalSettings({ |
|||
alertConfig: { |
|||
enable: settings.alertConfig?.enable ?? false, |
|||
intervalSec: value |
|||
}, |
|||
}); |
|||
const setEnableFiltering = (value) => |
|||
updateLocalSettings({ |
|||
filterConfig: { ...settings.filterConfig, enable: value }, |
|||
}); |
|||
const setFilterMethod = (value) => |
|||
updateLocalSettings({ |
|||
filterConfig: { ...settings.filterConfig, method: value }, |
|||
}); |
|||
const setWindowSize = (value) => |
|||
updateLocalSettings({ |
|||
filterConfig: { ...settings.filterConfig, size: value }, |
|||
}); |
|||
const setFilterThreshold = (value) => |
|||
updateLocalSettings({ |
|||
filterConfig: { ...settings.filterConfig, threshold: -Math.abs(value) }, |
|||
}); |
|||
const setFlowThreshold = (value) => |
|||
updateLocalSettings({ |
|||
filterConfig: { ...settings.filterConfig, imgThreshold: value }, |
|||
}); |
|||
const setEnableMqtt = (value) => |
|||
updateLocalSettings({ |
|||
mqttConfig: { |
|||
enable: value, |
|||
mqtt: settings.mqttConfig?.mqtt ?? { |
|||
broker: '', |
|||
port: 1883, |
|||
topic: '', |
|||
username: '', |
|||
password: '', |
|||
client_id: '' |
|||
} |
|||
}, |
|||
}); |
|||
const setBrokerAddress = (value) => |
|||
updateLocalSettings({ |
|||
mqttConfig: { |
|||
...settings.mqttConfig, |
|||
mqtt: { |
|||
...(settings.mqttConfig?.mqtt ?? {}), |
|||
broker: value |
|||
}, |
|||
}, |
|||
}); |
|||
const setMqttPort = (value) => |
|||
updateLocalSettings({ |
|||
mqttConfig: { |
|||
...settings.mqttConfig, |
|||
mqtt: { |
|||
...(settings.mqttConfig?.mqtt ?? {}), |
|||
port: value |
|||
}, |
|||
}, |
|||
}); |
|||
const setMqttTopic = (value) => |
|||
updateLocalSettings({ |
|||
mqttConfig: { |
|||
...settings.mqttConfig, |
|||
mqtt: { |
|||
...(settings.mqttConfig?.mqtt ?? {}), |
|||
topic: value |
|||
}, |
|||
}, |
|||
}); |
|||
const setMqttClientId = (value) => |
|||
updateLocalSettings({ |
|||
mqttConfig: { |
|||
...settings.mqttConfig, |
|||
mqtt: { |
|||
...(settings.mqttConfig?.mqtt ?? {}), |
|||
client_id: value |
|||
}, |
|||
}, |
|||
}); |
|||
const setMqttUsername = (value) => |
|||
updateLocalSettings({ |
|||
mqttConfig: { |
|||
...settings.mqttConfig, |
|||
mqtt: { |
|||
...(settings.mqttConfig?.mqtt ?? {}), |
|||
username: value |
|||
}, |
|||
}, |
|||
}); |
|||
const setMqttPassword = (value) => |
|||
updateLocalSettings({ |
|||
mqttConfig: { |
|||
...settings.mqttConfig, |
|||
mqtt: { |
|||
...(settings.mqttConfig?.mqtt ?? {}), |
|||
password: value |
|||
}, |
|||
}, |
|||
}); |
|||
|
|||
const handleSave = () => { |
|||
saveAllSettings(); |
|||
}; |
|||
|
|||
const handleReset = () => { |
|||
fetchAllSettings(); |
|||
}; |
|||
|
|||
return ( |
|||
<Spin spinning={loading} tip="正在加载配置..."> |
|||
<div |
|||
style={{ |
|||
padding: "12px", |
|||
backgroundColor: "#f5f7fa", |
|||
minHeight: "calc(100vh - 92px)", |
|||
}} |
|||
> |
|||
{/* WebSocket 连接状态提示 */} |
|||
{!isConnected && ( |
|||
<Alert |
|||
message="WebSocket 未连接" |
|||
description="当前未连接到设备,无法读取或保存配置。请检查网络连接。" |
|||
type="warning" |
|||
showIcon |
|||
style={{ marginBottom: 12, borderRadius: 8 }} |
|||
/> |
|||
)} |
|||
|
|||
<Card |
|||
style={{ |
|||
marginBottom: 12, |
|||
background: |
|||
"linear-gradient(135deg, #01152cff 0%, #063b77ff 100%)", |
|||
borderRadius: 12, |
|||
}} |
|||
styles={{ padding: 12 }} |
|||
> |
|||
<Row justify="space-between" align="middle"> |
|||
<Col> |
|||
<Space direction="vertical" size={4}> |
|||
<Title |
|||
level={2} |
|||
style={{ |
|||
margin: 0, |
|||
color: "white", |
|||
display: "flex", |
|||
alignItems: "center", |
|||
gap: 12, |
|||
}} |
|||
> |
|||
<SettingOutlined /> |
|||
高级参数配置 |
|||
</Title> |
|||
<Text |
|||
style={{ |
|||
color: "rgba(255,255,255,0.85)", |
|||
fontSize: 14, |
|||
}} |
|||
> |
|||
针对下位机运行的参数配置,修改后请及时保存(慎用) |
|||
</Text> |
|||
|
|||
</Space> |
|||
</Col> |
|||
<Col> |
|||
<Space size="middle"> |
|||
<Button |
|||
icon={<SyncOutlined spin={loading} />} |
|||
onClick={handleReset} |
|||
size="large" |
|||
disabled={!isConnected || loading} |
|||
style={{ height: 42, borderRadius: 8 }} |
|||
> |
|||
刷新配置 |
|||
</Button> |
|||
<Button |
|||
type="primary" |
|||
icon={<SaveOutlined />} |
|||
onClick={handleSave} |
|||
size="large" |
|||
disabled={!isConnected || loading} |
|||
style={{ |
|||
height: 42, |
|||
borderRadius: 8, |
|||
background: "white", |
|||
color: "#667eea", |
|||
borderColor: "white", |
|||
}} |
|||
> |
|||
保存更改 |
|||
</Button> |
|||
</Space> |
|||
</Col> |
|||
</Row> |
|||
</Card> |
|||
|
|||
<Row gutter={12}> |
|||
<Col xs={12} lg={12}> |
|||
<Card |
|||
title={ |
|||
<Space> |
|||
<DatabaseOutlined style={{ color: "#667eea" }} /> |
|||
<span>基础设备信息</span> |
|||
</Space> |
|||
} |
|||
|
|||
style={{ |
|||
marginBottom: 12, |
|||
borderRadius: 12, |
|||
paddingLeft: 24, |
|||
paddingRight: 24, |
|||
}} |
|||
> |
|||
<div style={{ marginBottom: 4, fontWeight: 500 }}> |
|||
设备编码 |
|||
</div> |
|||
<Input |
|||
value={deviceId} |
|||
onChange={(e) => setDeviceId(e.target.value)} |
|||
placeholder="请输入设备编码" |
|||
size="large" |
|||
style={{ borderRadius: 8 }} |
|||
/> |
|||
</Card> |
|||
|
|||
<Card |
|||
title={ |
|||
<Space> |
|||
<ClockCircleOutlined style={{ color: "#667eea" }} /> |
|||
<span>数据帧率 (FPS)</span> |
|||
</Space> |
|||
} |
|||
|
|||
style={{ |
|||
marginBottom: 12, |
|||
borderRadius: 12, |
|||
paddingLeft: 24, |
|||
paddingRight: 24, |
|||
}} |
|||
> |
|||
<div style={{ marginBottom: 12, fontWeight: 500 }}> |
|||
数据帧率 |
|||
</div> |
|||
<Row gutter={12} align="middle"> |
|||
<Col span={18}> |
|||
<Slider |
|||
min={1} |
|||
max={30} |
|||
value={fps} |
|||
onChange={setFps} |
|||
marks={{ 1: "1 Hz", 30: "30 Hz" }} |
|||
/> |
|||
</Col> |
|||
<Col span={6}> |
|||
<InputNumber |
|||
min={1} |
|||
max={30} |
|||
value={fps} |
|||
onChange={setFps} |
|||
style={{ width: "100%", borderRadius: 8 }} |
|||
size="large" |
|||
/> |
|||
</Col> |
|||
</Row> |
|||
<Text type="secondary" style={{ fontSize: 12 }}> |
|||
有效范围 1~30 Hz |
|||
</Text> |
|||
</Card> |
|||
|
|||
<Card |
|||
title={ |
|||
<Space> |
|||
<BellOutlined style={{ color: "#667eea" }} /> |
|||
<span>异常监控与报警</span> |
|||
</Space> |
|||
} |
|||
|
|||
style={{ |
|||
marginBottom: 12, |
|||
borderRadius: 12, |
|||
paddingLeft: 24, |
|||
paddingRight: 24, |
|||
}} |
|||
> |
|||
<div |
|||
style={{ |
|||
background: |
|||
"linear-gradient(135deg, #f5f7fa 0%, #e8eef5 100%)", |
|||
padding: 20, |
|||
borderRadius: 12, |
|||
marginBottom: 12, |
|||
}} |
|||
> |
|||
<Row justify="space-between" align="middle"> |
|||
<Col> |
|||
<Text strong>启用离线超时告警</Text> |
|||
<br /> |
|||
<Text type="secondary" style={{ fontSize: 12 }}> |
|||
超过此时长未接收到新数据则触发报警 |
|||
</Text> |
|||
</Col> |
|||
<Col> |
|||
<Switch |
|||
checked={enableOfflineAlert} |
|||
onChange={setEnableOfflineAlert} |
|||
/> |
|||
</Col> |
|||
</Row> |
|||
</div> |
|||
<div style={{ marginBottom: 4, fontWeight: 500 }}> |
|||
超时阈值 |
|||
</div> |
|||
<InputNumber |
|||
min={10} |
|||
max={300} |
|||
value={offlineThreshold} |
|||
onChange={setOfflineThreshold} |
|||
size="large" |
|||
addonAfter="秒" |
|||
style={{ width: "100%", borderRadius: 8 }} |
|||
/> |
|||
</Card> |
|||
</Col> |
|||
|
|||
<Col xs={12} lg={12}> |
|||
<Card |
|||
title={ |
|||
<Space> |
|||
<FilterOutlined style={{ color: "#667eea" }} /> |
|||
<span>数据滤波与异常记录</span> |
|||
</Space> |
|||
} |
|||
|
|||
style={{ |
|||
marginBottom: 12, |
|||
borderRadius: 12, |
|||
paddingLeft: 24, |
|||
paddingRight: 24, |
|||
}} |
|||
> |
|||
<div |
|||
style={{ |
|||
background: |
|||
"linear-gradient(135deg, #f5f7fa 0%, #e8eef5 100%)", |
|||
padding: 20, |
|||
borderRadius: 12, |
|||
marginBottom: 20, |
|||
}} |
|||
> |
|||
<Row justify="space-between" align="middle"> |
|||
<Col> |
|||
<Text strong>滤波配置</Text> |
|||
<br /> |
|||
<Text type="secondary" style={{ fontSize: 12 }}> |
|||
启用数据滤波算法 |
|||
</Text> |
|||
</Col> |
|||
<Col> |
|||
<Switch |
|||
checked={enableFiltering} |
|||
onChange={setEnableFiltering} |
|||
checkedChildren="已启用" |
|||
unCheckedChildren="已停用" |
|||
/> |
|||
</Col> |
|||
</Row> |
|||
</div> |
|||
<Row gutter={[12, 12]}> |
|||
<Col span={12}> |
|||
<div style={{ marginBottom: 4, fontWeight: 500 }}> |
|||
滤波方法 |
|||
</div> |
|||
<Select |
|||
value={filterMethod} |
|||
onChange={setFilterMethod} |
|||
size="large" |
|||
disabled={!enableFiltering} |
|||
style={{ width: "100%", borderRadius: 8 }} |
|||
> |
|||
<Option value="median">中值滤波</Option> |
|||
</Select> |
|||
</Col> |
|||
<Col span={12}> |
|||
<div style={{ marginBottom: 4, fontWeight: 500 }}> |
|||
窗口大小 |
|||
</div> |
|||
<InputNumber |
|||
min={3} |
|||
max={21} |
|||
step={2} |
|||
value={windowSize} |
|||
onChange={setWindowSize} |
|||
size="large" |
|||
disabled={!enableFiltering} |
|||
style={{ width: "100%", borderRadius: 8 }} |
|||
/> |
|||
</Col> |
|||
<Col span={12}> |
|||
<div style={{ marginBottom: 4, fontWeight: 500 }}> |
|||
滤波阈值 |
|||
</div> |
|||
<InputNumber |
|||
value={filterThreshold} |
|||
onChange={setFilterThreshold} |
|||
addonAfter="mm" |
|||
size="large" |
|||
disabled={!enableFiltering} |
|||
style={{ width: "100%", borderRadius: 8 }} |
|||
/> |
|||
</Col> |
|||
<Col span={12}> |
|||
<div style={{ marginBottom: 4, fontWeight: 500 }}> |
|||
波动阈值 |
|||
</div> |
|||
<InputNumber |
|||
min={0} |
|||
max={100} |
|||
step={0.1} |
|||
value={flowThreshold} |
|||
onChange={setFlowThreshold} |
|||
addonAfter="mm" |
|||
size="large" |
|||
disabled={!enableFiltering} |
|||
style={{ width: "100%", borderRadius: 8 }} |
|||
/> |
|||
</Col> |
|||
</Row> |
|||
</Card> |
|||
|
|||
<Card |
|||
title={ |
|||
<div |
|||
style={{ |
|||
display: "flex", |
|||
justifyContent: "space-between", |
|||
alignItems: "center", |
|||
}} |
|||
> |
|||
<Space> |
|||
<CloudUploadOutlined style={{ color: "#667eea" }} /> |
|||
<span>数据上报 (MQTT)</span> |
|||
</Space> |
|||
<Switch |
|||
checked={enableMqtt} |
|||
onChange={setEnableMqtt} |
|||
checkedChildren="启用" |
|||
unCheckedChildren="禁用" |
|||
/> |
|||
</div> |
|||
} |
|||
|
|||
style={{ |
|||
borderRadius: 12, |
|||
paddingLeft: 24, |
|||
paddingRight: 24, |
|||
}} |
|||
> |
|||
<Row gutter={[12, 12]}> |
|||
<Col span={12}> |
|||
<div style={{ marginBottom: 4, fontWeight: 500 }}> |
|||
Broker Address |
|||
</div> |
|||
<Input |
|||
value={brokerAddress} |
|||
onChange={(e) => setBrokerAddress(e.target.value)} |
|||
placeholder="例如: 218.3.126.49" |
|||
size="large" |
|||
disabled={!enableMqtt} |
|||
style={{ borderRadius: 8 }} |
|||
/> |
|||
</Col> |
|||
<Col span={8}> |
|||
<div style={{ marginBottom: 4, fontWeight: 500 }}> |
|||
Port |
|||
</div> |
|||
<Input |
|||
value={mqttPort} |
|||
onChange={(e) => setMqttPort(e.target.value)} |
|||
placeholder="1883" |
|||
size="large" |
|||
disabled={!enableMqtt} |
|||
style={{ borderRadius: 8 }} |
|||
/> |
|||
</Col> |
|||
<Col span={12}> |
|||
<div style={{ marginBottom: 4, fontWeight: 500 }}> |
|||
Topic |
|||
</div> |
|||
<Input |
|||
value={mqttTopic} |
|||
onChange={(e) => setMqttTopic(e.target.value)} |
|||
placeholder="例如: wybb/z/mqtt179" |
|||
size="large" |
|||
disabled={!enableMqtt} |
|||
style={{ borderRadius: 8 }} |
|||
/> |
|||
</Col> |
|||
<Col span={8}> |
|||
<div style={{ marginBottom: 4, fontWeight: 500 }}> |
|||
Client ID |
|||
</div> |
|||
<Input |
|||
value={mqttClientId} |
|||
onChange={(e) => setMqttClientId(e.target.value)} |
|||
placeholder="wybb_z1_123" |
|||
size="large" |
|||
disabled={!enableMqtt} |
|||
style={{ borderRadius: 8 }} |
|||
/> |
|||
</Col> |
|||
<Col span={8}> |
|||
<div style={{ marginBottom: 4, fontWeight: 500 }}> |
|||
Username |
|||
</div> |
|||
<Input |
|||
value={mqttUsername} |
|||
onChange={(e) => setMqttUsername(e.target.value)} |
|||
placeholder="用户名" |
|||
size="large" |
|||
disabled={!enableMqtt} |
|||
style={{ borderRadius: 8 }} |
|||
/> |
|||
</Col> |
|||
<Col span={8}> |
|||
<div style={{ marginBottom: 4, fontWeight: 500 }}> |
|||
Password |
|||
</div> |
|||
<Input.Password |
|||
value={mqttPassword} |
|||
onChange={(e) => setMqttPassword(e.target.value)} |
|||
placeholder="密码" |
|||
size="large" |
|||
disabled={!enableMqtt} |
|||
style={{ borderRadius: 8 }} |
|||
/> |
|||
</Col> |
|||
</Row> |
|||
</Card> |
|||
</Col> |
|||
</Row> |
|||
</div> |
|||
</Spin> |
|||
); |
|||
}; |
|||
|
|||
export default AdvancedSettings; |
|||
@ -0,0 +1,475 @@ |
|||
/** |
|||
* useAdvancedSettings Hook |
|||
* |
|||
* 高级配置管理 Hook,用于管理设备的高级参数配置 |
|||
* |
|||
* 功能包括: |
|||
* - 设备编码读取与设置 (getId/setId) |
|||
* - 数据帧率读取与设置 (getDataFps/setDataFps) |
|||
* - 异常超时监控读取与设置 (getAlert/setAlert) |
|||
* - 滤波配置读取与设置 (getWin/setWin) |
|||
* - MQTT 上报配置读取与设置 (getMqtt/setMqtt) |
|||
* |
|||
* 使用示例: |
|||
* ```jsx
|
|||
* const { |
|||
* settings, |
|||
* loading, |
|||
* fetchAllSettings, |
|||
* updateDeviceId, |
|||
* updateDataFps, |
|||
* updateAlertConfig, |
|||
* updateFilterConfig, |
|||
* updateMqttConfig, |
|||
* } = useAdvancedSettings(); |
|||
* ``` |
|||
*/ |
|||
|
|||
import { useState, useEffect, useCallback } from 'react'; |
|||
import { message } from 'antd'; |
|||
import { useWebSocket, useWebSocketSubscription } from '../actions/websocket.jsx'; |
|||
|
|||
/** |
|||
* 高级配置默认值 |
|||
*/ |
|||
const DEFAULT_SETTINGS = { |
|||
// 设备编码
|
|||
deviceId: '', |
|||
|
|||
// 数据帧率
|
|||
dataFps: 10, |
|||
|
|||
// 异常监控配置
|
|||
alertConfig: { |
|||
enable: false, |
|||
intervalSec: 60 |
|||
}, |
|||
|
|||
// 滤波配置
|
|||
filterConfig: { |
|||
enable: true, |
|||
method: 'median', |
|||
size: 5, |
|||
threshold: -0.1, |
|||
imgThreshold: 10.0 |
|||
}, |
|||
|
|||
// MQTT 配置
|
|||
mqttConfig: { |
|||
enable: true, |
|||
mqtt: { |
|||
broker: '', |
|||
port: 1883, |
|||
topic: '', |
|||
username: '', |
|||
password: '', |
|||
client_id: '' |
|||
} |
|||
} |
|||
}; |
|||
|
|||
/** |
|||
* 高级配置管理 Hook |
|||
*/ |
|||
const useAdvancedSettings = () => { |
|||
const { isConnected, isReady, sendMessage } = useWebSocket(); |
|||
|
|||
// 配置状态
|
|||
const [settings, setSettings] = useState(DEFAULT_SETTINGS); |
|||
const [loading, setLoading] = useState(false); |
|||
const [fetchStatus, setFetchStatus] = useState({}); |
|||
|
|||
// WebSocket 订阅 - 监听所有来自设备的响应
|
|||
const { latest: getIdResponse } = useWebSocketSubscription('dev', 'getId'); |
|||
const { latest: setIdResponse } = useWebSocketSubscription('dev', 'setId'); |
|||
const { latest: getDataFpsResponse } = useWebSocketSubscription('dev', 'getDataFps'); |
|||
const { latest: setDataFpsResponse } = useWebSocketSubscription('dev', 'setDataFps'); |
|||
const { latest: getAlertResponse } = useWebSocketSubscription('dev', 'getAlert'); |
|||
const { latest: setAlertResponse } = useWebSocketSubscription('dev', 'setAlert'); |
|||
const { latest: getWinResponse } = useWebSocketSubscription('dev', 'getWin'); |
|||
const { latest: setWinResponse } = useWebSocketSubscription('dev', 'setWin'); |
|||
const { latest: getMqttResponse } = useWebSocketSubscription('dev', 'getMqtt'); |
|||
const { latest: setMqttResponse } = useWebSocketSubscription('dev', 'setMqtt'); |
|||
|
|||
/** |
|||
* 处理设备编码读取响应 |
|||
*/ |
|||
useEffect(() => { |
|||
if (getIdResponse?.values?.id !== undefined) { |
|||
console.log('📥 收到设备编码:', getIdResponse.values.id); |
|||
setSettings(prev => ({ |
|||
...prev, |
|||
deviceId: getIdResponse.values.id |
|||
})); |
|||
setFetchStatus(prev => ({ ...prev, deviceId: 'success' })); |
|||
} |
|||
}, [getIdResponse]); |
|||
|
|||
/** |
|||
* 处理设备编码设置响应 |
|||
*/ |
|||
useEffect(() => { |
|||
if (setIdResponse?.values?.operate !== undefined) { |
|||
if (setIdResponse.values.operate) { |
|||
|
|||
} else { |
|||
message.error('设备编码设置失败'); |
|||
} |
|||
} |
|||
}, [setIdResponse]); |
|||
|
|||
/** |
|||
* 处理数据帧率读取响应 |
|||
*/ |
|||
useEffect(() => { |
|||
if (getDataFpsResponse?.values?.dataFps !== undefined) { |
|||
console.log('📥 收到数据帧率:', getDataFpsResponse.values.dataFps); |
|||
setSettings(prev => ({ |
|||
...prev, |
|||
dataFps: getDataFpsResponse.values.dataFps |
|||
})); |
|||
setFetchStatus(prev => ({ ...prev, dataFps: 'success' })); |
|||
} |
|||
}, [getDataFpsResponse]); |
|||
|
|||
/** |
|||
* 处理数据帧率设置响应 |
|||
*/ |
|||
useEffect(() => { |
|||
if (setDataFpsResponse?.values?.operate !== undefined) { |
|||
if (setDataFpsResponse.values.operate) { |
|||
|
|||
} else { |
|||
message.error('数据帧率设置失败'); |
|||
} |
|||
} |
|||
}, [setDataFpsResponse]); |
|||
|
|||
/** |
|||
* 处理异常监控配置读取响应 |
|||
*/ |
|||
useEffect(() => { |
|||
if (getAlertResponse?.values) { |
|||
console.log('📥 收到异常监控配置:', getAlertResponse.values); |
|||
setSettings(prev => ({ |
|||
...prev, |
|||
alertConfig: getAlertResponse.values |
|||
})); |
|||
setFetchStatus(prev => ({ ...prev, alert: 'success' })); |
|||
} |
|||
}, [getAlertResponse]); |
|||
|
|||
/** |
|||
* 处理异常监控设置响应 |
|||
*/ |
|||
useEffect(() => { |
|||
if (setAlertResponse?.values?.operate !== undefined) { |
|||
if (setAlertResponse.values.operate) { |
|||
|
|||
} else { |
|||
message.error('异常监控配置设置失败'); |
|||
} |
|||
} |
|||
}, [setAlertResponse]); |
|||
|
|||
/** |
|||
* 处理滤波配置读取响应 |
|||
*/ |
|||
useEffect(() => { |
|||
if (getWinResponse?.values) { |
|||
console.log('📥 收到滤波配置:', getWinResponse.values); |
|||
setSettings(prev => ({ |
|||
...prev, |
|||
filterConfig: getWinResponse.values |
|||
})); |
|||
setFetchStatus(prev => ({ ...prev, filter: 'success' })); |
|||
} |
|||
}, [getWinResponse]); |
|||
|
|||
/** |
|||
* 处理滤波配置设置响应 |
|||
*/ |
|||
useEffect(() => { |
|||
if (setWinResponse?.values?.operate !== undefined) { |
|||
if (setWinResponse.values.operate) { |
|||
|
|||
} else { |
|||
message.error('滤波配置设置失败'); |
|||
} |
|||
} |
|||
}, [setWinResponse]); |
|||
|
|||
/** |
|||
* 处理 MQTT 配置读取响应 |
|||
*/ |
|||
useEffect(() => { |
|||
if (getMqttResponse?.values) { |
|||
console.log('📥 收到 MQTT 配置:', getMqttResponse.values); |
|||
setSettings(prev => ({ |
|||
...prev, |
|||
mqttConfig: getMqttResponse.values |
|||
})); |
|||
setFetchStatus(prev => ({ ...prev, mqtt: 'success' })); |
|||
} |
|||
}, [getMqttResponse]); |
|||
|
|||
/** |
|||
* 处理 MQTT 配置设置响应 |
|||
*/ |
|||
useEffect(() => { |
|||
if (setMqttResponse?.values?.operate !== undefined) { |
|||
if (setMqttResponse.values.operate) { |
|||
|
|||
} else { |
|||
message.error('MQTT 配置设置失败'); |
|||
} |
|||
} |
|||
}, [setMqttResponse]); |
|||
|
|||
/** |
|||
* 发送 WebSocket 命令 |
|||
*/ |
|||
const sendCommand = useCallback((cmd, values = {}) => { |
|||
if (!isConnected) { |
|||
message.warning('WebSocket 未连接,请稍后重试'); |
|||
return false; |
|||
} |
|||
|
|||
const command = { |
|||
_from: 'setup', |
|||
cmd, |
|||
values |
|||
}; |
|||
|
|||
console.log('📤 发送命令:', command); |
|||
return sendMessage(JSON.stringify(command)); |
|||
}, [isConnected, sendMessage]); |
|||
|
|||
/** |
|||
* 获取设备编码 |
|||
*/ |
|||
const fetchDeviceId = useCallback(() => { |
|||
setFetchStatus(prev => ({ ...prev, deviceId: 'loading' })); |
|||
return sendCommand('getId', {}); |
|||
}, [sendCommand]); |
|||
|
|||
/** |
|||
* 更新设备编码 |
|||
*/ |
|||
const updateDeviceId = useCallback((id) => { |
|||
if (!id || !id.trim()) { |
|||
message.warning('请输入设备编码'); |
|||
return false; |
|||
} |
|||
return sendCommand('setId', { id }); |
|||
}, [sendCommand]); |
|||
|
|||
/** |
|||
* 获取数据帧率 |
|||
*/ |
|||
const fetchDataFps = useCallback(() => { |
|||
setFetchStatus(prev => ({ ...prev, dataFps: 'loading' })); |
|||
return sendCommand('getDataFps', {}); |
|||
}, [sendCommand]); |
|||
|
|||
/** |
|||
* 更新数据帧率 |
|||
*/ |
|||
const updateDataFps = useCallback((dataFps) => { |
|||
if (dataFps < 1 || dataFps > 30) { |
|||
message.warning('数据帧率范围为 1-30 Hz'); |
|||
return false; |
|||
} |
|||
return sendCommand('setDataFps', { dataFps }); |
|||
}, [sendCommand]); |
|||
|
|||
/** |
|||
* 获取异常监控配置 |
|||
*/ |
|||
const fetchAlertConfig = useCallback(() => { |
|||
setFetchStatus(prev => ({ ...prev, alert: 'loading' })); |
|||
return sendCommand('getAlert', {}); |
|||
}, [sendCommand]); |
|||
|
|||
/** |
|||
* 更新异常监控配置 |
|||
*/ |
|||
const updateAlertConfig = useCallback((alertConfig) => { |
|||
const { enable, intervalSec } = alertConfig; |
|||
return sendCommand('setAlert', { enable, intervalSec }); |
|||
}, [sendCommand]); |
|||
|
|||
/** |
|||
* 获取滤波配置 |
|||
*/ |
|||
const fetchFilterConfig = useCallback(() => { |
|||
setFetchStatus(prev => ({ ...prev, filter: 'loading' })); |
|||
return sendCommand('getWin', {}); |
|||
}, [sendCommand]); |
|||
|
|||
/** |
|||
* 更新滤波配置 |
|||
*/ |
|||
const updateFilterConfig = useCallback((filterConfig) => { |
|||
const { enable, method, size, threshold, imgThreshold } = filterConfig; |
|||
return sendCommand('setWin', { |
|||
enable, |
|||
method, |
|||
size, |
|||
threshold, |
|||
imgThreshold |
|||
}); |
|||
}, [sendCommand]); |
|||
|
|||
/** |
|||
* 获取 MQTT 配置 |
|||
*/ |
|||
const fetchMqttConfig = useCallback(() => { |
|||
setFetchStatus(prev => ({ ...prev, mqtt: 'loading' })); |
|||
return sendCommand('getMqtt', {}); |
|||
}, [sendCommand]); |
|||
|
|||
/** |
|||
* 更新 MQTT 配置 |
|||
*/ |
|||
const updateMqttConfig = useCallback((mqttConfig) => { |
|||
const { enable, mqtt } = mqttConfig; |
|||
|
|||
// 验证必填字段
|
|||
if (enable) { |
|||
if (!mqtt.broker || !mqtt.broker.trim()) { |
|||
message.warning('请输入 Broker 地址'); |
|||
return false; |
|||
} |
|||
if (!mqtt.port) { |
|||
message.warning('请输入端口号'); |
|||
return false; |
|||
} |
|||
if (!mqtt.topic || !mqtt.topic.trim()) { |
|||
message.warning('请输入 Topic'); |
|||
return false; |
|||
} |
|||
if (!mqtt.client_id || !mqtt.client_id.trim()) { |
|||
message.warning('请输入 Client ID'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
return sendCommand('setMqtt', { enable, mqtt }); |
|||
}, [sendCommand]); |
|||
|
|||
/** |
|||
* 获取所有配置 |
|||
*/ |
|||
const fetchAllSettings = useCallback(async () => { |
|||
if (!isConnected) { |
|||
message.warning('WebSocket 未连接,无法获取配置'); |
|||
return; |
|||
} |
|||
|
|||
if (!isReady) { |
|||
message.info('连接正在建立中,指令已加入队列...'); |
|||
} |
|||
|
|||
setLoading(true); |
|||
setFetchStatus({}); |
|||
|
|||
console.log('🔄 开始获取所有高级配置...'); |
|||
|
|||
|
|||
// 所有指令都用 setTimeout 包裹,首个指令也延迟,避免第一个指令丢包
|
|||
fetchDeviceId() |
|||
setTimeout(() => fetchDeviceId(), 50); |
|||
setTimeout(() => fetchDataFps(), 150); |
|||
setTimeout(() => fetchAlertConfig(), 250); |
|||
setTimeout(() => fetchFilterConfig(), 350); |
|||
setTimeout(() => fetchMqttConfig(), 450); |
|||
|
|||
// 等待所有响应
|
|||
setTimeout(() => { |
|||
setLoading(false); |
|||
console.log('配置获取完成'); |
|||
}, 2000); |
|||
}, [isConnected, isReady, fetchDeviceId, fetchDataFps, fetchAlertConfig, fetchFilterConfig, fetchMqttConfig]); |
|||
|
|||
/** |
|||
* 保存所有配置 |
|||
*/ |
|||
const saveAllSettings = useCallback(async () => { |
|||
if (!isConnected) { |
|||
message.warning('WebSocket 未连接,无法保存配置'); |
|||
return false; |
|||
} |
|||
|
|||
if (!isReady) { |
|||
message.info('连接正在建立中,指令已加入队列...'); |
|||
} |
|||
|
|||
// 依次发送所有设置命令,并收集每项的结果
|
|||
const results = []; |
|||
results.push(await updateDeviceId(settings.deviceId)); |
|||
results.push(await new Promise(resolve => setTimeout(() => resolve(updateDataFps(settings.dataFps)), 100))); |
|||
results.push(await new Promise(resolve => setTimeout(() => resolve(updateAlertConfig(settings.alertConfig)), 200))); |
|||
results.push(await new Promise(resolve => setTimeout(() => resolve(updateFilterConfig(settings.filterConfig)), 300))); |
|||
results.push(await new Promise(resolve => setTimeout(() => resolve(updateMqttConfig(settings.mqttConfig)), 400))); |
|||
|
|||
// 判断是否全部成功
|
|||
const allSuccess = results.every(r => r !== false); |
|||
if (allSuccess) { |
|||
message.success('保存成功'); |
|||
return true; |
|||
} else { |
|||
message.error('保存失败'); |
|||
return false; |
|||
} |
|||
}, [isConnected, isReady, settings, updateDeviceId, updateDataFps, updateAlertConfig, updateFilterConfig, updateMqttConfig]); |
|||
|
|||
/** |
|||
* 重置配置到默认值 |
|||
*/ |
|||
const resetSettings = useCallback(() => { |
|||
setSettings(DEFAULT_SETTINGS); |
|||
message.info('配置已重置为默认值'); |
|||
}, []); |
|||
|
|||
/** |
|||
* 更新本地设置(不发送到设备) |
|||
*/ |
|||
const updateLocalSettings = useCallback((updates) => { |
|||
setSettings(prev => ({ |
|||
...prev, |
|||
...updates |
|||
})); |
|||
}, []); |
|||
|
|||
// 组件应在需要时自行调用 fetchAllSettings,不再在 hook 内自动调用
|
|||
|
|||
return { |
|||
// 状态
|
|||
settings, |
|||
loading, |
|||
fetchStatus, |
|||
isConnected, |
|||
isReady, |
|||
|
|||
// 操作方法
|
|||
fetchAllSettings, |
|||
saveAllSettings, |
|||
resetSettings, |
|||
updateLocalSettings, |
|||
|
|||
// 单项操作
|
|||
fetchDeviceId, |
|||
updateDeviceId, |
|||
fetchDataFps, |
|||
updateDataFps, |
|||
fetchAlertConfig, |
|||
updateAlertConfig, |
|||
fetchFilterConfig, |
|||
updateFilterConfig, |
|||
fetchMqttConfig, |
|||
updateMqttConfig, |
|||
}; |
|||
}; |
|||
|
|||
export default useAdvancedSettings; |
|||
@ -0,0 +1 @@ |
|||
import { useState, useEffect, useCallback } from "react"; const CORRECT_PASSWORD_HASH = "77796ac7e66ecc44954287ed7de7096c4016dd6ffb2763091c4eb3bc4d28b6dc", AUTH_STATUS_KEY = "advanced_config_unlocked"; async function generatePasswordHash(e) { try { var t = (new TextEncoder).encode(e), c = await crypto.subtle.digest("SHA-256", t); return Array.from(new Uint8Array(c)).map(e => e.toString(16).padStart(2, "0")).join("") } catch (e) { throw console.error("密码哈希生成失败:", e), e } } function checkUnlockStatus() { return "true" === sessionStorage.getItem(AUTH_STATUS_KEY) } function setUnlockStatus(e) { e ? sessionStorage.setItem(AUTH_STATUS_KEY, "true") : sessionStorage.removeItem(AUTH_STATUS_KEY) } function useAuth() { let [e, t] = useState(checkUnlockStatus); return useEffect(() => { t(checkUnlockStatus()) }, []), { isUnlocked: e, verifyPassword: useCallback(async e => { try { return e ? await generatePasswordHash(e) === CORRECT_PASSWORD_HASH && (setUnlockStatus(!0), t(!0), !0) : !1 } catch (e) { return console.error("密码验证失败:", e), !1 } }, []), logout: useCallback(() => { setUnlockStatus(!1), t(!1) }, []) } } export { useAuth, generatePasswordHash, checkUnlockStatus }; |
|||
@ -0,0 +1,246 @@ |
|||
# 高级配置 WebSocket 协议对接文档 |
|||
|
|||
1. 主机编码读取指令 |
|||
|
|||
```json |
|||
{ |
|||
"_from": "setup", |
|||
"cmd": "getId", |
|||
"values": {} |
|||
} |
|||
``` |
|||
|
|||
响应示例 |
|||
|
|||
```json |
|||
{ |
|||
"_from": "dev", |
|||
"cmd": "getId", |
|||
"values": { |
|||
"id": "uu1234" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
2. 主机编码设置指令 |
|||
|
|||
```json |
|||
{ |
|||
"_from": "setup", |
|||
"cmd": "setId", |
|||
"values": { "id": "uu1234" } |
|||
} |
|||
``` |
|||
|
|||
响应示例 |
|||
|
|||
```json |
|||
{ |
|||
"_from": "dev", |
|||
"cmd": "setId", |
|||
"values": { "operate": true } |
|||
} |
|||
``` |
|||
|
|||
3. 帧率读取指令 |
|||
|
|||
```json |
|||
{ |
|||
"_from": "setup", |
|||
"cmd": "getDataFps", |
|||
"values": {} |
|||
} |
|||
``` |
|||
响应示例 |
|||
|
|||
```json |
|||
{ |
|||
"_from": "dev", |
|||
"cmd": "getDataFps", |
|||
"values": { "dataFps": 10 } |
|||
} |
|||
``` |
|||
|
|||
4. 帧率设置指令 |
|||
|
|||
```json |
|||
{ |
|||
"_from": "setup", |
|||
"cmd": "setDataFps", |
|||
"values": { "dataFps": 10 } |
|||
} |
|||
``` |
|||
响应示例 |
|||
|
|||
```json |
|||
{ |
|||
"_from": "dev", |
|||
"cmd": "setDataFps", |
|||
"values": { "operate": true } |
|||
} |
|||
``` |
|||
|
|||
5. 异常超时监控-读取指令 |
|||
```json |
|||
{ |
|||
"_from": "setup", |
|||
"cmd": "getAlert", |
|||
"values": {} |
|||
} |
|||
``` |
|||
响应示例 |
|||
```json |
|||
{ |
|||
"_from": "dev", |
|||
"cmd": "getAlert", |
|||
"values": { |
|||
"enable": false, |
|||
"intervalSec": 6 |
|||
} |
|||
} |
|||
``` |
|||
|
|||
6. 异常超时监控-设置指令 |
|||
```json |
|||
{ |
|||
"_from": "setup", |
|||
"cmd": "setAlert", |
|||
"values": { |
|||
"enable": false, |
|||
"intervalSec": 6 |
|||
} |
|||
} |
|||
``` |
|||
响应示例 |
|||
```json |
|||
{ |
|||
"_from": "dev", |
|||
"cmd": "setAlert", |
|||
"values": { |
|||
"operate": true |
|||
} |
|||
} |
|||
``` |
|||
|
|||
7. 滤波-读取 |
|||
```json |
|||
{ |
|||
"_from": "setup", |
|||
"cmd":"getWin", |
|||
"values": {} |
|||
} |
|||
``` |
|||
响应示例 |
|||
```json |
|||
{ |
|||
"_from": "dev", |
|||
"cmd": "getWin", |
|||
"values": { |
|||
"enable": true, |
|||
"method": "median", |
|||
"size": 5, |
|||
"threshold": -0.1, |
|||
"imgThreshold": 10.0 |
|||
} |
|||
} |
|||
``` |
|||
|
|||
8. 滤波-设置 |
|||
```json |
|||
{ |
|||
"_from": "setup", |
|||
"cmd":"setWin", |
|||
"values": { |
|||
"enable": true, |
|||
"method": "median", |
|||
"size": 5, |
|||
"threshold": -0.1, |
|||
"imgThreshold": 10.0 |
|||
} |
|||
} |
|||
``` |
|||
响应示例 |
|||
```json |
|||
{ |
|||
"_from": "dev", |
|||
"cmd": "setWin", |
|||
"values": { |
|||
"operate": true |
|||
} |
|||
} |
|||
``` |
|||
|
|||
9. mqtt 上报读取 |
|||
```json |
|||
{ |
|||
"_from": "setup", |
|||
"cmd":"getMqtt", |
|||
"values": {} |
|||
} |
|||
``` |
|||
响应示例 |
|||
```json |
|||
{ |
|||
"_from": "dev", |
|||
"cmd": "getMqtt", |
|||
"values": { |
|||
"mqtt": { |
|||
"broker": "218.3.126.49", |
|||
"port": 1883, |
|||
"topic": "wybb/zj/mqtt110_debug", |
|||
"username": "", |
|||
"password": "", |
|||
"client_id": "wybb_debug" |
|||
}, |
|||
"enable": true |
|||
} |
|||
} |
|||
``` |
|||
|
|||
10. mqtt上报设置 |
|||
```json |
|||
{ |
|||
"_from": "setup", |
|||
"cmd": "setMqtt", |
|||
"values": { |
|||
"mqtt": { |
|||
"broker": "218.3.126.49", |
|||
"port": 1883, |
|||
"topic": "wybb/zj/mqtt110_debug", |
|||
"username": "", |
|||
"password": "", |
|||
"client_id": "wybb_debug" |
|||
}, |
|||
"enable": true |
|||
} |
|||
} |
|||
``` |
|||
响应示例 |
|||
```json |
|||
{ |
|||
"_from": "dev", |
|||
"cmd": "setMqtt", |
|||
"values": { |
|||
"operate": true |
|||
} |
|||
} |
|||
``` |
|||
10. 异常超时监控-读取 |
|||
```json |
|||
{ |
|||
"_from": "setup", |
|||
"cmd":"getAlert", |
|||
"values": {} |
|||
} |
|||
``` |
|||
响应示例 |
|||
```json |
|||
{ |
|||
"_from": "dev", |
|||
"cmd": "getAlert", |
|||
"values": { |
|||
"enable": false, |
|||
"intervalSec": 6 |
|||
} |
|||
} |
|||
``` |
|||
Loading…
Reference in new issue