commit
496f0754fd
65 changed files with 10453 additions and 0 deletions
@ -0,0 +1,17 @@ |
|||
# http://editorconfig.org |
|||
root = true |
|||
|
|||
[*] |
|||
indent_style = space |
|||
indent_size = 3 |
|||
end_of_line = lf |
|||
charset = utf-8 |
|||
trim_trailing_whitespace = true |
|||
insert_final_newline = true |
|||
|
|||
[*.md] |
|||
trim_trailing_whitespace = false |
|||
indent_size = 2 |
|||
|
|||
[Makefile] |
|||
indent_style = tab |
@ -0,0 +1,7 @@ |
|||
node_modules/ |
|||
dist/ |
|||
.env |
|||
npm-debug.log |
|||
.DS_Store |
|||
*.log |
|||
.idea/ |
@ -0,0 +1,3 @@ |
|||
registry=https://registry.npmmirror.com |
|||
@peace/:registry=https://nexus.ngaiot.com/repository/fs-npm |
|||
@fs/attachment:registry=https://nexus.ngaiot.com/repository/fs-npm |
@ -0,0 +1,97 @@ |
|||
{ |
|||
// 使用 IntelliSense 了解相关属性。 |
|||
// 悬停以查看现有属性的描述。 |
|||
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 |
|||
"version": "0.2.0", |
|||
"configurations": [ |
|||
{ |
|||
"type": "node", |
|||
"request": "launch", |
|||
"name": "开发模式", |
|||
"cwd": "${workspaceRoot}", |
|||
"runtimeExecutable": "npm", |
|||
"skipFiles": [ |
|||
"<node_internals>/**" |
|||
], |
|||
"runtimeArgs": [ |
|||
"run", |
|||
"start", |
|||
"--", |
|||
"--inspect-brk" |
|||
], |
|||
"console": "integratedTerminal", |
|||
"env": { |
|||
/* |
|||
参数都写在这里 省事儿 |
|||
修改后需重启 |
|||
*/ |
|||
"NODE_ENV": "development", |
|||
"PORT": "5000", |
|||
// |
|||
"FS_QINIU_DOMAIN": "https://resources-test.anxinyun.cn", |
|||
"FS_QINIU_ACTION": "https://up-z0.qiniup.com", |
|||
"FS_QINIU_ASSETS_DIR": "temp_v5", |
|||
// |
|||
"FS_SOCKET_URL": "ws://localhost:4000", |
|||
// |
|||
"API": "http://localhost:4000", |
|||
// TCP代理配置 |
|||
"TCP_HOST": "10.8.30.179", |
|||
"TCP_PORT": "2230", |
|||
} |
|||
}, |
|||
{ |
|||
"type": "node", |
|||
"request": "launch", |
|||
"name": "本地构建", |
|||
"cwd": "${workspaceRoot}", |
|||
"runtimeExecutable": "npm", |
|||
"skipFiles": [ |
|||
"<node_internals>/**" |
|||
], |
|||
"runtimeArgs": [ |
|||
"run", |
|||
"build", |
|||
// "--", |
|||
// "--inspect-brk" |
|||
], |
|||
"console": "integratedTerminal", |
|||
"env": {} |
|||
}, |
|||
{ |
|||
"type": "node", |
|||
"request": "launch", |
|||
"name": "生产模式", |
|||
"cwd": "${workspaceRoot}", |
|||
"runtimeExecutable": "npm", |
|||
"skipFiles": [ |
|||
"<node_internals>/**" |
|||
], |
|||
"runtimeArgs": [ |
|||
"run", |
|||
"start", |
|||
"--", |
|||
"--inspect-brk" |
|||
], |
|||
"console": "integratedTerminal", |
|||
"env": { |
|||
/* |
|||
参数都写在这里 省事儿 |
|||
修改后需重启 |
|||
*/ |
|||
"NODE_ENV": "production", |
|||
"PORT": "5100", |
|||
/* ... */ |
|||
"FS_QINIU_DOMAIN": "https://resources-test.anxinyun.cn", |
|||
"FS_QINIU_ACTION": "https://up-z0.qiniup.com", |
|||
"FS_QINIU_ASSETS_DIR": "temp_v5", |
|||
// |
|||
"FS_SOCKET_URL": "ws://localhost:4000", |
|||
// |
|||
"API": "http://localhost:4000", |
|||
"TCP_HOST": "127.0.0.1", |
|||
"TCP_PORT": "2230", |
|||
} |
|||
}, |
|||
] |
|||
} |
@ -0,0 +1,3 @@ |
|||
{ |
|||
"editor.tabSize": 3 |
|||
} |
@ -0,0 +1,21 @@ |
|||
FROM node:20.18-alpine AS build-stage |
|||
WORKDIR /app |
|||
COPY package*.json ./ |
|||
COPY .npmrc ./ |
|||
RUN npm install --loglevel=http |
|||
COPY . . |
|||
RUN npm run build |
|||
|
|||
FROM node:20.18-alpine AS prod-stage |
|||
WORKDIR /app |
|||
COPY package*.json ./ |
|||
COPY .npmrc ./ |
|||
COPY config.cjs ./ |
|||
COPY server ./server |
|||
RUN npm install --loglevel=http |
|||
COPY --from=build-stage /app/dist ./dist |
|||
ENV NODE_ENV=production |
|||
ENV PORT=8080 |
|||
EXPOSE 8080 |
|||
EXPOSE 8081 |
|||
CMD ["npm", "start"] |
@ -0,0 +1,13 @@ |
|||
# 无源标靶上位机 |
|||
|
|||
## Docker |
|||
### 构建镜像 |
|||
|
|||
```bash |
|||
docker build -t passive-targeting-web . |
|||
``` |
|||
### 运行容器 |
|||
|
|||
```bash |
|||
docker run -d -p 8080:8080 -p 8081:8081 --name passive-targeting-web passive-targeting-web |
|||
``` |
After Width: | Height: | Size: 4.2 KiB |
After Width: | Height: | Size: 1.6 MiB |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 8.2 KiB |
After Width: | Height: | Size: 536 KiB |
@ -0,0 +1,7 @@ |
|||
console.log(` |
|||
,----. ,------. ,------. ,---. ,-----.,------. |
|||
' , | | .--. '| .---' / O \\ ' .--./| .---' |
|||
| | / | '--' || \`--, | .-. || | | \`--, |
|||
' '--'| | | --' |\`-- -. | | | |' '--'\\\| \`---.
|
|||
\`----' \`--' \`----- '·--''--·\`-----' \`-----'
|
|||
`);
|
@ -0,0 +1,10 @@ |
|||
import React from 'react' |
|||
import ReactDOM from 'react-dom/client' |
|||
import App from '../index' |
|||
|
|||
ReactDOM.hydrateRoot( |
|||
document.getElementById('root'), |
|||
// <React.StrictMode> |
|||
<App /> |
|||
// </React.StrictMode> |
|||
) |
@ -0,0 +1,12 @@ |
|||
import React from 'react' |
|||
import ReactDOMServer from 'react-dom/server' |
|||
import App from '../index' |
|||
|
|||
export function render ({ path }) { |
|||
const html = ReactDOMServer.renderToString( |
|||
<React.StrictMode> |
|||
<App /> |
|||
</React.StrictMode> |
|||
) |
|||
return { html } |
|||
} |
@ -0,0 +1,14 @@ |
|||
import App from "./src/app"; |
|||
import { ConfigProvider } from "antd"; |
|||
import zhCN from 'antd/locale/zh_CN'; |
|||
|
|||
export default () => { |
|||
return ( |
|||
<ConfigProvider |
|||
locale={zhCN} |
|||
theme={{}} |
|||
> |
|||
<App /> |
|||
</ConfigProvider> |
|||
) |
|||
} |
@ -0,0 +1,13 @@ |
|||
"use strict"; |
|||
import { useEffect, useState } from "react"; |
|||
import Layout from "./layout/index"; |
|||
import Auth from "./sections/auth"; |
|||
import Home from "./sections/home"; |
|||
import Wuyuanbiaoba from "./sections/wuyuanbiaoba"; |
|||
import "./app.less"; |
|||
const App = () => { |
|||
return <Layout |
|||
sections={[Auth, Home, Wuyuanbiaoba]} |
|||
/> |
|||
}; |
|||
export default App; |
@ -0,0 +1,36 @@ |
|||
* { |
|||
margin: 0; |
|||
padding: 0; |
|||
box-sizing: border-box; |
|||
user-select: none; |
|||
} |
|||
|
|||
// 美化滚动条 |
|||
::-webkit-scrollbar { |
|||
width: 8px; |
|||
} |
|||
|
|||
::-webkit-scrollbar-track { |
|||
width: 6px; |
|||
background: rgba(#101f1c, 0.1); |
|||
-webkit-border-radius: 1em; |
|||
-moz-border-radius: 1em; |
|||
border-radius: 1em; |
|||
/* 通过 margin 设置轨道与元素的距离 */ |
|||
margin: 6px; |
|||
} |
|||
|
|||
::-webkit-scrollbar-thumb { |
|||
background-color: rgba(144, 147, 153, 0.5); |
|||
background-clip: padding-box; |
|||
min-height: 28px; |
|||
-webkit-border-radius: 2em; |
|||
-moz-border-radius: 2em; |
|||
border-radius: 2em; |
|||
transition: background-color 0.3s; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
::-webkit-scrollbar-thumb:hover { |
|||
background-color: rgba(144, 147, 153, 0.3); |
|||
} |
@ -0,0 +1,35 @@ |
|||
import React, { Component } from 'react'; |
|||
import { Result, Button } from 'antd'; |
|||
|
|||
class ErrorBoundary extends Component { |
|||
constructor(props) { |
|||
super(props); |
|||
this.state = { hasError: false }; |
|||
} |
|||
|
|||
static getDerivedStateFromError (error) { |
|||
// 更新 state 使下一次渲染能够显示降级后的 UI |
|||
return { hasError: true }; |
|||
} |
|||
|
|||
componentDidCatch (error, errorInfo) { |
|||
// 可以将错误日志上报给服务器 |
|||
} |
|||
|
|||
render () { |
|||
const { errorRender } = this.props; |
|||
if (this.state.hasError) { |
|||
// 可以自定义降级后的 UI 并渲染 |
|||
return errorRender || <Result |
|||
status="error" |
|||
title="页面出错了哦!" |
|||
subTitle="请联系管理员进行修复" |
|||
// extra={<Button onClick={() => this.state.navigate(-1)}>返回上一页</Button>} |
|||
/>; |
|||
} |
|||
|
|||
return this.props.children; |
|||
} |
|||
} |
|||
|
|||
export default ErrorBoundary; |
@ -0,0 +1,7 @@ |
|||
// import Upload from "./upload";
|
|||
import ErrorBoundary from "./errorBoundary"; |
|||
|
|||
export { |
|||
// Upload,
|
|||
ErrorBoundary, |
|||
}; |
@ -0,0 +1,150 @@ |
|||
import React, { useState, useMemo, useEffect } from 'react'; |
|||
import { useDispatch, useSelector } from "react-redux"; |
|||
import { UploadOutlined } from '@ant-design/icons'; |
|||
import { Button, Upload, Spin, message, Image } from 'antd'; |
|||
import moment from 'moment'; |
|||
import request from "superagent"; |
|||
|
|||
const Images = ['png', 'jpg', 'svg', 'jpeg'] |
|||
|
|||
export default function upload (props) { |
|||
const { value = null, onChange = () => { }, maxCount, maxSize = 3, types = [], disabled = false, xunruanDecode, ...uploadOption } = props; |
|||
const { FS_QINIU_DOMAIN, FS_QINIU_ACTION, FS_QINIU_ASSETS_DIR } = window.env; |
|||
/** |
|||
* fileList: [] # 参考 |
|||
* types: ['jpg'] # 限定文件类型 |
|||
* maxSize: 1024 # 限定文件大小 M |
|||
*/ |
|||
const [messageApi, contextHolder] = message.useMessage(); |
|||
const actions = useSelector(state => state.global.actions); |
|||
const qiniuToken = useSelector(state => state.qiniuToken); |
|||
const dispatch = useDispatch(); |
|||
// |
|||
const [fileList_, setFileList_] = useState([]); |
|||
const [previewImg, setPreviewImg] = useState(null); |
|||
|
|||
useEffect(() => { |
|||
if (!qiniuToken?.data) { |
|||
dispatch(actions.qiniuToken()) |
|||
} |
|||
}, []) |
|||
|
|||
const uploadProps = { |
|||
name: "file", |
|||
action: FS_QINIU_ACTION, |
|||
fileList: (value || fileList_).map((df, index) => { |
|||
return { |
|||
...df, |
|||
uid: df.uid || -index, |
|||
status: df.status || 'done', |
|||
name: df?.url?.split('/').pop() ?? df.name, |
|||
url: df.url ? |
|||
df.url.startsWith('http') ? df.url |
|||
: `${FS_QINIU_DOMAIN}/${df?.url}` |
|||
: df.thumbUrl, |
|||
} |
|||
}), |
|||
// disabled: disabled || (maxCount && fileList_.length >= maxCount), |
|||
data: (file) => { |
|||
return { |
|||
token: qiniuToken.data, |
|||
/** key 表现为文件名 */ |
|||
key: `${FS_QINIU_ASSETS_DIR}/${moment().format('YYYYMMDDHHmmss')}/${file.name}`, |
|||
} |
|||
}, |
|||
beforeUpload: (file) => { |
|||
return new Promise((resolve, reject) => { |
|||
if (maxCount && (value || fileList_).length >= maxCount) { |
|||
messageApi.warning(`最多选择${maxCount}个文件上传`) |
|||
return Upload.LIST_IGNORE |
|||
} |
|||
if (file.name.length > 60) { |
|||
messageApi.warning(`文件名过长(大于60字符),请修改后上传`); |
|||
return Upload.LIST_IGNORE |
|||
} |
|||
if (file.size > maxSize * 1024 * 1024) { |
|||
messageApi.warning(`文件须小于 ${maxSize} M`); |
|||
return Upload.LIST_IGNORE |
|||
} |
|||
let fileNameArr = file.name.split('.') |
|||
let postfix = '' |
|||
if (fileNameArr.length > 1) { |
|||
postfix = fileNameArr[fileNameArr.length - 1] |
|||
postfix.toLowerCase() |
|||
} |
|||
if (types.length && (!types.includes(postfix) || !postfix)) { |
|||
messageApi.warning(`请上传 ${types.join(',')} 类型的文件`); |
|||
return Upload.LIST_IGNORE |
|||
} |
|||
return resolve() |
|||
}) |
|||
}, |
|||
customRequest: xunruanDecode ? async (p) => { |
|||
const { file, filename, onSuccess, onError } = p |
|||
try { |
|||
// 讯软解密 |
|||
const formData = new FormData(); |
|||
formData.append("file", file); |
|||
const decodeRes = await request.post("/xunruan/decryption",).send(formData); |
|||
onSuccess({ |
|||
...decodeRes.text |
|||
}) |
|||
} catch (error) { |
|||
console.error(error); |
|||
onError({}) |
|||
} |
|||
} : undefined, |
|||
|
|||
|
|||
onChange: (files) => { |
|||
let fileList = files?.fileList?.map(f => { |
|||
return { |
|||
...f, |
|||
url: f.response?.key ?? f?.url ?? f.thumbUrl |
|||
} |
|||
}) |
|||
setFileList_(fileList); |
|||
onChange(fileList) |
|||
}, |
|||
onPreview: (file) => { |
|||
const url = file?.url || `${FS_QINIU_DOMAIN} / ${file?.response.key}` |
|||
let postfix = url.split('.').pop(); |
|||
postfix = postfix.toLowerCase(); |
|||
if (url.indexOf("pdf") !== -1 || url.indexOf("csv") !== -1) { |
|||
window.open(url) |
|||
} else if (Images.includes(postfix)) { |
|||
setPreviewImg(url); |
|||
} else { |
|||
window.open(`https://view.officeapps.live.com/op/view.aspx?src=${url}`) |
|||
} |
|||
}, |
|||
// maxCount: maxCount, |
|||
...uploadOption |
|||
} |
|||
|
|||
return ( |
|||
<Spin spinning={qiniuToken?.loading} > |
|||
{contextHolder} |
|||
<Upload Upload |
|||
{...uploadProps} |
|||
> |
|||
{props.children || <Button icon={<UploadOutlined />} />} |
|||
</Upload > |
|||
{ |
|||
previewImg ? <Image |
|||
style={{ display: 'none', } |
|||
} |
|||
src="" |
|||
preview={{ |
|||
visible: true, |
|||
src: previewImg, |
|||
onVisibleChange: (value) => { |
|||
setPreviewImg(null); |
|||
}, |
|||
} |
|||
} |
|||
/> : '' |
|||
} |
|||
</Spin > |
|||
) |
|||
} |
@ -0,0 +1,93 @@ |
|||
import React, { useEffect, useState, useRef, useContext } from "react"; |
|||
import { Breadcrumb, Layout, theme } from "antd"; |
|||
import { Outlet, useLocation } from "react-router-dom"; |
|||
import { useDispatch, useSelector } from "react-redux"; |
|||
import { ErrorBoundary } from '@comps' |
|||
|
|||
const { Content } = Layout; |
|||
|
|||
export default function content (props) { |
|||
const { token: { colorBgContainer, borderRadiusLG }, } = theme.useToken(); |
|||
const actions = useSelector(state => state.global.actions); |
|||
const dispatch = useDispatch(); |
|||
const location = useLocation(); |
|||
const [breadcrumbItems, setBreadcrumbItems] = useState([]); |
|||
const contentRef = useRef(); |
|||
|
|||
useEffect(() => { |
|||
/* 监听 content 容器大小变化 */ |
|||
const resizeObserver = new ResizeObserver(entries => { |
|||
for (const entry of entries) { // 因为只监听一个元素(contentRef),所以不做判断 |
|||
const { width, height } = entry.contentRect; |
|||
dispatch(actions?.setContentSize({ width, height })) |
|||
} |
|||
}); |
|||
resizeObserver.observe(contentRef.current); |
|||
}, []) |
|||
|
|||
useEffect(() => { |
|||
/** 组织面包屑 */ |
|||
let { pathname = '' } = location |
|||
if (pathname.startsWith('/')) { |
|||
pathname = pathname.substring(1) |
|||
} |
|||
let breadcrumbList = [] |
|||
if (pathname) { |
|||
let pathnamArr = pathname.split('/') |
|||
let nav_ = props.routerNav; |
|||
|
|||
for (let kp of pathnamArr) { |
|||
for (let nv of nav_) { |
|||
if (kp == nv?.path) { |
|||
breadcrumbList.push({ title: nv.label, patch: nv.path }); |
|||
nav_ = nv.children || []; |
|||
break |
|||
} |
|||
} |
|||
} |
|||
} |
|||
setBreadcrumbItems(breadcrumbList ?? []); |
|||
}, [location.pathname]); |
|||
|
|||
return ( |
|||
<> |
|||
<Content |
|||
ref={contentRef} |
|||
style={{ margin: "0 12px 0 0", position: 'relative' }} |
|||
> |
|||
<Breadcrumb |
|||
style={{ margin: "8px 0", minHeight: 22 }} |
|||
items={breadcrumbItems} |
|||
onClick={e => { }} |
|||
/> |
|||
<div style={{ |
|||
paddingRight: 6, |
|||
height: 'calc(100% - 50px)', |
|||
background: colorBgContainer, |
|||
borderRadius: borderRadiusLG, |
|||
overflow: 'hidden', |
|||
}}> |
|||
<div |
|||
style={{ |
|||
padding: '12px 4px 12px 12px', |
|||
minHeight: 360, |
|||
height: '100%', |
|||
overflow: 'auto', |
|||
}} |
|||
> |
|||
<ErrorBoundary> |
|||
<Outlet /> |
|||
</ErrorBoundary> |
|||
</div> |
|||
</div> |
|||
</Content> |
|||
{/* <Footer |
|||
style={{ |
|||
textAlign: "center", |
|||
}} |
|||
> |
|||
江西飞尚科技 ©{new Date().getFullYear()} |
|||
</Footer> */} |
|||
</> |
|||
); |
|||
} |
@ -0,0 +1,42 @@ |
|||
import React, { useContext } from "react"; |
|||
import { Layout, Dropdown, Space, theme } from "antd"; |
|||
import { UserOutlined } from "@ant-design/icons"; |
|||
import { useNavigate } from "react-router-dom"; |
|||
import fsLogo from "@assets/img/fs_logo_banner.png"; |
|||
|
|||
const { Header } = Layout; |
|||
|
|||
export default function header () { |
|||
const navigate = useNavigate(); |
|||
const userInfo = JSON.parse(sessionStorage.getItem("user")); |
|||
|
|||
const { token: { colorBgContainer }, } = theme.useToken(); |
|||
|
|||
return ( |
|||
<Header style={{ padding: '0 24px', background: colorBgContainer, display: "flex", justifyContent: "space-between", alignItems: "center", boxShadow: '0px 3px 6px -4px rgba(0, 0, 0, 0.12)', zIndex: 1 }}> |
|||
<div style={{ display: 'flex' }}> |
|||
<img style={{ height: 40, }} src={fsLogo} /> |
|||
</div> |
|||
<Dropdown |
|||
menu={{ |
|||
items: [{ |
|||
label: <div onClick={() => { |
|||
navigate("/signin"); |
|||
sessionStorage.removeItem("user"); |
|||
}}> |
|||
退出登录 |
|||
</div>, |
|||
key: "logout", |
|||
},] |
|||
}} |
|||
> |
|||
<a onClick={e => e.preventDefault()}> |
|||
<Space> |
|||
<UserOutlined /> |
|||
{userInfo?.name} |
|||
</Space> |
|||
</a> |
|||
</Dropdown> |
|||
</Header> |
|||
); |
|||
} |
@ -0,0 +1,76 @@ |
|||
import React, { useEffect, useContext } from "react"; |
|||
import { Menu, Layout, theme } from "antd"; |
|||
import { useState } from "react"; |
|||
import "../container/index.less"; |
|||
import { useNavigate } from "react-router-dom"; |
|||
|
|||
const { Sider } = Layout; |
|||
|
|||
export default function sider (props) { |
|||
const navigate = useNavigate(); |
|||
const { token: { colorBgContainer }, } = theme.useToken(); |
|||
const { collapsed } = props |
|||
const [menuItems, setMenuItems] = useState([]); |
|||
const [openKeys, setOpenKeys] = useState([]) |
|||
const [selectedKeys, setSelectedKeys] = useState([]) |
|||
|
|||
useEffect(() => { |
|||
const siderNav = (nav = []) => { |
|||
return nav.filter(item => item.nav) |
|||
.map(item => { |
|||
return { |
|||
label: item.label, |
|||
key: item.path, |
|||
icon: item.icon, |
|||
children: item.children && siderNav(item.children), |
|||
}; |
|||
}) |
|||
}; |
|||
|
|||
setMenuItems(siderNav(props.routerNav)) |
|||
}, [props.routerNav]) |
|||
|
|||
useEffect(() => { |
|||
let siderOpenKeys = sessionStorage.getItem('siderOpenKeys') |
|||
if (siderOpenKeys) { |
|||
setOpenKeys(JSON.parse(siderOpenKeys)) |
|||
} |
|||
let siderSelectKeys = sessionStorage.getItem('siderSelectKeys') |
|||
if (siderSelectKeys) { |
|||
setSelectedKeys(JSON.parse(siderSelectKeys)) |
|||
} |
|||
}, []) |
|||
|
|||
return ( |
|||
<Sider |
|||
collapsed={collapsed} |
|||
style={{ |
|||
background: colorBgContainer, |
|||
}} |
|||
> |
|||
<div style={{ |
|||
height: '100%', overflow: 'auto', scrollbarWidth: 'thin', |
|||
}}> |
|||
<Menu |
|||
mode="inline" |
|||
items={menuItems} |
|||
selectedKeys={selectedKeys} |
|||
openKeys={openKeys} |
|||
onClick={({ keyPath, key }) => { |
|||
let url = keyPath.reduce((pre, cur) => { |
|||
return cur + '/' + pre; |
|||
}, ''); |
|||
navigate(url); |
|||
sessionStorage.setItem('siderSelectKeys', JSON.stringify([key])); |
|||
setSelectedKeys([key]); |
|||
}} |
|||
onOpenChange={(openKeys) => { |
|||
sessionStorage.setItem('siderOpenKeys', JSON.stringify(openKeys)); |
|||
setOpenKeys(openKeys); |
|||
}} |
|||
/> |
|||
</div> |
|||
|
|||
</Sider> |
|||
); |
|||
} |
@ -0,0 +1,30 @@ |
|||
import React, { useEffect, useState } from "react"; |
|||
import { LeftOutlined } from "@ant-design/icons"; |
|||
import { Layout } from "antd"; |
|||
import Sider from "../components/sider"; |
|||
import Content from "../components/content"; |
|||
import Header from "../components/header.jsx"; |
|||
import './index.less' |
|||
|
|||
export default (props) => { |
|||
const [collapsed, setCollapsed] = useState(false); |
|||
|
|||
useEffect(() => { |
|||
if (!sessionStorage.getItem("user")) { |
|||
window.location.replace("/signin"); |
|||
} |
|||
}, []); |
|||
|
|||
return ( |
|||
<Layout style={{ height: "100vh", }} > |
|||
<Header /> |
|||
<Layout> |
|||
<Sider collapsed={collapsed} routerNav={props.routerNav} /> |
|||
<div style={{ alignContent: 'center', padding: '0 2px', }}> |
|||
<LeftOutlined className={`collaps-icon ${collapsed ? 'collapsed' : ''}`} onClick={() => setCollapsed(!collapsed)} /> |
|||
</div> |
|||
<Content routerNav={props.routerNav} /> |
|||
</Layout> |
|||
</Layout> |
|||
); |
|||
} |
@ -0,0 +1,20 @@ |
|||
.collaps-icon { |
|||
cursor: pointer; |
|||
opacity: 0.3; |
|||
transform: scale(1, 10); |
|||
transition: opacity 0.2s ease, transform 0.2s ease; |
|||
|
|||
&:hover { |
|||
opacity: 0.8; |
|||
transform: scale(1, 8) |
|||
} |
|||
} |
|||
|
|||
.collapsed { |
|||
transform: scale(-1, 10); |
|||
|
|||
&:hover { |
|||
opacity: 0.8; |
|||
transform: scale(-1, 8) |
|||
} |
|||
} |
@ -0,0 +1,92 @@ |
|||
import { useEffect, useState } from "react"; |
|||
import { BrowserRouter, Route, Routes, Outlet } from "react-router-dom"; |
|||
import Layout from "./container"; |
|||
import { basicReducer, apiRequest } from "@u"; |
|||
import { createStore, createSlice } from "@peace/react_client"; |
|||
import { Provider } from "react-redux"; |
|||
|
|||
export default function index (props) { |
|||
const [outerRoutes, setOuterRoutes] = useState([]); |
|||
const [innerRoutes, setInnerRoutes] = useState([]); |
|||
const [routerNav, setRouterNav] = useState([]); //存储内部路由,生成面包屑 |
|||
const [store, setStore] = useState(null); |
|||
|
|||
const flatRoute = route => { |
|||
return route.map(item => { |
|||
return ( |
|||
<Route |
|||
key={item.key} |
|||
path={item?.path} |
|||
element={item.component ? <item.component /> : <Outlet />} |
|||
> |
|||
{item.children && flatRoute(item.children)} |
|||
</Route> |
|||
); |
|||
}); |
|||
}; |
|||
|
|||
useEffect(() => { |
|||
const reduxEle = basicReducer(props.sections, apiRequest); |
|||
|
|||
const global = createSlice({ |
|||
name: "global", |
|||
initialState: { |
|||
actions: reduxEle.requestMap, // 所有 section 定义的接口请求方法 |
|||
contentWidth: -1, |
|||
contentHeight: -1, |
|||
}, |
|||
reducers: { |
|||
setContentSize: (state, { payload }) => { |
|||
state.contentWidth = payload.width |
|||
state.contentHeight = payload.height |
|||
}, |
|||
setActions: (state, { payload }) => { |
|||
state.actions = payload; |
|||
}, |
|||
}, |
|||
}); |
|||
reduxEle.reducerMap["global"] = global.reducer; |
|||
|
|||
const store = createStore(reduxEle.reducerMap) |
|||
|
|||
setStore(store); |
|||
store.dispatch( |
|||
global.actions.setActions( |
|||
Object.assign({}, reduxEle.requestMap, global.actions) |
|||
) |
|||
) |
|||
}, [props.sections]); |
|||
|
|||
useEffect(() => { |
|||
let outerRoutes = []; |
|||
let innerRoutes = []; |
|||
props.sections.forEach(item => { |
|||
if (item.route.type === "outer") { |
|||
outerRoutes.push(item.route.route); |
|||
} else { |
|||
innerRoutes.push(item.route.route); |
|||
} |
|||
}); |
|||
setOuterRoutes(flatRoute(outerRoutes)); |
|||
setInnerRoutes(flatRoute(innerRoutes)); |
|||
setRouterNav(innerRoutes); |
|||
}, [props.sections]); |
|||
|
|||
return store ? ( |
|||
<Provider store={store}> |
|||
<BrowserRouter> |
|||
<Routes> |
|||
{outerRoutes} |
|||
<Route |
|||
path="/" |
|||
element={ |
|||
<Layout routerNav={routerNav} /> |
|||
} |
|||
> |
|||
{innerRoutes} |
|||
</Route> |
|||
</Routes> |
|||
</BrowserRouter> |
|||
</Provider> |
|||
) : null; |
|||
} |
@ -0,0 +1,9 @@ |
|||
import { basicAction, apiTable } from "@u" |
|||
|
|||
const login = basicAction.post(apiTable.login, { |
|||
// tip: false,
|
|||
tipFail: "登录失败", |
|||
reducerName: "user" |
|||
}); |
|||
|
|||
export default { login } |
@ -0,0 +1,5 @@ |
|||
import actions from "./auth"; |
|||
|
|||
export default { |
|||
...actions |
|||
} |
@ -0,0 +1,90 @@ |
|||
import { LockOutlined, UserOutlined } from "@ant-design/icons"; |
|||
import { useEffect } from "react"; |
|||
import { useDispatch, useSelector } from "react-redux"; |
|||
import { useNavigate } from "react-router-dom"; |
|||
import { Button, Form, Input, Flex } from "antd"; |
|||
import "./login.less"; |
|||
|
|||
export default function login() { |
|||
const dispatch = useDispatch(); |
|||
const navigate = useNavigate(); |
|||
|
|||
const actions = useSelector((state) => state.global.actions); |
|||
const onFinish = (values) => { |
|||
dispatch( |
|||
actions.login({ |
|||
body: { ...values }, |
|||
params: {}, |
|||
}) |
|||
).then((res) => { |
|||
const { success, data } = res.payload; |
|||
if (success) { |
|||
sessionStorage.setItem("user", JSON.stringify(data)); |
|||
navigate("/"); |
|||
} |
|||
}); |
|||
}; |
|||
|
|||
useEffect(() => { |
|||
sessionStorage.clear(); |
|||
}, []); |
|||
|
|||
return ( |
|||
<div |
|||
className="login" |
|||
style={{ |
|||
height: "100vh", |
|||
width: "100vw", |
|||
display: "flex", |
|||
justifyContent: "flex-end", |
|||
}} |
|||
> |
|||
<div |
|||
style={{ |
|||
background: "rgba(255,255,255,0.2)", |
|||
display: "flex", |
|||
alignItems: "center", |
|||
justifyContent: "center", |
|||
}} |
|||
> |
|||
<Form |
|||
size="large" |
|||
onFinish={onFinish} |
|||
style={{ width: "24vw", minWidth: 320, padding: 24 }} |
|||
> |
|||
<Form.Item |
|||
name="username" |
|||
rules={[ |
|||
{ |
|||
required: true, |
|||
message: "请输入用户名", |
|||
}, |
|||
]} |
|||
> |
|||
<Input prefix={<UserOutlined />} placeholder="用户名" /> |
|||
</Form.Item> |
|||
<Form.Item |
|||
name="password" |
|||
rules={[ |
|||
{ |
|||
required: true, |
|||
message: "请输入密码", |
|||
}, |
|||
]} |
|||
> |
|||
<Input |
|||
prefix={<LockOutlined />} |
|||
type="password" |
|||
placeholder="密码" |
|||
/> |
|||
</Form.Item> |
|||
<Form.Item> |
|||
<Button block type="primary" htmlType="submit"> |
|||
登录 |
|||
</Button> |
|||
</Form.Item> |
|||
</Form> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
@ -0,0 +1,4 @@ |
|||
.login { |
|||
background: url("@assets/img/auth_bg.png") no-repeat fixed 0 0; |
|||
background-size: cover; |
|||
} |
@ -0,0 +1,6 @@ |
|||
import route from './router'; |
|||
import actions from './actions'; |
|||
export default { |
|||
route: route, |
|||
actions: actions, |
|||
}; |
@ -0,0 +1,9 @@ |
|||
import Login from './container/login' |
|||
export default { |
|||
type: 'outer', |
|||
route: { |
|||
key: 'signin', |
|||
path: "/signin", |
|||
component: Login |
|||
} |
|||
}; |
@ -0,0 +1,11 @@ |
|||
import { basicAction } from "@u" |
|||
import qiniu from './qiniu' |
|||
/** |
|||
* @param {url} 请求地址(必选项) |
|||
* @param {option} 携带参数({}) |
|||
* const func = basicAction.get(url, option); |
|||
*/ |
|||
|
|||
export default { |
|||
...qiniu, |
|||
} |
@ -0,0 +1,7 @@ |
|||
import { basicAction, apiTable } from "@u" |
|||
|
|||
const qiniuToken = basicAction.get(apiTable.qiniuToken, { |
|||
tip: false, |
|||
}); |
|||
|
|||
export default { qiniuToken } |
@ -0,0 +1,45 @@ |
|||
import { useDispatch, useSelector } from "react-redux"; |
|||
// import { Upload } from '@comps' |
|||
import { apiRequest } from '@u' |
|||
import { Form } from "antd"; |
|||
import { useEffect } from "react"; |
|||
|
|||
|
|||
export default function Index () { |
|||
const global = useSelector(state => state.global); //仓库方法 |
|||
const dispatch = useDispatch(); |
|||
const { FS_QINIU_DOMAIN, FS_QINIU_ASSETS_DIR, FS_SOCKET_URL } = window.env; |
|||
|
|||
useEffect(() => { |
|||
var ws = new WebSocket(FS_SOCKET_URL) |
|||
ws.onopen = function () { } |
|||
ws.onmessage = function (e) { } |
|||
}, []) |
|||
|
|||
return ( |
|||
<div> |
|||
<h1>Welcome @peace</h1> |
|||
<Form |
|||
initialValues={{ |
|||
up: [{ |
|||
uid: -1, |
|||
url: `/temp_v5/20240913155734/-511108760.png` |
|||
}] |
|||
}} |
|||
> |
|||
{/* <Form.Item name="up"> |
|||
<Upload |
|||
maxCount={2} |
|||
listType='picture-card' |
|||
// types={['png']} |
|||
onChange={(fileList) => { |
|||
console.log(fileList) |
|||
}} |
|||
> |
|||
<span>上传文件</span> |
|||
</Upload> |
|||
</Form.Item> */} |
|||
</Form> |
|||
</div> |
|||
); |
|||
} |
@ -0,0 +1,7 @@ |
|||
import route from './router' |
|||
import actions from './actions' |
|||
|
|||
export default { |
|||
route: route, |
|||
actions: actions, |
|||
} |
@ -0,0 +1,24 @@ |
|||
import { HomeOutlined, ApartmentOutlined, } from "@ant-design/icons"; |
|||
import Index from "./container"; |
|||
|
|||
export default { |
|||
type: "inner", |
|||
route: { |
|||
key: "home", |
|||
path: "home", |
|||
// component: Index, |
|||
label: "Home", // 导航栏显示的名称 |
|||
icon: <HomeOutlined />, |
|||
nav: true, // 是否显示在左侧 Sider 导航栏 |
|||
children: [ |
|||
{ |
|||
key: "child", |
|||
path: "child", |
|||
component: Index, |
|||
label: "Child", |
|||
icon: <ApartmentOutlined />, |
|||
nav: true, |
|||
}, |
|||
], |
|||
}, |
|||
}; |
@ -0,0 +1,9 @@ |
|||
// 导出WebSocket相关功能
|
|||
export { |
|||
WebSocketProvider, |
|||
useWebSocket, |
|||
useWebSocketSubscription, |
|||
useWebSocketMessage |
|||
} from './websocket.jsx'; |
|||
|
|||
export default {}; |
@ -0,0 +1,464 @@ |
|||
/** |
|||
* WebSocket Hook - TCP代理连接管理 |
|||
* |
|||
* 提供WebSocket连接的统一管理,用于与TCP代理服务器进行通信 |
|||
* |
|||
* 数据格式规范: |
|||
* ```json |
|||
* { |
|||
* "_from": "dev|setup|...", // 数据来源: dev=设备, setup=上位机 |
|||
* "cmd": "getBase|setConfig|...", // 具体命令 |
|||
* "values": {} // 命令参数或数据 |
|||
* } |
|||
* ``` |
|||
* |
|||
* 使用方法: |
|||
* |
|||
* 1. 在根组件中使用WebSocketProvider包装: |
|||
* ```jsx |
|||
* import { WebSocketProvider } from './actions/websocket.jsx'; |
|||
* |
|||
* const App = () => { |
|||
* return ( |
|||
* <WebSocketProvider> |
|||
* <YourComponents /> |
|||
* </WebSocketProvider> |
|||
* ); |
|||
* }; |
|||
* ``` |
|||
* |
|||
* 2. 基本使用: |
|||
* ```jsx |
|||
* import { useWebSocket } from './actions/websocket.jsx'; |
|||
* |
|||
* const MyComponent = () => { |
|||
* const { isConnected, sendMessage, lastMessage } = useWebSocket(); |
|||
* |
|||
* const handleSendCommand = () => { |
|||
* sendMessage(JSON.stringify({ |
|||
* _from: 'setup', |
|||
* cmd: 'setConfig', |
|||
* values: { x: 100, y: 200 } |
|||
* })); |
|||
* }; |
|||
* |
|||
* return ( |
|||
* <div> |
|||
* <button onClick={handleSendCommand} disabled={!isConnected}> |
|||
* 发送配置 |
|||
* </button> |
|||
* <div>最新消息: {JSON.stringify(lastMessage)}</div> |
|||
* </div> |
|||
* ); |
|||
* }; |
|||
* ``` |
|||
* |
|||
* 3. 订阅特定来源和命令的消息: |
|||
* ```jsx |
|||
* import { useWebSocketSubscription } from './actions/websocket.jsx'; |
|||
* |
|||
* const DeviceDataComponent = () => { |
|||
* 只监听设备发送的基础数据 |
|||
* const baseData = useWebSocketSubscription('dev', 'getBase'); |
|||
* |
|||
* 监听所有设备消息 |
|||
* const allDeviceData = useWebSocketSubscription('dev'); |
|||
* |
|||
* 监听特定命令(任何来源) |
|||
* const configData = useWebSocketSubscription(null, 'setConfig'); |
|||
* |
|||
* return ( |
|||
* <div> |
|||
* <h3>设备基础数据:</h3> |
|||
* <pre>{JSON.stringify(baseData, null, 2)}</pre> |
|||
* </div> |
|||
* ); |
|||
* }; |
|||
* ``` |
|||
* |
|||
* 4. 自定义消息处理: |
|||
* ```jsx |
|||
* import { useWebSocketMessage } from './actions/websocket.jsx'; |
|||
* |
|||
* const CustomHandler = () => { |
|||
* useWebSocketMessage(({ _from, cmd, values, timestamp }) => { |
|||
* if (_from === 'dev' && cmd === 'alarm') { |
|||
* 处理设备告警 |
|||
* handleDeviceAlarm(values); |
|||
* } |
|||
* }); |
|||
* |
|||
* return <div>监听中...</div>; |
|||
* }; |
|||
* ``` |
|||
* |
|||
* API说明: |
|||
* - isConnected: boolean - 连接状态 |
|||
* - connectionStatus: string - 连接状态('connecting'|'connected'|'disconnected'|'error') |
|||
* - sendMessage: (message: string) => boolean - 发送消息 |
|||
* - lastMessage: object - 最后接收的消息 |
|||
* - messageHistory: array - 消息历史 |
|||
* - subscribe: (from, cmd, callback) => unsubscribe - 订阅消息 |
|||
* - useWebSocketSubscription: (from, cmd) => data - 订阅Hook |
|||
* - useWebSocketMessage: (callback) => void - 消息监听Hook |
|||
* |
|||
* 特性: |
|||
* - 基于 _from 和 cmd 的发布订阅模式 |
|||
* - 自动连接和重连(最多5次) |
|||
* - 消息类型过滤和路由 |
|||
* - 多组件共享同一WebSocket连接 |
|||
* - 完整的连接生命周期管理 |
|||
*/ |
|||
|
|||
import React, { |
|||
createContext, |
|||
useContext, |
|||
useEffect, |
|||
useRef, |
|||
useState, |
|||
useCallback, |
|||
} from "react"; |
|||
|
|||
// 创建WebSocket Context |
|||
const WebSocketContext = createContext(); |
|||
|
|||
// WebSocket Provider组件 |
|||
export const WebSocketProvider = ({ children }) => { |
|||
const [isConnected, setIsConnected] = useState(false); |
|||
const [connectionStatus, setConnectionStatus] = useState("disconnected"); |
|||
const [lastMessage, setLastMessage] = useState(null); // 最后接收的消息 |
|||
const [messageHistory, setMessageHistory] = useState([]); // 消息历史 |
|||
const socketRef = useRef(null); |
|||
const reconnectTimeoutRef = useRef(null); |
|||
const reconnectAttemptsRef = useRef(0); |
|||
const subscriptionsRef = useRef(new Map()); // 订阅器存储 |
|||
const maxReconnectAttempts = 5; |
|||
const reconnectInterval = 3000; // 3秒 |
|||
const maxHistoryLength = 100; // 最大历史消息数量 |
|||
|
|||
// 动态获取WebSocket连接地址,支持局域网访问 |
|||
const getWebSocketUrl = () => { |
|||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; |
|||
const host = window.location.hostname; |
|||
const port = Number(window.location.port) + 1 || 8081; |
|||
return `${protocol}//${host}:${port}/tcp-proxy`; |
|||
}; |
|||
|
|||
const websocketUrl = getWebSocketUrl(); |
|||
|
|||
// 生成订阅key |
|||
const getSubscriptionKey = (from, cmd) => { |
|||
if (from && cmd) return `${from}:${cmd}`; |
|||
if (from) return `${from}:*`; |
|||
if (cmd) return `*:${cmd}`; |
|||
return "*:*"; |
|||
}; |
|||
|
|||
// 订阅消息 |
|||
const subscribe = useCallback((from, cmd, callback) => { |
|||
const key = getSubscriptionKey(from, cmd); |
|||
|
|||
if (!subscriptionsRef.current.has(key)) { |
|||
subscriptionsRef.current.set(key, new Set()); |
|||
} |
|||
|
|||
subscriptionsRef.current.get(key).add(callback); |
|||
|
|||
// 返回取消订阅函数 |
|||
return () => { |
|||
const callbacks = subscriptionsRef.current.get(key); |
|||
if (callbacks) { |
|||
callbacks.delete(callback); |
|||
if (callbacks.size === 0) { |
|||
subscriptionsRef.current.delete(key); |
|||
} |
|||
} |
|||
}; |
|||
}, []); |
|||
|
|||
// 通知订阅者 |
|||
const notifySubscribers = useCallback((messageData) => { |
|||
const { _from, cmd } = messageData; |
|||
|
|||
// 可能匹配的订阅key列表 |
|||
const keysToCheck = [ |
|||
getSubscriptionKey(_from, cmd), // 精确匹配 |
|||
getSubscriptionKey(_from, null), // 匹配来源 |
|||
getSubscriptionKey(null, cmd), // 匹配命令 |
|||
getSubscriptionKey(null, null), // 匹配所有 |
|||
]; |
|||
|
|||
keysToCheck.forEach((key) => { |
|||
const callbacks = subscriptionsRef.current.get(key); |
|||
if (callbacks) { |
|||
callbacks.forEach((callback) => { |
|||
try { |
|||
callback(messageData); |
|||
} catch (error) { |
|||
console.error("订阅回调执行错误:", error); |
|||
} |
|||
}); |
|||
} |
|||
}); |
|||
}, []); |
|||
|
|||
// 处理接收到的消息 |
|||
const handleMessage = useCallback( |
|||
(data) => { |
|||
const timestamp = Date.now(); |
|||
let parsedData = null; |
|||
|
|||
try { |
|||
parsedData = JSON.parse(data); |
|||
|
|||
// 验证数据格式 |
|||
if (!parsedData._from || !parsedData.cmd) { |
|||
console.warn("收到格式不正确的消息:", parsedData); |
|||
return; |
|||
} |
|||
|
|||
// console.log(`收到消息 [${parsedData._from}:${parsedData.cmd}]:`, parsedData); |
|||
} catch (error) { |
|||
console.error("解析WebSocket消息失败:", error, data); |
|||
return; |
|||
} |
|||
|
|||
const messageData = { |
|||
id: `msg_${timestamp}`, |
|||
timestamp, |
|||
rawData: data, |
|||
...parsedData, |
|||
}; |
|||
|
|||
// 更新最后消息和历史 |
|||
setLastMessage(messageData); |
|||
setMessageHistory((prev) => { |
|||
const newHistory = [messageData, ...prev]; |
|||
return newHistory.slice(0, maxHistoryLength); |
|||
}); |
|||
|
|||
// 通知订阅者 |
|||
notifySubscribers(messageData); |
|||
}, |
|||
[notifySubscribers] |
|||
); |
|||
|
|||
// 发送消息 |
|||
const sendMessage = useCallback((message) => { |
|||
if (socketRef.current?.readyState === WebSocket.OPEN) { |
|||
try { |
|||
socketRef.current.send(message); |
|||
// console.log('发送WebSocket消息:', message); |
|||
return true; |
|||
} catch (error) { |
|||
console.error("发送WebSocket消息失败:", error); |
|||
return false; |
|||
} |
|||
} else { |
|||
console.warn("WebSocket未连接,无法发送消息"); |
|||
return false; |
|||
} |
|||
}, []); |
|||
|
|||
// 连接WebSocket |
|||
const connect = useCallback(() => { |
|||
if (socketRef.current?.readyState === WebSocket.OPEN) { |
|||
return; // 已经连接 |
|||
} |
|||
|
|||
setConnectionStatus("connecting"); |
|||
console.log("尝试连接WebSocket:", websocketUrl); |
|||
|
|||
try { |
|||
socketRef.current = new WebSocket(websocketUrl); |
|||
|
|||
socketRef.current.onopen = () => { |
|||
console.log("WebSocket连接已建立"); |
|||
setIsConnected(true); |
|||
setConnectionStatus("connected"); |
|||
reconnectAttemptsRef.current = 0; |
|||
|
|||
// 发送初始消息或心跳 |
|||
// sendMessage(JSON.stringify({ |
|||
// _from: 'setup', |
|||
// cmd: 'init', |
|||
// values: { timestamp: Date.now() } |
|||
// })); |
|||
}; |
|||
|
|||
socketRef.current.onmessage = (event) => { |
|||
try { |
|||
const data = |
|||
typeof event.data === "string" ? event.data : event.data; |
|||
|
|||
// 这里可以处理接收到的消息,比如更新全局状态 |
|||
// 或者触发自定义事件 |
|||
handleMessage(data); |
|||
} catch (error) { |
|||
console.error("处理WebSocket消息错误:", error); |
|||
} |
|||
}; |
|||
|
|||
socketRef.current.onclose = (event) => { |
|||
console.log("WebSocket连接已关闭:", event.code, event.reason); |
|||
setIsConnected(false); |
|||
setConnectionStatus("disconnected"); |
|||
|
|||
// 自动重连逻辑 |
|||
if (reconnectAttemptsRef.current < maxReconnectAttempts) { |
|||
reconnectAttemptsRef.current++; |
|||
console.log( |
|||
`尝试重连 ${reconnectAttemptsRef.current}/${maxReconnectAttempts}` |
|||
); |
|||
reconnectTimeoutRef.current = setTimeout(() => { |
|||
connect(); |
|||
}, reconnectInterval); |
|||
} else { |
|||
console.log("达到最大重连次数,停止重连"); |
|||
setConnectionStatus("error"); |
|||
} |
|||
}; |
|||
|
|||
socketRef.current.onerror = (error) => { |
|||
console.error("WebSocket错误:", error); |
|||
setConnectionStatus("error"); |
|||
}; |
|||
} catch (error) { |
|||
console.error("创建WebSocket连接失败:", error); |
|||
setConnectionStatus("error"); |
|||
} |
|||
}, [handleMessage, sendMessage]); |
|||
|
|||
// 断开连接 |
|||
const disconnect = useCallback(() => { |
|||
if (reconnectTimeoutRef.current) { |
|||
clearTimeout(reconnectTimeoutRef.current); |
|||
} |
|||
|
|||
if (socketRef.current) { |
|||
socketRef.current.close(1000, "用户主动断开"); |
|||
socketRef.current = null; |
|||
} |
|||
|
|||
setIsConnected(false); |
|||
setConnectionStatus("disconnected"); |
|||
reconnectAttemptsRef.current = 0; |
|||
}, []); |
|||
|
|||
// 获取特定来源和命令的消息历史 |
|||
const getMessages = useCallback( |
|||
(from, cmd) => { |
|||
return messageHistory.filter((msg) => { |
|||
if (from && cmd) return msg._from === from && msg.cmd === cmd; |
|||
if (from) return msg._from === from; |
|||
if (cmd) return msg.cmd === cmd; |
|||
return true; |
|||
}); |
|||
}, |
|||
[messageHistory] |
|||
); |
|||
|
|||
// 清空消息历史 |
|||
const clearMessageHistory = useCallback(() => { |
|||
setMessageHistory([]); |
|||
setLastMessage(null); |
|||
}, []); |
|||
|
|||
// 组件挂载时自动连接 |
|||
useEffect(() => { |
|||
connect(); |
|||
|
|||
// 组件卸载时清理 |
|||
return () => { |
|||
if (reconnectTimeoutRef.current) { |
|||
clearTimeout(reconnectTimeoutRef.current); |
|||
} |
|||
if (socketRef.current) { |
|||
socketRef.current.close(); |
|||
} |
|||
}; |
|||
}, [connect]); |
|||
|
|||
// 提供给子组件的值 |
|||
const value = { |
|||
// 连接状态 |
|||
isConnected, |
|||
connectionStatus, |
|||
|
|||
// 连接控制 |
|||
connect, |
|||
disconnect, |
|||
sendMessage, |
|||
|
|||
// 数据访问 |
|||
lastMessage, |
|||
messageHistory, |
|||
getMessages, |
|||
clearMessageHistory, |
|||
|
|||
// 订阅功能 |
|||
subscribe, |
|||
|
|||
// 原始socket |
|||
socket: socketRef.current, |
|||
}; |
|||
|
|||
return ( |
|||
<WebSocketContext.Provider value={value}> |
|||
{children} |
|||
</WebSocketContext.Provider> |
|||
); |
|||
}; |
|||
|
|||
// 自定义Hook,方便子组件使用 |
|||
export const useWebSocket = () => { |
|||
const context = useContext(WebSocketContext); |
|||
if (!context) { |
|||
throw new Error("useWebSocket必须在WebSocketProvider内部使用"); |
|||
} |
|||
return context; |
|||
}; |
|||
|
|||
// 订阅特定来源和命令的消息Hook |
|||
export const useWebSocketSubscription = (from = null, cmd = null) => { |
|||
const { subscribe, getMessages } = useWebSocket(); |
|||
const [data, setData] = useState([]); |
|||
const [latestMessage, setLatestMessage] = useState(null); |
|||
|
|||
useEffect(() => { |
|||
// 获取历史消息 |
|||
const history = getMessages(from, cmd); |
|||
setData(history); |
|||
setLatestMessage(history[0] || null); |
|||
|
|||
// 订阅新消息 |
|||
const unsubscribe = subscribe(from, cmd, (messageData) => { |
|||
setLatestMessage(messageData); |
|||
setData((prev) => [messageData, ...prev.slice(0, 99)]); // 保持最多100条 |
|||
}); |
|||
|
|||
return unsubscribe; |
|||
}, [subscribe, getMessages, from, cmd]); |
|||
|
|||
return { |
|||
data, // 所有匹配的消息 |
|||
latest: latestMessage, // 最新的消息 |
|||
values: latestMessage?.values || null, // 最新消息的values字段 |
|||
}; |
|||
}; |
|||
|
|||
// 通用消息监听Hook |
|||
export const useWebSocketMessage = (callback) => { |
|||
const { subscribe } = useWebSocket(); |
|||
|
|||
useEffect(() => { |
|||
if (!callback) return; |
|||
|
|||
// 监听所有消息 |
|||
const unsubscribe = subscribe(null, null, callback); |
|||
return unsubscribe; |
|||
}, [subscribe, callback]); |
|||
}; |
|||
|
|||
// 默认导出 |
|||
export default WebSocketContext; |
File diff suppressed because it is too large
@ -0,0 +1,296 @@ |
|||
import React from 'react'; |
|||
import { Typography, Badge } from 'antd'; |
|||
import ReactECharts from 'echarts-for-react'; |
|||
|
|||
const { Title } = Typography; |
|||
|
|||
const RealtimeCharts = ({ tableData, lastUpdateTime }) => { |
|||
// 固定的设备颜色映射,确保颜色一致性 |
|||
const getDeviceColor = (deviceId) => { |
|||
const colorMap = { |
|||
'DEV000': "#1890ff", // 蓝色 |
|||
'DEV001': "#52c41a", // 绿色 |
|||
'DEV002': "#faad14", // 橙色 |
|||
'DEV003': "#f5222d", // 红色 |
|||
'DEV004': "#722ed1", // 紫色 |
|||
'DEV005': "#fa8c16", // 橙红色 |
|||
'DEV006': "#13c2c2", // 青色 |
|||
'DEV007': "#eb2f96", // 粉色 |
|||
'DEV008': "#2f54eb", // 深蓝色 |
|||
'DEV009': "#fa541c", // 火橙色 |
|||
}; |
|||
|
|||
// 如果设备ID在映射中,使用固定颜色 |
|||
if (colorMap[deviceId]) { |
|||
return colorMap[deviceId]; |
|||
} |
|||
|
|||
// 对于未预定义的设备,基于设备编号生成颜色 |
|||
const deviceNumber = deviceId.replace(/\D/g, ''); // 提取数字 |
|||
const colors = [ |
|||
"#1890ff", "#52c41a", "#faad14", "#f5222d", "#722ed1", |
|||
"#fa8c16", "#13c2c2", "#eb2f96", "#2f54eb", "#fa541c" |
|||
]; |
|||
return colors[parseInt(deviceNumber) % colors.length]; |
|||
}; |
|||
|
|||
// 准备图表数据 |
|||
const prepareChartData = () => { |
|||
// 获取所有唯一的设备ID并排序,确保顺序一致 |
|||
const deviceIds = [...new Set(tableData.map((item) => item.deviceId))].sort(); |
|||
|
|||
// 获取所有时间点并排序 |
|||
const allTimes = [ |
|||
...new Set(tableData.map((item) => item.updateTime)), |
|||
].sort((a, b) => new Date(a) - new Date(b)); |
|||
const timeLabels = allTimes.map((time) => { |
|||
const date = new Date(time); |
|||
return date.toLocaleTimeString("zh-CN", { |
|||
hour: "2-digit", |
|||
minute: "2-digit", |
|||
second: "2-digit", |
|||
}); |
|||
}); |
|||
|
|||
// 为每个设备准备数据 |
|||
const deviceData = deviceIds.map((deviceId) => { |
|||
const deviceItems = tableData.filter( |
|||
(item) => item.deviceId === deviceId |
|||
); |
|||
|
|||
// 为每个时间点找到对应的X和Y值,如果没有则为null |
|||
const xData = allTimes.map((time) => { |
|||
const item = deviceItems.find((d) => d.updateTime === time); |
|||
return item ? parseFloat(item.xValue) : null; |
|||
}); |
|||
|
|||
const yData = allTimes.map((time) => { |
|||
const item = deviceItems.find((d) => d.updateTime === time); |
|||
return item ? parseFloat(item.yValue) : null; |
|||
}); |
|||
|
|||
// 使用固定的颜色映射 |
|||
const color = getDeviceColor(deviceId); |
|||
|
|||
return { |
|||
deviceId, |
|||
xData, |
|||
yData, |
|||
color, |
|||
}; |
|||
}); |
|||
|
|||
return { timeLabels, deviceData }; |
|||
}; |
|||
|
|||
const { timeLabels, deviceData } = prepareChartData(); |
|||
|
|||
// X值折线图配置 |
|||
const getXChartOption = () => ({ |
|||
title: { |
|||
text: "X轴位移数据", |
|||
left: "center", |
|||
textStyle: { |
|||
fontSize: 16, |
|||
fontWeight: "normal", |
|||
}, |
|||
}, |
|||
tooltip: { |
|||
trigger: "axis", |
|||
formatter: function (params) { |
|||
let result = `时间: ${params[0].axisValue}<br/>`; |
|||
params.forEach((param) => { |
|||
if (param.value !== null) { |
|||
result += `${param.seriesName}: ${param.value} mm<br/>`; |
|||
} |
|||
}); |
|||
return result; |
|||
}, |
|||
}, |
|||
legend: { |
|||
orient: "horizontal", |
|||
bottom: "5%", |
|||
textStyle: { |
|||
fontSize: 12, |
|||
}, |
|||
}, |
|||
grid: { |
|||
left: "3%", |
|||
right: "4%", |
|||
bottom: "15%", |
|||
top: "15%", |
|||
containLabel: true, |
|||
}, |
|||
xAxis: { |
|||
type: "category", |
|||
data: timeLabels, |
|||
axisLabel: { |
|||
rotate: 45, |
|||
fontSize: 11, |
|||
}, |
|||
}, |
|||
yAxis: { |
|||
type: "value", |
|||
name: "X值(mm)", |
|||
nameTextStyle: { |
|||
fontSize: 13, |
|||
}, |
|||
axisLabel: { |
|||
fontSize: 11, |
|||
}, |
|||
}, |
|||
series: deviceData.map((device) => ({ |
|||
name: device.deviceId, |
|||
type: "line", |
|||
data: device.xData, |
|||
smooth: false, |
|||
connectNulls: false, |
|||
lineStyle: { |
|||
color: device.color, |
|||
width: 2, |
|||
}, |
|||
itemStyle: { |
|||
color: device.color, |
|||
}, |
|||
symbol: "circle", |
|||
symbolSize: 4, |
|||
})), |
|||
}); |
|||
|
|||
// Y值折线图配置 |
|||
const getYChartOption = () => ({ |
|||
title: { |
|||
text: "Y轴位移数据", |
|||
left: "center", |
|||
textStyle: { |
|||
fontSize: 16, |
|||
fontWeight: "normal", |
|||
}, |
|||
}, |
|||
tooltip: { |
|||
trigger: "axis", |
|||
formatter: function (params) { |
|||
let result = `时间: ${params[0].axisValue}<br/>`; |
|||
params.forEach((param) => { |
|||
if (param.value !== null) { |
|||
result += `${param.seriesName}: ${param.value} mm<br/>`; |
|||
} |
|||
}); |
|||
return result; |
|||
}, |
|||
}, |
|||
legend: { |
|||
orient: "horizontal", |
|||
bottom: "5%", |
|||
textStyle: { |
|||
fontSize: 12, |
|||
}, |
|||
}, |
|||
grid: { |
|||
left: "3%", |
|||
right: "4%", |
|||
bottom: "15%", |
|||
top: "15%", |
|||
containLabel: true, |
|||
}, |
|||
xAxis: { |
|||
type: "category", |
|||
data: timeLabels, |
|||
axisLabel: { |
|||
rotate: 45, |
|||
fontSize: 11, |
|||
}, |
|||
}, |
|||
yAxis: { |
|||
type: "value", |
|||
name: "Y值(mm)", |
|||
nameTextStyle: { |
|||
fontSize: 13, |
|||
}, |
|||
axisLabel: { |
|||
fontSize: 11, |
|||
}, |
|||
}, |
|||
series: deviceData.map((device) => ({ |
|||
name: device.deviceId, |
|||
type: "line", |
|||
data: device.yData, |
|||
smooth: false, |
|||
connectNulls: false, |
|||
lineStyle: { |
|||
color: device.color, |
|||
width: 2, |
|||
}, |
|||
itemStyle: { |
|||
color: device.color, |
|||
}, |
|||
symbol: "circle", |
|||
symbolSize: 4, |
|||
})), |
|||
}); |
|||
|
|||
return ( |
|||
<div |
|||
style={{ |
|||
flex: 2, |
|||
padding: "16px", |
|||
display: "flex", |
|||
flexDirection: "column", |
|||
}} |
|||
> |
|||
<Title level={4} style={{ marginBottom: "16px" }}> |
|||
实时数据图 |
|||
<Badge |
|||
status="processing" |
|||
text={`实时更新 - 最后更新: ${lastUpdateTime.toLocaleTimeString()}`} |
|||
style={{ marginLeft: "16px", fontSize: "12px" }} |
|||
/> |
|||
</Title> |
|||
<div |
|||
style={{ |
|||
flex: 1, |
|||
display: "flex", |
|||
flexDirection: "column", |
|||
gap: "20px", |
|||
minHeight: "500px", |
|||
}} |
|||
> |
|||
{/* X轴折线图 */} |
|||
<div |
|||
style={{ |
|||
flex: 1, |
|||
backgroundColor: "white", |
|||
borderRadius: "8px", |
|||
border: "1px solid #e8e8e8", |
|||
minHeight: "250px", |
|||
}} |
|||
> |
|||
<ReactECharts |
|||
option={getXChartOption()} |
|||
style={{ height: "100%", width: "100%" }} |
|||
opts={{ renderer: "canvas" }} |
|||
/> |
|||
</div> |
|||
|
|||
{/* Y轴折线图 */} |
|||
<div |
|||
style={{ |
|||
flex: 1, |
|||
backgroundColor: "white", |
|||
borderRadius: "8px", |
|||
border: "1px solid #e8e8e8", |
|||
minHeight: "250px", |
|||
}} |
|||
> |
|||
<ReactECharts |
|||
option={getYChartOption()} |
|||
style={{ height: "100%", width: "100%" }} |
|||
opts={{ renderer: "canvas" }} |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
export default RealtimeCharts; |
@ -0,0 +1,61 @@ |
|||
import React from "react"; |
|||
import { Table, Typography, Badge } from "antd"; |
|||
|
|||
const { Title } = Typography; |
|||
|
|||
const RealtimeDataTable = ({ realtimeData }) => { |
|||
const tableColumns = [ |
|||
{ title: "设备编号", dataIndex: "deviceId", key: "deviceId" }, |
|||
{ |
|||
title: "X值(mm)", |
|||
dataIndex: "xValue", |
|||
key: "xValue", |
|||
render: (text) => Number(text), |
|||
}, |
|||
{ |
|||
title: "Y值(mm)", |
|||
dataIndex: "yValue", |
|||
key: "yValue", |
|||
render: (text) => Number(text), |
|||
}, |
|||
{ title: "更新时间", dataIndex: "updateTime", key: "updateTime", width: 180 }, |
|||
]; |
|||
|
|||
return ( |
|||
<div |
|||
style={{ |
|||
flex: 1, |
|||
padding: "16px", |
|||
}} |
|||
> |
|||
<Title level={4} style={{ marginBottom: "16px" }}> |
|||
最新数据 |
|||
<Badge |
|||
status="processing" |
|||
text={`${realtimeData.length} 个标靶数据`} |
|||
style={{ marginLeft: "16px", fontSize: "12px" }} |
|||
/> |
|||
</Title> |
|||
<div |
|||
style={{ |
|||
height: "calc(600px - 60px - 32px)", |
|||
overflow: "hidden", |
|||
}} |
|||
> |
|||
<Table |
|||
bordered |
|||
dataSource={realtimeData} |
|||
columns={tableColumns} |
|||
pagination={false} |
|||
size="small" |
|||
scroll={{ |
|||
y: 508, |
|||
}} |
|||
virtual |
|||
/> |
|||
</div> |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
export default RealtimeDataTable; |
@ -0,0 +1,228 @@ |
|||
import React, { useState, useEffect } from "react"; |
|||
import { |
|||
Modal, |
|||
Form, |
|||
Input, |
|||
Checkbox, |
|||
Slider, |
|||
Select, |
|||
Button, |
|||
Popconfirm, |
|||
} from "antd"; |
|||
import { RightOutlined, DownOutlined } from "@ant-design/icons"; |
|||
|
|||
const { Option } = Select; |
|||
|
|||
const TargetDetailModal = ({ |
|||
visible, |
|||
mode, |
|||
targetData, |
|||
onOk, |
|||
onCancel, |
|||
onDelete, |
|||
}) => { |
|||
const [form] = Form.useForm(); |
|||
const [advancedConfig, setAdvancedConfig] = useState(false); |
|||
|
|||
// 高斯模糊阈值选项 |
|||
const gaussianBlurOptions = [1, 3, 5, 7, 9, 11]; |
|||
|
|||
useEffect(() => { |
|||
if (visible && targetData) { |
|||
// 编辑模式时填充表单数据 |
|||
form.setFieldsValue({ |
|||
name: targetData.name || "", |
|||
radius: targetData.radius || "", |
|||
isReferencePoint: targetData.isReferencePoint || false, |
|||
gradientThreshold: targetData.gradientThreshold || 100, |
|||
anchorThreshold: targetData.anchorThreshold || 80, |
|||
gaussianBlurThreshold: targetData.gaussianBlurThreshold || 3, |
|||
}); |
|||
setAdvancedConfig(targetData.hasAdvancedConfig || false); |
|||
} else if (visible && mode === "add") { |
|||
// 新增模式时设置默认值 |
|||
form.setFieldsValue({ |
|||
name: "", |
|||
radius: "", |
|||
isReferencePoint: false, |
|||
gradientThreshold: 100, |
|||
anchorThreshold: 80, |
|||
gaussianBlurThreshold: 3, |
|||
}); |
|||
setAdvancedConfig(false); |
|||
} |
|||
}, [visible, targetData, mode, form]); |
|||
|
|||
const handleOk = () => { |
|||
form |
|||
.validateFields() |
|||
.then((values) => { |
|||
const targetInfo = { |
|||
...values, |
|||
hasAdvancedConfig: advancedConfig, |
|||
id: targetData?.id || Date.now().toString(), |
|||
}; |
|||
onOk(targetInfo); |
|||
form.resetFields(); |
|||
setAdvancedConfig(false); |
|||
}) |
|||
.catch((info) => { |
|||
console.log("表单验证失败:", info); |
|||
}); |
|||
}; |
|||
|
|||
const handleCancel = () => { |
|||
form.resetFields(); |
|||
setAdvancedConfig(false); |
|||
onCancel(); |
|||
}; |
|||
|
|||
const handleDelete = () => { |
|||
if (targetData) { |
|||
// 传递 key 或 id 给删除函数 |
|||
const targetKey = targetData.key || targetData.id; |
|||
onDelete(targetKey); |
|||
} |
|||
}; |
|||
|
|||
const handleAdvancedConfigChange = () => { |
|||
setAdvancedConfig(!advancedConfig); |
|||
}; |
|||
|
|||
return ( |
|||
<Modal |
|||
title="详情" |
|||
open={visible} |
|||
onOk={handleOk} |
|||
onCancel={handleCancel} |
|||
width={400} |
|||
destroyOnHidden={true} |
|||
footer={[ |
|||
mode === "edit" && ( |
|||
<Popconfirm |
|||
key="delete" |
|||
title="确认删除选中的标靶?" |
|||
description="此操作将移除画面中的标靶及其历史数据,且不可恢复。" |
|||
onConfirm={handleDelete} |
|||
okText="确认删除" |
|||
cancelText="取消" |
|||
placement="topRight" |
|||
> |
|||
<Button danger style={{ float: "left" }}> |
|||
删除标靶 |
|||
</Button> |
|||
</Popconfirm> |
|||
), |
|||
<Button key="cancel" onClick={handleCancel}> |
|||
取消 |
|||
</Button>, |
|||
<Button key="ok" type="primary" onClick={handleOk}> |
|||
确认 |
|||
</Button>, |
|||
]} |
|||
> |
|||
<Form |
|||
form={form} |
|||
layout="vertical" |
|||
initialValues={{ |
|||
isReferencePoint: false, |
|||
gradientThreshold: 100, |
|||
anchorThreshold: 80, |
|||
gaussianBlurThreshold: 3, |
|||
}} |
|||
> |
|||
<Form.Item |
|||
label="标靶描述" |
|||
name="name" |
|||
rules={[ |
|||
{ required: true, message: "请输入标靶描述" }, |
|||
{ max: 50, message: "标靶描述不能超过50个字符" }, |
|||
]} |
|||
> |
|||
<Input placeholder="请输入标靶描述" /> |
|||
</Form.Item> |
|||
|
|||
<Form.Item |
|||
label="标靶圆物理半径(mm)" |
|||
name="radius" |
|||
rules={[ |
|||
{ required: true, message: "请输入标靶圆物理半径" }, |
|||
{ |
|||
pattern: /^\d+(\.\d+)?$/, |
|||
message: "请输入有效的数字", |
|||
}, |
|||
]} |
|||
> |
|||
<Input placeholder="请输入标靶圆物理半径" /> |
|||
</Form.Item> |
|||
|
|||
<Form.Item label="高级配置"> |
|||
<Button |
|||
type="text" |
|||
onClick={handleAdvancedConfigChange} |
|||
icon={advancedConfig ? <DownOutlined /> : <RightOutlined />} |
|||
style={{ |
|||
padding: "0", |
|||
height: "auto", |
|||
display: "flex", |
|||
alignItems: "center", |
|||
fontSize: "14px", |
|||
}} |
|||
> |
|||
展开高级配置选项 |
|||
</Button> |
|||
</Form.Item> |
|||
|
|||
{advancedConfig && ( |
|||
<> |
|||
<Form.Item name="isReferencePoint" valuePropName="checked"> |
|||
<Checkbox>设为基准点</Checkbox> |
|||
</Form.Item> |
|||
|
|||
<Form.Item label="梯度阈值" name="gradientThreshold"> |
|||
<Slider |
|||
min={0} |
|||
max={200} |
|||
marks={{ |
|||
0: "0", |
|||
200: "200", |
|||
}} |
|||
tooltip={{ |
|||
formatter: (value) => `${value}`, |
|||
}} |
|||
included={true} |
|||
/> |
|||
</Form.Item> |
|||
|
|||
<Form.Item label="锚点阈值" name="anchorThreshold"> |
|||
<Slider |
|||
min={0} |
|||
max={200} |
|||
marks={{ |
|||
0: "0", |
|||
200: "200", |
|||
}} |
|||
tooltip={{ |
|||
formatter: (value) => `${value}`, |
|||
}} |
|||
included={true} |
|||
/> |
|||
</Form.Item> |
|||
|
|||
<Form.Item label="高斯模糊阈值" name="gaussianBlurThreshold"> |
|||
<Select placeholder="请选择高斯模糊阈值"> |
|||
{gaussianBlurOptions.map((value) => ( |
|||
<Option key={value} value={value}> |
|||
{value} |
|||
</Option> |
|||
))} |
|||
</Select> |
|||
</Form.Item> |
|||
</> |
|||
)} |
|||
</Form> |
|||
</Modal> |
|||
); |
|||
}; |
|||
|
|||
export default TargetDetailModal; |
@ -0,0 +1,84 @@ |
|||
import React from "react"; |
|||
import { Table, Button } from "antd"; |
|||
import { EditOutlined } from "@ant-design/icons"; |
|||
|
|||
const TargetList = ({ |
|||
targetListData, |
|||
selectedTargetId, |
|||
onEditTarget, |
|||
onSelectTarget, |
|||
onClickClearAll, |
|||
}) => { |
|||
const targetColumns = [ |
|||
{ |
|||
title: "标靶操作", |
|||
dataIndex: "name", |
|||
key: "name", |
|||
render: (text, record) => ( |
|||
<div |
|||
style={{ |
|||
display: "flex", |
|||
alignItems: "center", |
|||
justifyContent: "space-between", |
|||
padding: "8px 0", |
|||
backgroundColor: |
|||
selectedTargetId === record.id ? "#e6f7ff" : "transparent", |
|||
borderRadius: "4px", |
|||
cursor: "pointer", |
|||
transition: "background-color 0.2s", |
|||
}} |
|||
onClick={() => onSelectTarget(record)} |
|||
> |
|||
<span |
|||
style={{ |
|||
fontSize: "14px", |
|||
color: selectedTargetId === record.id ? "#1890ff" : "#333", |
|||
fontWeight: |
|||
selectedTargetId === record.id ? "500" : "normal", |
|||
}} |
|||
> |
|||
{text} |
|||
</span> |
|||
<Button |
|||
type="text" |
|||
size="small" |
|||
icon={<EditOutlined />} |
|||
onClick={(e) => { |
|||
e.stopPropagation(); // 阻止事件冒泡 |
|||
onEditTarget(record); |
|||
}} |
|||
style={{ |
|||
color: "#1890ff", |
|||
fontSize: "12px", |
|||
padding: "4px 8px", |
|||
}} |
|||
> |
|||
编辑 |
|||
</Button> |
|||
</div> |
|||
), |
|||
}, |
|||
]; |
|||
|
|||
return ( |
|||
<div style={{ flex: 1, overflow: "auto", padding: "0 16px" }}> |
|||
<Table |
|||
dataSource={targetListData} |
|||
columns={targetColumns} |
|||
pagination={false} |
|||
size="small" |
|||
style={{ height: "100%" }} |
|||
showHeader={false} |
|||
/> |
|||
<Button |
|||
type="primary" |
|||
style={{ marginTop: "16px", width: "100%" }} |
|||
onClick={onClickClearAll} |
|||
> |
|||
一键清零 |
|||
</Button> |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
export default TargetList; |
@ -0,0 +1,99 @@ |
|||
import React from "react"; |
|||
import { Table, Button, Radio } from "antd"; |
|||
import { EditOutlined } from "@ant-design/icons"; |
|||
|
|||
const TemplateList = ({ |
|||
tempListData, |
|||
selectedTemplate, |
|||
onTemplateSelect, |
|||
onAddTemplate, |
|||
onEditTemplate, |
|||
}) => { |
|||
const tempColumns = [ |
|||
{ |
|||
title: "模板选择", |
|||
dataIndex: "name", |
|||
key: "name", |
|||
render: (text, record) => ( |
|||
<div |
|||
style={{ |
|||
display: "flex", |
|||
alignItems: "center", |
|||
justifyContent: "space-between", |
|||
padding: "8px 0", |
|||
}} |
|||
> |
|||
<div style={{ display: "flex", alignItems: "center" }}> |
|||
<Radio |
|||
checked={selectedTemplate === record.key} |
|||
onChange={() => onTemplateSelect(record.key)} |
|||
style={{ marginRight: "12px" }} |
|||
/> |
|||
<span |
|||
style={{ |
|||
fontSize: "14px", |
|||
color: "#333", |
|||
marginRight: "12px", |
|||
}} |
|||
> |
|||
{text} |
|||
</span> |
|||
{record.imageUrl && ( |
|||
<img |
|||
src={record.imageUrl} |
|||
alt={`${text} 模板图片`} |
|||
style={{ |
|||
width: "40px", |
|||
height: "40px", |
|||
objectFit: "cover", |
|||
border: "1px solid #d9d9d9", |
|||
borderRadius: "4px", |
|||
}} |
|||
/> |
|||
)} |
|||
</div> |
|||
<Button |
|||
type="text" |
|||
size="small" |
|||
icon={<EditOutlined />} |
|||
onClick={(e) => { |
|||
e.stopPropagation(); |
|||
onEditTemplate(record); |
|||
}} |
|||
style={{ |
|||
color: "#1890ff", |
|||
fontSize: "12px", |
|||
padding: "4px 8px", |
|||
}} |
|||
> |
|||
编辑 |
|||
</Button> |
|||
</div> |
|||
), |
|||
}, |
|||
]; |
|||
|
|||
return ( |
|||
<div style={{ flex: 1, overflow: "auto", padding: "0 16px" }}> |
|||
<Table |
|||
dataSource={tempListData} |
|||
columns={tempColumns} |
|||
pagination={false} |
|||
size="small" |
|||
style={{ height: "100%" }} |
|||
showHeader={false} |
|||
/> |
|||
{tempListData.length < 3 && ( |
|||
<Button |
|||
type="primary" |
|||
style={{ marginTop: "16px", width: "100%" }} |
|||
onClick={onAddTemplate} |
|||
> |
|||
添加新模板 |
|||
</Button> |
|||
)} |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
export default TemplateList; |
@ -0,0 +1,427 @@ |
|||
import React, { useState, useEffect } from "react"; |
|||
import { |
|||
Modal, |
|||
Form, |
|||
Input, |
|||
InputNumber, |
|||
Checkbox, |
|||
Slider, |
|||
Select, |
|||
Button, |
|||
Space, |
|||
message, |
|||
Popconfirm, |
|||
Upload, |
|||
} from "antd"; |
|||
import { DeleteOutlined, UploadOutlined } from "@ant-design/icons"; |
|||
|
|||
const { Option } = Select; |
|||
|
|||
const TemplateModal = ({ |
|||
visible, |
|||
mode, // 'add' | 'edit' |
|||
templateData, |
|||
onOk, |
|||
onCancel, |
|||
onDelete, |
|||
tempListData, |
|||
}) => { |
|||
const [form] = Form.useForm(); |
|||
const [isBaseline, setIsBaseline] = useState(false); |
|||
const [isPerspectiveCorrection, setIsPerspectiveCorrection] = |
|||
useState(false); |
|||
const [binaryThreshold, setBinaryThreshold] = useState(100); |
|||
const [gaussianBlur, setGaussianBlur] = useState(1); |
|||
const [physicalRadius, setPhysicalRadius] = useState(40.0); |
|||
const [gradientThresholdValue, setGradientThresholdValue] = useState(100); |
|||
const [anchorThresholdValue, setAnchorThresholdValue] = useState(80); |
|||
const [fileList, setFileList] = useState([]); |
|||
const [imageUrl, setImageUrl] = useState(""); |
|||
const [previewVisible, setPreviewVisible] = useState(false); |
|||
const [previewImage, setPreviewImage] = useState(""); |
|||
|
|||
// 当模态框打开时,设置表单初始值 |
|||
useEffect(() => { |
|||
if (visible) { |
|||
if (mode === "edit" && templateData) { |
|||
// 编辑模式:填充现有数据 |
|||
form.setFieldsValue({ |
|||
name: templateData.name, |
|||
gaussianBlur: templateData.gaussianBlur || 1, |
|||
physicalRadius: templateData.physicalRadius || 40.0, |
|||
gradientThresholdValue: |
|||
templateData.gradientThresholdValue || 100, |
|||
anchorThresholdValue: templateData.anchorThresholdValue || 80, |
|||
}); |
|||
setIsBaseline(templateData.isBaseline || false); |
|||
setIsPerspectiveCorrection( |
|||
templateData.isPerspectiveCorrection || false |
|||
); |
|||
setBinaryThreshold(templateData.binaryThreshold || 100); |
|||
setGaussianBlur(templateData.gaussianBlur || 1); |
|||
setPhysicalRadius(templateData.physicalRadius || 40.0); |
|||
setGradientThresholdValue( |
|||
templateData.gradientThresholdValue || 100 |
|||
); |
|||
setAnchorThresholdValue(templateData.anchorThresholdValue || 80); |
|||
|
|||
// 设置图片数据 |
|||
if (templateData.imageUrl) { |
|||
setImageUrl(templateData.imageUrl); |
|||
setFileList([ |
|||
{ |
|||
uid: "-1", |
|||
name: templateData.name + ".jpg", |
|||
status: "done", |
|||
url: templateData.imageUrl, |
|||
}, |
|||
]); |
|||
} else { |
|||
setImageUrl(""); |
|||
setFileList([]); |
|||
} |
|||
} else { |
|||
// 新增模式:设置默认值 |
|||
form.resetFields(); |
|||
setIsBaseline(false); |
|||
setIsPerspectiveCorrection(false); |
|||
setBinaryThreshold(100); |
|||
setGaussianBlur(1); |
|||
setPhysicalRadius(40.0); |
|||
setGradientThresholdValue(100); |
|||
setAnchorThresholdValue(80); |
|||
setImageUrl(""); |
|||
setFileList([]); |
|||
} |
|||
} |
|||
}, [visible, mode, templateData, form]); |
|||
|
|||
// 图片上传处理 |
|||
const handleUploadChange = ({ fileList: newFileList }) => { |
|||
setFileList(newFileList); |
|||
|
|||
// 如果文件列表为空(删除图片),清空imageUrl |
|||
if (newFileList.length === 0) { |
|||
setImageUrl(""); |
|||
} |
|||
}; |
|||
|
|||
const beforeUpload = (file) => { |
|||
const isJpgOrPng = |
|||
file.type === "image/jpeg" || file.type === "image/png"; |
|||
if (!isJpgOrPng) { |
|||
message.error("只能上传 JPG/PNG 格式的图片!"); |
|||
return false; |
|||
} |
|||
const isLt2M = file.size / 1024 / 1024 < 1; |
|||
if (!isLt2M) { |
|||
message.error("图片大小不能超过 1MB!"); |
|||
return false; |
|||
} |
|||
|
|||
// 读取文件并转换为base64 |
|||
const reader = new FileReader(); |
|||
reader.onload = (e) => { |
|||
const base64Data = e.target.result; |
|||
setImageUrl(base64Data); |
|||
|
|||
// 更新文件列表,添加预览URL |
|||
setFileList([ |
|||
{ |
|||
uid: file.uid, |
|||
name: file.name, |
|||
status: "done", |
|||
url: base64Data, // 使用base64数据作为预览URL |
|||
originFileObj: file, |
|||
}, |
|||
]); |
|||
}; |
|||
reader.readAsDataURL(file); |
|||
|
|||
return false; // 阻止默认上传行为 |
|||
}; |
|||
|
|||
// 处理图片预览 |
|||
const handlePreview = async (file) => { |
|||
if (!file.url && !file.preview) { |
|||
file.preview = await getBase64(file.originFileObj); |
|||
} |
|||
setPreviewImage(file.url || file.preview); |
|||
setPreviewVisible(true); |
|||
}; |
|||
|
|||
// 将文件转换为base64用于预览 |
|||
const getBase64 = (file) => |
|||
new Promise((resolve, reject) => { |
|||
const reader = new FileReader(); |
|||
reader.readAsDataURL(file); |
|||
reader.onload = () => resolve(reader.result); |
|||
reader.onerror = (error) => reject(error); |
|||
}); |
|||
|
|||
const handleOk = async () => { |
|||
try { |
|||
const values = await form.validateFields(); |
|||
|
|||
// 检查是否上传了图片 |
|||
if (!imageUrl) { |
|||
message.error("请上传标靶图片"); |
|||
return; |
|||
} |
|||
|
|||
// 检查模板数量限制(仅新增时) |
|||
if (mode === "add" && tempListData.length >= 3) { |
|||
message.error( |
|||
"已达到标靶模板数量上限(3个)!请删除未使用的模板后再新增。" |
|||
); |
|||
return; |
|||
} |
|||
|
|||
const templateInfo = { |
|||
...values, |
|||
isBaseline, |
|||
isPerspectiveCorrection, |
|||
binaryThreshold, |
|||
gaussianBlur, |
|||
physicalRadius, |
|||
gradientThresholdValue, |
|||
anchorThresholdValue, |
|||
imageUrl, |
|||
key: mode === "edit" ? templateData.key : Date.now().toString(), |
|||
id: mode === "edit" ? templateData.id : Date.now(), |
|||
}; |
|||
|
|||
onOk(templateInfo); |
|||
message.success(mode === "add" ? "新增模板成功!" : "编辑模板成功!"); |
|||
} catch (error) { |
|||
console.error("表单验证失败:", error); |
|||
} |
|||
}; |
|||
|
|||
const handleDelete = () => { |
|||
if (templateData) { |
|||
// 检查是否为第一个模板(内置模板) |
|||
if (templateData.key === 'builtin_1') { |
|||
message.warning("该模板为内置模板,不允许删除!"); |
|||
return; |
|||
} |
|||
onDelete(templateData); |
|||
message.success("删除模板成功!"); |
|||
} |
|||
}; |
|||
|
|||
const title = mode === "add" ? "新增标靶模板" : "编辑标靶模板"; |
|||
|
|||
return ( |
|||
<> |
|||
<Modal |
|||
title={title} |
|||
open={visible} |
|||
onCancel={onCancel} |
|||
width={500} |
|||
footer={[ |
|||
mode === "edit" && templateData?.key !== 'builtin_1' && ( |
|||
<Popconfirm |
|||
key="delete" |
|||
title={`确认删除标靶模板[${templateData?.name}]?`} |
|||
onConfirm={handleDelete} |
|||
okText="确认删除" |
|||
cancelText="取消" |
|||
okButtonProps={{ danger: true }} |
|||
> |
|||
<Button danger icon={<DeleteOutlined />}> |
|||
删除 |
|||
</Button> |
|||
</Popconfirm> |
|||
), |
|||
<Button key="ok" type="primary" onClick={handleOk}> |
|||
{mode === "add" ? "新增" : "确认"} |
|||
</Button>, |
|||
].filter(Boolean)} |
|||
> |
|||
<Form |
|||
form={form} |
|||
layout="vertical" |
|||
initialValues={{ |
|||
name: "", |
|||
gaussianBlur: 1, |
|||
physicalRadius: 40.0, |
|||
gradientThresholdValue: 100, |
|||
anchorThresholdValue: 80, |
|||
}} |
|||
> |
|||
<Form.Item |
|||
label="模板名称" |
|||
name="name" |
|||
rules={[ |
|||
{ required: true, message: "请输入模板名称" }, |
|||
{ max: 20, message: "模板名称不能超过20个字符" }, |
|||
]} |
|||
> |
|||
<Input placeholder="请输入模板名称" /> |
|||
</Form.Item> |
|||
|
|||
<Form.Item |
|||
label="标靶图片" |
|||
rules={[{ required: true, message: "请上传标靶图片" }]} |
|||
> |
|||
<Upload |
|||
name="targetImage" |
|||
listType="picture-card" |
|||
fileList={fileList} |
|||
onChange={handleUploadChange} |
|||
beforeUpload={beforeUpload} |
|||
onPreview={handlePreview} |
|||
maxCount={1} |
|||
> |
|||
{fileList.length >= 1 ? null : ( |
|||
<div> |
|||
<UploadOutlined /> |
|||
<div style={{ marginTop: 8 }}>上传图片</div> |
|||
</div> |
|||
)} |
|||
</Upload> |
|||
<div |
|||
style={{ |
|||
fontSize: "12px", |
|||
color: "#999", |
|||
marginTop: "4px", |
|||
}} |
|||
> |
|||
支持JPG、PNG格式,文件大小不超过1MB |
|||
</div> |
|||
</Form.Item> |
|||
|
|||
<Form.Item label="配置参数"> |
|||
<Space direction="vertical" style={{ width: "100%" }}> |
|||
<Checkbox |
|||
checked={isBaseline} |
|||
onChange={(e) => setIsBaseline(e.target.checked)} |
|||
> |
|||
设为基准点 |
|||
</Checkbox> |
|||
</Space> |
|||
</Form.Item> |
|||
<Form.Item |
|||
label="标靶物理圆半径" |
|||
name="physicalRadius" |
|||
rules={[ |
|||
{ required: true, message: "请输入标靶物理圆半径" }, |
|||
{ |
|||
type: "number", |
|||
min: 0.1, |
|||
max: 500, |
|||
message: "半径必须在0.1-500mm之间", |
|||
}, |
|||
]} |
|||
> |
|||
<InputNumber |
|||
value={physicalRadius} |
|||
onChange={setPhysicalRadius} |
|||
min={0.1} |
|||
max={500} |
|||
step={0.1} |
|||
precision={1} |
|||
addonAfter="mm" |
|||
style={{ width: "100%" }} |
|||
placeholder="请输入标靶物理圆半径" |
|||
/> |
|||
</Form.Item> |
|||
<Form.Item label="梯度阈值"> |
|||
<div style={{ padding: "0 8px" }}> |
|||
<Slider |
|||
min={0} |
|||
max={200} |
|||
value={gradientThresholdValue} |
|||
onChange={setGradientThresholdValue} |
|||
marks={{ |
|||
0: "0", |
|||
|
|||
100: "100", |
|||
|
|||
200: "200", |
|||
}} |
|||
/> |
|||
<div |
|||
style={{ |
|||
textAlign: "center", |
|||
marginTop: "8px", |
|||
fontSize: "14px", |
|||
color: "#1890ff", |
|||
fontWeight: "bold", |
|||
}} |
|||
> |
|||
{gradientThresholdValue} |
|||
</div> |
|||
</div> |
|||
</Form.Item> |
|||
|
|||
<Form.Item label="锚点阈值"> |
|||
<div style={{ padding: "0 8px" }}> |
|||
<Slider |
|||
min={0} |
|||
max={200} |
|||
value={anchorThresholdValue} |
|||
onChange={setAnchorThresholdValue} |
|||
marks={{ |
|||
0: "0", |
|||
|
|||
80: "80", |
|||
|
|||
200: "200", |
|||
}} |
|||
/> |
|||
<div |
|||
style={{ |
|||
textAlign: "center", |
|||
marginTop: "8px", |
|||
fontSize: "14px", |
|||
color: "#1890ff", |
|||
fontWeight: "bold", |
|||
}} |
|||
> |
|||
{anchorThresholdValue} |
|||
</div> |
|||
</div> |
|||
</Form.Item> |
|||
<Form.Item label="高斯模糊阈值" name="gaussianBlur"> |
|||
<Select |
|||
value={gaussianBlur} |
|||
onChange={setGaussianBlur} |
|||
style={{ width: "100%" }} |
|||
> |
|||
<Option value={1}>1</Option> |
|||
<Option value={3}>3</Option> |
|||
<Option value={5}>5</Option> |
|||
<Option value={7}>7</Option> |
|||
<Option value={9}>9</Option> |
|||
<Option value={11}>11</Option> |
|||
</Select> |
|||
</Form.Item> |
|||
</Form> |
|||
</Modal> |
|||
|
|||
{/* 图片预览Modal */} |
|||
<Modal |
|||
open={previewVisible} |
|||
title="预览图片" |
|||
footer={null} |
|||
onCancel={() => setPreviewVisible(false)} |
|||
width={600} |
|||
centered |
|||
> |
|||
<img |
|||
alt="预览" |
|||
style={{ |
|||
width: "100%", |
|||
maxHeight: "500px", |
|||
objectFit: "contain", |
|||
}} |
|||
src={previewImage} |
|||
/> |
|||
</Modal> |
|||
</> |
|||
); |
|||
}; |
|||
|
|||
export default TemplateModal; |
@ -0,0 +1,7 @@ |
|||
export { default as CameraView } from './CameraView'; |
|||
export { default as TargetList } from './TargetList'; |
|||
export { default as TemplateList } from './TemplateList'; |
|||
export { default as RealtimeCharts } from './RealtimeCharts'; |
|||
export { default as RealtimeDataTable } from './RealtimeDataTable'; |
|||
export { default as TemplateModal } from './TemplateModal'; |
|||
export { default as TargetDetailModal } from './TargetDetailModal'; |
@ -0,0 +1,476 @@ |
|||
import React, { useState, useEffect } from "react"; |
|||
import { Tabs, Typography } from "antd"; |
|||
import { EyeOutlined } from "@ant-design/icons"; |
|||
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"; |
|||
|
|||
const { Title } = Typography; |
|||
|
|||
// 内部组件,使用WebSocket hook |
|||
const WuyuanbiaobaContent = () => { |
|||
const { isConnected, sendMessage } = useWebSocket(); |
|||
|
|||
// 订阅实时数据 |
|||
const realtimeDataSubscription = useWebSocketSubscription('dev', 'data'); |
|||
|
|||
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 [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: `target${Number(item.pos)+1}`, |
|||
xValue: item.x, |
|||
yValue: item.y, |
|||
updateTime: data.time || new Date().toLocaleString(), |
|||
})); |
|||
}; |
|||
|
|||
// 初始化数据 |
|||
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) { |
|||
console.log('收到实时数据:', newRealtimeData); |
|||
|
|||
// 更新最新数据表格 - 保留之前设备的数据,只更新有新数据的设备 |
|||
setRealtimeData(prevRealtimeData => { |
|||
// 创建设备ID到数据的映射 |
|||
const deviceDataMap = new Map(); |
|||
|
|||
// 先保留所有之前的设备数据 |
|||
prevRealtimeData.forEach(data => { |
|||
deviceDataMap.set(data.deviceId, data); |
|||
}); |
|||
|
|||
// 用新数据更新或添加设备 |
|||
newRealtimeData.forEach(data => { |
|||
deviceDataMap.set(data.deviceId, data); |
|||
}); |
|||
|
|||
// 转换回数组格式,按设备ID排序保持一致性 |
|||
return Array.from(deviceDataMap.values()).sort((a, b) => |
|||
a.deviceId.localeCompare(b.deviceId) |
|||
); |
|||
}); |
|||
|
|||
// 更新历史数据(用于图表显示) |
|||
setTableData(prevData => { |
|||
const updatedData = [ |
|||
...prevData, |
|||
...newRealtimeData.map((point, index) => ({ |
|||
...point, |
|||
key: `${Date.now()}_${point.key}`, // 为历史数据生成唯一key |
|||
})), |
|||
]; |
|||
// 保持最多100个数据点 |
|||
return updatedData.slice(-100); |
|||
}); |
|||
|
|||
setLastUpdateTime(new Date()); |
|||
} |
|||
} |
|||
}, [realtimeDataSubscription.latest]); |
|||
|
|||
// 编辑标靶的处理函数 |
|||
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 |
|||
style={{ |
|||
display: "flex", |
|||
alignItems: "center", |
|||
cursor: "pointer", |
|||
color: "#1890ff", |
|||
fontSize: "24px", |
|||
marginLeft: "16px", |
|||
}} |
|||
> |
|||
<EyeOutlined /> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* 中间区域 - 固定视口剩余高度 */} |
|||
<div |
|||
style={{ |
|||
height: "calc(100vh - 60px)", |
|||
display: "flex", |
|||
margin: "16px", |
|||
}} |
|||
> |
|||
{/* Camera 区域 */} |
|||
<CameraView |
|||
selectedTargetId={selectedTargetId} |
|||
onClearSelection={handleClearSelection} |
|||
onRectangleClick={handleRectangleClick} |
|||
selectedTemplate={ |
|||
selectedTemplate ? getTemplateByKey(selectedTemplate) : null |
|||
} |
|||
/> |
|||
|
|||
{/* 右侧 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} |
|||
/> |
|||
|
|||
{/* 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; |
@ -0,0 +1,344 @@ |
|||
import { useState, useEffect, useCallback } from 'react'; |
|||
import { useWebSocket, useWebSocketSubscription } from '../actions/websocket.jsx'; |
|||
|
|||
/** |
|||
* 标靶数据管理 Hook |
|||
* |
|||
* 功能: |
|||
* - 通过WebSocket获取标靶数据 |
|||
* - 提供标靶的增删改查功能 |
|||
* - 自动处理WebSocket通信 |
|||
* |
|||
* 使用方法: |
|||
* ```jsx
|
|||
* const { |
|||
* targets, |
|||
* loading, |
|||
* addTarget, |
|||
* updateTarget, |
|||
* deleteTarget, |
|||
* refreshTargets |
|||
* } = useTargetStorage(); |
|||
* ``` |
|||
*/ |
|||
export const useTargetStorage = () => { |
|||
const [targets, setTargets] = useState([]); |
|||
const [loading, setLoading] = useState(true); |
|||
const [error, setError] = useState(null); |
|||
const [isProcessing, setIsProcessing] = useState(false); |
|||
|
|||
const { isConnected, sendMessage } = useWebSocket(); |
|||
|
|||
// 监听标靶数据响应
|
|||
const { latest: targetDataResponse } = useWebSocketSubscription('dev', 'getPoints'); |
|||
|
|||
// 发送获取标靶数据的命令
|
|||
const fetchTargets = useCallback(() => { |
|||
if (!isConnected) { |
|||
console.warn('WebSocket未连接,无法获取标靶数据'); |
|||
return; |
|||
} |
|||
|
|||
setLoading(true); |
|||
setError(null); |
|||
|
|||
const success = sendMessage(JSON.stringify({ |
|||
"_from": "setup", |
|||
"cmd": "getPoints", |
|||
"values": {} |
|||
})); |
|||
|
|||
if (!success) { |
|||
setError('发送获取标靶数据命令失败'); |
|||
setLoading(false); |
|||
} |
|||
}, [isConnected, sendMessage]); |
|||
|
|||
// 处理接收到的标靶数据
|
|||
useEffect(() => { |
|||
if (targetDataResponse && targetDataResponse.values) { |
|||
try { |
|||
const responseValues = targetDataResponse.values; |
|||
console.log('收到标靶数据:', responseValues); |
|||
|
|||
// 检查是否为空对象
|
|||
if (!responseValues || Object.keys(responseValues).length === 0) { |
|||
console.log('服务端返回空的标靶数据'); |
|||
setTargets([]); |
|||
setError(null); |
|||
setLoading(false); |
|||
return; |
|||
} |
|||
|
|||
// 处理服务端返回的 targets 对象格式
|
|||
const targetsData = responseValues.targets || {}; |
|||
|
|||
// 将对象转换为数组格式,并转换为前端需要的格式
|
|||
const formattedTargets = Object.keys(targetsData).map((targetKey) => { |
|||
const targetInfo = targetsData[targetKey]; |
|||
const info = targetInfo.info || {}; |
|||
const rectangleArea = info.rectangle_area || {}; |
|||
const threshold = info.threshold || {}; |
|||
|
|||
return { |
|||
key: info.id !== undefined ? info.id.toString() : targetKey, |
|||
id: info.id !== undefined ? info.id.toString() : targetKey, |
|||
name: info.desc || `目标${targetKey}`, |
|||
radius: info.radius || 40.0, |
|||
isReferencePoint: info.base || false, |
|||
// 根据实际数据结构映射阈值
|
|||
gradientThreshold: threshold.gradient || 100, // 梯度阈值
|
|||
anchorThreshold: threshold.anchor || 80, // 锚点阈值
|
|||
gaussianBlurThreshold: threshold.gauss || 3, |
|||
binaryThreshold: threshold.binary || 120, // 保留二值化阈值
|
|||
hasAdvancedConfig: true, // 有配置数据的都认为是有高级配置
|
|||
// 保留原始服务端数据
|
|||
rectangleArea: rectangleArea, |
|||
perspective: targetInfo.perspective || null, |
|||
handlerInfo: targetInfo.handler_info || null, |
|||
}; |
|||
}); |
|||
|
|||
setTargets(formattedTargets); |
|||
setError(null); |
|||
console.log('解析到标靶数据:', formattedTargets); |
|||
} catch (err) { |
|||
console.error('处理标靶数据失败:', err); |
|||
setError('处理标靶数据失败'); |
|||
setTargets([]); // 出错时设置为空数组
|
|||
} finally { |
|||
setLoading(false); |
|||
} |
|||
} else if (targetDataResponse && !targetDataResponse.values) { |
|||
// 如果收到响应但没有values,可能是空响应
|
|||
console.log('收到空的标靶数据响应'); |
|||
setTargets([]); |
|||
setError(null); |
|||
setLoading(false); |
|||
} |
|||
}, [targetDataResponse]); |
|||
|
|||
// WebSocket连接建立后自动获取数据
|
|||
useEffect(() => { |
|||
if (isConnected) { |
|||
fetchTargets(); |
|||
} |
|||
}, [isConnected, fetchTargets]); |
|||
|
|||
// 通用的标靶数据处理纯函数
|
|||
const buildTargetsPayload = useCallback((targetsArray) => { |
|||
const targets = {}; |
|||
|
|||
targetsArray.forEach((target, index) => { |
|||
const rectangleArea = target.rectangleArea || {}; |
|||
targets[index.toString()] = { |
|||
info: { |
|||
rectangle_area: { |
|||
x: rectangleArea.x || 0, |
|||
y: rectangleArea.y || 0, |
|||
w: rectangleArea.w || rectangleArea.width || 100, |
|||
h: rectangleArea.h || rectangleArea.height || 100, |
|||
}, |
|||
threshold: { |
|||
binary: target.binaryThreshold || 120, |
|||
gauss: target.gaussianBlurThreshold || 1, |
|||
gradient: target.gradientThreshold || 100, |
|||
anchor: target.anchorThreshold || 80, |
|||
}, |
|||
radius: parseFloat(target.radius) || 40.0, |
|||
id: target.id || target.key, |
|||
desc: target.name || `${target.id || target.key}_target`, |
|||
base: target.isReferencePoint || false, |
|||
}, |
|||
perspective: target.perspective || [ |
|||
[1.0, 0, 0], |
|||
[0, 1.0, 0], |
|||
[0, 0, 1.0], |
|||
], |
|||
}; |
|||
}); |
|||
|
|||
return { |
|||
_from: "setup", |
|||
cmd: "setPoints", |
|||
values: { |
|||
targets: targets, |
|||
}, |
|||
}; |
|||
}, []); |
|||
|
|||
// 通用的发送标靶数据函数
|
|||
const sendTargetsData = useCallback((outputData, operationType, targetIdentifier) => { |
|||
if (isProcessing) { |
|||
console.log(`${operationType}请求正在处理中,忽略重复请求`); |
|||
return false; |
|||
} |
|||
|
|||
setIsProcessing(true); |
|||
console.log(`准备发送的${operationType}数据:`, outputData); |
|||
|
|||
try { |
|||
// 发送更新后的标靶列表到服务端
|
|||
const jsonString = JSON.stringify(outputData, null, 2).replace( |
|||
/"perspective":\s*\[\s*\[\s*1,\s*0,\s*0\s*\],\s*\[\s*0,\s*1,\s*0\s*\],\s*\[\s*0,\s*0,\s*1\s*\]\s*\]/g, |
|||
'"perspective": [\n [1.0, 0, 0],\n [0, 1.0, 0],\n [0, 0, 1.0]\n ]' |
|||
); |
|||
|
|||
const success = sendMessage(jsonString); |
|||
|
|||
if (success) { |
|||
console.log(`${operationType}标靶成功,已发送更新数据到服务端:`, targetIdentifier); |
|||
console.log('发送的数据:', outputData); |
|||
// 延迟重新获取数据,确保服务器处理完成
|
|||
setTimeout(() => { |
|||
console.log('开始重新获取标靶数据...'); |
|||
fetchTargets(); |
|||
setIsProcessing(false); |
|||
}, 1000); |
|||
return true; |
|||
} else { |
|||
console.error(`发送${operationType}数据到服务端失败`); |
|||
setIsProcessing(false); |
|||
return false; |
|||
} |
|||
} catch (error) { |
|||
console.error(`${operationType}数据处理失败:`, error); |
|||
setIsProcessing(false); |
|||
return false; |
|||
} |
|||
}, [sendMessage, fetchTargets, isProcessing]); |
|||
|
|||
// 添加标靶
|
|||
const addTarget = useCallback((targetInfo) => { |
|||
if (!isConnected) { |
|||
console.warn('WebSocket未连接,无法添加标靶'); |
|||
return false; |
|||
} |
|||
|
|||
try { |
|||
const success = sendMessage(JSON.stringify({ |
|||
"_from": "setup", |
|||
"cmd": "addPoint", |
|||
"values": targetInfo |
|||
})); |
|||
|
|||
if (success) { |
|||
// 本地更新(实际数据会通过WebSocket响应更新)
|
|||
const newTarget = { |
|||
...targetInfo, |
|||
key: targetInfo.id || Date.now().toString(), |
|||
id: targetInfo.id || Date.now().toString(), |
|||
}; |
|||
setTargets(prev => [...prev, newTarget]); |
|||
console.log('发送添加标靶命令成功'); |
|||
return true; |
|||
} |
|||
|
|||
return false; |
|||
} catch (error) { |
|||
console.error('添加标靶失败:', error); |
|||
return false; |
|||
} |
|||
}, [isConnected, sendMessage]); |
|||
|
|||
// 更新标靶
|
|||
const updateTarget = useCallback((targetInfo) => { |
|||
console.log('准备更新标靶:', targetInfo); |
|||
|
|||
if (!isConnected) { |
|||
console.warn('WebSocket未连接,无法更新标靶'); |
|||
return false; |
|||
} |
|||
|
|||
try { |
|||
// 先更新本地状态
|
|||
let updatedTargets = []; |
|||
setTargets(prev => { |
|||
console.log(prev, '更新标靶前的状态'); |
|||
updatedTargets = prev.map(target => |
|||
target.key === targetInfo.key || target.id === targetInfo.id |
|||
? { ...target, ...targetInfo } |
|||
: target |
|||
); |
|||
console.log(updatedTargets, '更新标靶后的状态'); |
|||
console.log('更新的标靶匹配:', prev.map(t => ({ |
|||
id: t.id, |
|||
key: t.key, |
|||
match: t.key === targetInfo.key || t.id === targetInfo.id, |
|||
isUpdated: t.key === targetInfo.key || t.id === targetInfo.id ? '是' : '否' |
|||
}))); |
|||
return updatedTargets; |
|||
}); |
|||
|
|||
// 使用通用函数构建payload并发送
|
|||
const outputData = buildTargetsPayload(updatedTargets); |
|||
return sendTargetsData(outputData, '更新', targetInfo.key || targetInfo.id); |
|||
|
|||
} catch (error) { |
|||
console.error('更新标靶失败:', error); |
|||
return false; |
|||
} |
|||
}, [isConnected, buildTargetsPayload, sendTargetsData]); |
|||
|
|||
// 删除标靶
|
|||
const deleteTarget = useCallback((targetKey) => { |
|||
console.log('准备删除标靶:', targetKey); |
|||
|
|||
if (!isConnected) { |
|||
console.warn('WebSocket未连接,无法删除标靶'); |
|||
return false; |
|||
} |
|||
|
|||
try { |
|||
// 先更新本地状态
|
|||
let updatedTargets = []; |
|||
setTargets(prev => { |
|||
console.log(prev, '删除标靶前的状态'); |
|||
updatedTargets = prev.filter(target => target.key !== targetKey && target.id !== targetKey); |
|||
console.log(updatedTargets, '删除标靶后的状态'); |
|||
console.log('删除的标靶ID匹配:', prev.map(t => ({ id: t.id, key: t.key, match: t.key === targetKey || t.id === targetKey }))); |
|||
return updatedTargets; |
|||
}); |
|||
|
|||
// 使用通用函数构建payload并发送
|
|||
const outputData = buildTargetsPayload(updatedTargets); |
|||
return sendTargetsData(outputData, '删除', targetKey); |
|||
|
|||
} catch (error) { |
|||
console.error('删除标靶失败:', error); |
|||
return false; |
|||
} |
|||
}, [isConnected, buildTargetsPayload, sendTargetsData]); |
|||
|
|||
// 根据key查找标靶
|
|||
const getTargetByKey = useCallback((key) => { |
|||
return targets.find(target => target.key === key || target.id === key); |
|||
}, [targets]); |
|||
|
|||
// 刷新标靶数据
|
|||
const refreshTargets = useCallback(() => { |
|||
fetchTargets(); |
|||
}, [fetchTargets]); |
|||
|
|||
// 清空标靶数据
|
|||
const clearTargets = useCallback(() => { |
|||
setTargets([]); |
|||
}, []); |
|||
|
|||
return { |
|||
targets, |
|||
loading, |
|||
error, |
|||
isProcessing, |
|||
addTarget, |
|||
updateTarget, |
|||
deleteTarget, |
|||
getTargetByKey, |
|||
refreshTargets, |
|||
clearTargets, |
|||
// WebSocket连接状态
|
|||
isConnected, |
|||
}; |
|||
}; |
|||
|
|||
export default useTargetStorage; |
@ -0,0 +1,250 @@ |
|||
import { useState, useEffect } from 'react'; |
|||
|
|||
// localStorage 存储键名
|
|||
const TEMPLATE_STORAGE_KEY = 'wuyuanbiaoba_templates'; |
|||
|
|||
// 将图片转换为base64的工具函数
|
|||
const convertImageToBase64 = (imagePath) => { |
|||
return new Promise((resolve, reject) => { |
|||
const img = new Image(); |
|||
img.crossOrigin = 'anonymous'; // 处理跨域问题
|
|||
|
|||
img.onload = () => { |
|||
const canvas = document.createElement('canvas'); |
|||
const ctx = canvas.getContext('2d'); |
|||
|
|||
canvas.width = img.width; |
|||
canvas.height = img.height; |
|||
|
|||
ctx.drawImage(img, 0, 0); |
|||
|
|||
try { |
|||
const base64 = canvas.toDataURL('image/jpeg', 0.8); |
|||
resolve(base64); |
|||
} catch (error) { |
|||
reject(error); |
|||
} |
|||
}; |
|||
|
|||
img.onerror = () => { |
|||
reject(new Error(`Failed to load image: ${imagePath}`)); |
|||
}; |
|||
|
|||
img.src = imagePath; |
|||
}); |
|||
}; |
|||
|
|||
// 创建内置模板的函数
|
|||
const createBuiltinTemplate = async () => { |
|||
try { |
|||
// 标靶图片路径
|
|||
const imagePath = '/client/assets/img/wuyuanbiaoba_template_images/temp_target1.jpg'; |
|||
const imageBase64 = await convertImageToBase64(imagePath); |
|||
|
|||
return { |
|||
key: 'builtin_1', |
|||
name: 'template1', |
|||
isBaseline: false, |
|||
isPerspectiveCorrection: false, |
|||
binaryThreshold: 100, |
|||
gaussianBlur: 1, |
|||
physicalRadius: 40.0, |
|||
gradientThresholdValue: 100, |
|||
anchorThresholdValue: 80, |
|||
imageUrl: imageBase64, |
|||
id: 'builtin_1', |
|||
}; |
|||
} catch (error) { |
|||
console.error('创建内置模板失败:', error); |
|||
// 如果图片加载失败,返回不带图片的内置模板
|
|||
return { |
|||
key: 'builtin_1', |
|||
name: '模板1', |
|||
isBaseline: false, |
|||
isPerspectiveCorrection: false, |
|||
binaryThreshold: 100, |
|||
gaussianBlur: 1, |
|||
physicalRadius: 40.0, |
|||
gradientThresholdValue: 100, |
|||
anchorThresholdValue: 80, |
|||
imageUrl: "", |
|||
id: 'builtin_1', |
|||
}; |
|||
} |
|||
}; |
|||
|
|||
// localStorage 工具函数
|
|||
const saveTemplatesToStorage = (templates) => { |
|||
try { |
|||
localStorage.setItem(TEMPLATE_STORAGE_KEY, JSON.stringify(templates)); |
|||
return true; |
|||
} catch (error) { |
|||
console.error('保存模板数据到localStorage失败:', error); |
|||
return false; |
|||
} |
|||
}; |
|||
|
|||
const getTemplatesFromStorage = () => { |
|||
try { |
|||
const stored = localStorage.getItem(TEMPLATE_STORAGE_KEY); |
|||
return stored ? JSON.parse(stored) : null; |
|||
} catch (error) { |
|||
console.error('从localStorage读取模板数据失败:', error); |
|||
return null; |
|||
} |
|||
}; |
|||
|
|||
// 模板存储管理 Hook
|
|||
export const useTemplateStorage = () => { |
|||
const [templates, setTemplates] = useState([]); |
|||
const [loading, setLoading] = useState(true); |
|||
|
|||
// 初始化加载模板数据
|
|||
useEffect(() => { |
|||
const loadTemplates = async () => { |
|||
const storedTemplates = getTemplatesFromStorage(); |
|||
|
|||
if (storedTemplates && storedTemplates.length > 0) { |
|||
// 如果localStorage中有数据,检查是否已有内置模板
|
|||
const hasBuiltinTemplate = storedTemplates.some(template => template.key === 'builtin_1'); |
|||
|
|||
if (!hasBuiltinTemplate) { |
|||
// 如果没有内置模板,添加内置模板
|
|||
try { |
|||
const builtinTemplate = await createBuiltinTemplate(); |
|||
const updatedTemplates = [builtinTemplate, ...storedTemplates]; |
|||
setTemplates(updatedTemplates); |
|||
saveTemplatesToStorage(updatedTemplates); |
|||
console.log('内置模板已添加到现有模板列表'); |
|||
} catch (error) { |
|||
console.error('添加内置模板失败:', error); |
|||
setTemplates(storedTemplates); |
|||
} |
|||
} else { |
|||
// 已有内置模板,直接使用现有数据
|
|||
setTemplates(storedTemplates); |
|||
} |
|||
} else { |
|||
// 如果localStorage中没有数据,只创建内置模板
|
|||
try { |
|||
const builtinTemplate = await createBuiltinTemplate(); |
|||
const allTemplates = [builtinTemplate]; |
|||
|
|||
setTemplates(allTemplates); |
|||
saveTemplatesToStorage(allTemplates); |
|||
console.log('已创建内置模板'); |
|||
} catch (error) { |
|||
console.error('创建内置模板失败:', error); |
|||
// 如果创建失败,创建空模板列表
|
|||
setTemplates([]); |
|||
saveTemplatesToStorage([]); |
|||
} |
|||
} |
|||
|
|||
setLoading(false); |
|||
}; |
|||
|
|||
loadTemplates(); |
|||
}, []); |
|||
|
|||
// 添加模板
|
|||
const addTemplate = (templateInfo) => { |
|||
const newTemplates = [...templates, templateInfo]; |
|||
setTemplates(newTemplates); |
|||
const saved = saveTemplatesToStorage(newTemplates); |
|||
|
|||
if (!saved) { |
|||
// 如果保存失败,回滚状态
|
|||
console.error('添加模板失败:无法保存到localStorage'); |
|||
return false; |
|||
} |
|||
|
|||
return true; |
|||
}; |
|||
|
|||
// 更新模板
|
|||
const updateTemplate = (templateInfo) => { |
|||
const updatedTemplates = templates.map((item) => |
|||
item.key === templateInfo.key ? templateInfo : item |
|||
); |
|||
setTemplates(updatedTemplates); |
|||
const saved = saveTemplatesToStorage(updatedTemplates); |
|||
|
|||
if (!saved) { |
|||
// 如果保存失败,回滚状态
|
|||
console.error('更新模板失败:无法保存到localStorage'); |
|||
setTemplates(templates); // 回滚到原状态
|
|||
return false; |
|||
} |
|||
|
|||
return true; |
|||
}; |
|||
|
|||
// 删除模板
|
|||
const deleteTemplate = (templateKey) => { |
|||
const filteredTemplates = templates.filter((item) => item.key !== templateKey); |
|||
setTemplates(filteredTemplates); |
|||
const saved = saveTemplatesToStorage(filteredTemplates); |
|||
|
|||
if (!saved) { |
|||
// 如果保存失败,回滚状态
|
|||
console.error('删除模板失败:无法保存到localStorage'); |
|||
setTemplates(templates); // 回滚到原状态
|
|||
return false; |
|||
} |
|||
|
|||
return true; |
|||
}; |
|||
|
|||
// 根据key查找模板
|
|||
const getTemplateByKey = (key) => { |
|||
return templates.find(template => template.key === key); |
|||
}; |
|||
|
|||
// 清空所有模板
|
|||
const clearAllTemplates = () => { |
|||
setTemplates([]); |
|||
const saved = saveTemplatesToStorage([]); |
|||
|
|||
if (!saved) { |
|||
console.error('清空模板失败:无法保存到localStorage'); |
|||
return false; |
|||
} |
|||
|
|||
return true; |
|||
}; |
|||
|
|||
// 重置为默认模板
|
|||
const resetToDefaultTemplates = async () => { |
|||
try { |
|||
const builtinTemplate = await createBuiltinTemplate(); |
|||
const allTemplates = [builtinTemplate]; |
|||
|
|||
setTemplates(allTemplates); |
|||
const saved = saveTemplatesToStorage(allTemplates); |
|||
|
|||
if (!saved) { |
|||
console.error('重置模板失败:无法保存到localStorage'); |
|||
return false; |
|||
} |
|||
|
|||
return true; |
|||
} catch (error) { |
|||
console.error('重置模板失败:', error); |
|||
return false; |
|||
} |
|||
}; |
|||
|
|||
return { |
|||
templates, |
|||
loading, |
|||
addTemplate, |
|||
updateTemplate, |
|||
deleteTemplate, |
|||
getTemplateByKey, |
|||
clearAllTemplates, |
|||
resetToDefaultTemplates, |
|||
}; |
|||
}; |
|||
|
|||
export default useTemplateStorage; |
@ -0,0 +1,7 @@ |
|||
import route from './router'; |
|||
import actions from './actions'; |
|||
|
|||
export default { |
|||
route: route, |
|||
actions: actions, |
|||
}; |
@ -0,0 +1,10 @@ |
|||
import Wuyuanbiaoba from './container/index' |
|||
|
|||
export default { |
|||
type: 'outer', |
|||
route: { |
|||
key: 'wuyuanbiaoba', |
|||
path: "/wuyuanbiaoba", |
|||
component: Wuyuanbiaoba |
|||
} |
|||
}; |
@ -0,0 +1,6 @@ |
|||
export const apiTable = { |
|||
login: "/login", |
|||
logout: "/logout", |
|||
|
|||
qiniuToken: '/qiniu/token' |
|||
} |
@ -0,0 +1,22 @@ |
|||
import { apiTable } from './api' |
|||
|
|||
import { request, basicAction, basicReducer } from '@peace/react_client' |
|||
import { message } from 'antd' |
|||
|
|||
const apiRequest = new request({ |
|||
proxy: '/_api', |
|||
userKey: 'user', // session 中的用户key 值 用于拼接 token
|
|||
message: message, // 全局提示组件
|
|||
interceptor: (res, err) => { // 拦截器
|
|||
if (err) { |
|||
if (err?.status === 401) { |
|||
window.location.replace('/signin'); |
|||
sessionStorage.clear() |
|||
} |
|||
return err |
|||
} |
|||
return res |
|||
} |
|||
}) |
|||
|
|||
export { basicAction, basicReducer, apiTable, apiRequest } |
@ -0,0 +1,103 @@ |
|||
'use strict'; |
|||
|
|||
/** 解析项企流程表单的字段值 */ |
|||
|
|||
const schemaRecursionObj = (obj, target, schemaPath) => { |
|||
let schemaPath_ = JSON.parse(JSON.stringify(schemaPath)) |
|||
if (obj.properties) { |
|||
for (let prKey in obj.properties) { |
|||
if (obj.properties[prKey].title == target) { |
|||
schemaPath_.push({ |
|||
prKey, |
|||
...obj.properties[prKey] |
|||
}) |
|||
return schemaPath_ |
|||
} |
|||
const hasProperties = obj.properties[prKey].properties |
|||
const isGroup = obj.properties[prKey].type == 'array' && obj.properties[prKey].title == '分组' |
|||
if (hasProperties || isGroup) { |
|||
schemaPath_.push({ |
|||
prKey, |
|||
...obj.properties[prKey], |
|||
isGroup: isGroup, |
|||
}) |
|||
schemaPath_ = schemaRecursionObj( |
|||
isGroup ? |
|||
obj.properties[prKey].items |
|||
: obj.properties[prKey], |
|||
target, |
|||
schemaPath_ |
|||
) |
|||
if (!schemaPath_) { |
|||
return [] |
|||
} |
|||
if (schemaPath_.length > schemaPath.length) { |
|||
return schemaPath_ |
|||
} |
|||
} |
|||
} |
|||
} else { |
|||
return schemaPath_ |
|||
} |
|||
} |
|||
|
|||
const dataRecursionObj = (dataObj, index, needData, lastKeyObj, nd) => { |
|||
const keyObj = needData[nd].schemaPath[index] |
|||
if (dataObj.hasOwnProperty(keyObj.prKey)) { |
|||
if (lastKeyObj.prKey == keyObj.prKey) { |
|||
let gotValue = dataObj[keyObj.prKey] |
|||
if (keyObj.enum && !needData[nd].fromDataSource) { |
|||
let vIndex = keyObj.enum.findIndex(ke => ke == gotValue) |
|||
gotValue = keyObj.enumNames[vIndex] |
|||
} |
|||
return gotValue |
|||
} else { |
|||
if (keyObj.isGroup) { |
|||
debugger |
|||
for (let item of dataObj[keyObj.prKey]) { |
|||
const gotValue = dataRecursionObj(item, index + 1, needData, lastKeyObj, nd) |
|||
if (gotValue) { |
|||
return gotValue |
|||
} |
|||
} |
|||
} else { |
|||
return dataRecursionObj(dataObj[keyObj.prKey], index + 1, needData, lastKeyObj, nd) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
const getData = (applyDetail, needData) => { |
|||
for (let nd in needData) { |
|||
if (needData[nd].noProcess) { |
|||
continue |
|||
} |
|||
needData[nd].schemaPath = schemaRecursionObj(applyDetail.formSchema.jsonSchema, needData[nd]['keyWord'], []) |
|||
if (needData[nd].schemaPath && needData[nd].schemaPath.length) { |
|||
const lastKeyObj = needData[nd].schemaPath[ |
|||
needData[nd].schemaPath.length - 1 |
|||
] |
|||
needData[nd].value = dataRecursionObj(applyDetail.formData, 0, needData, lastKeyObj, nd) |
|||
} else { |
|||
// 记录错误 关键数据没找到
|
|||
} |
|||
} |
|||
} |
|||
|
|||
export const parseProcessData = ( |
|||
applyDetail, |
|||
/* |
|||
applyDetail = { |
|||
formSchema: r?.formData?.workflowProcessVersion?.workflowProcessForm?.formSchema, |
|||
formData: r?.formData?.formData |
|||
} |
|||
*/ |
|||
pomsNeedData = { |
|||
title: { |
|||
keyWord: '标题', |
|||
}, |
|||
}) => { |
|||
let needData = JSON.parse(JSON.stringify(pomsNeedData)) |
|||
getData(applyDetail, needData) |
|||
return needData |
|||
} |
@ -0,0 +1,32 @@ |
|||
const path = require('path') |
|||
|
|||
module.exports = { |
|||
env: process.env.NODE_ENV || 'development', // 运行环境 development | production |
|||
port: process.env.PORT || 5000, // 服务端口 |
|||
title: 'FreeSun', // 网站标题 |
|||
favicon: '/client/assets/favicon.ico', // 网站图标 |
|||
scripts: [// 需引入的静态脚本文件 |
|||
'/client/assets/script/peace.js' |
|||
], |
|||
proxy: [{ // 代理配置 |
|||
path: '/_api', |
|||
target: process.env.API, |
|||
changeOrigin: true, |
|||
rewrite: (path) => path.replace(/^\/_api/, ""), |
|||
logs: process.env.NODE_ENV == 'development', // 是否打印代理日志 |
|||
}], |
|||
vite: { // vite 配置 |
|||
envPrefix: 'FS_', // 传递到前端页面 window.env 的环境变量的前缀 |
|||
resolve: { |
|||
alias: { // 别名 |
|||
'@u': path.join(__dirname, './client/src/utils'), |
|||
'@comps': path.join(__dirname, './client/src/components'), |
|||
'@assets': path.join(__dirname, './client/assets'), |
|||
} |
|||
}, |
|||
}, |
|||
//--- |
|||
xunruan: { // 讯软服务配置 |
|||
host: 'http://222.186.227.196:31935' |
|||
}, |
|||
} |
@ -0,0 +1,21 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="en"> |
|||
|
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
<link rel="shortcut icon" type="image/ico" href="<!--app-ico-->" /> |
|||
<title> |
|||
<!--app-title--> |
|||
</title> |
|||
<!--app-head--> |
|||
<!--app-script--> |
|||
<!--app-env--> |
|||
</head> |
|||
|
|||
<body> |
|||
<div id="root"><!--app-html--></div> |
|||
<script type="module" src="/client/entry/client.jsx"></script> |
|||
</body> |
|||
|
|||
</html> |
@ -0,0 +1,22 @@ |
|||
podTemplate { |
|||
node('pod-templ-jenkins-slave-common') { |
|||
|
|||
env.IMAGE_NAME = "${IOT_IMAGES_REGISTRY}/${IOT}/${JOB_NAME}" |
|||
env.IMAGE_NAME_SHORT = "${IOT}/${JOB_NAME}" |
|||
env.SVN_Add = "${SVN_ADDRESS}/ 这儿这儿这儿写完整 SVN 地址" |
|||
|
|||
stage('Run shell') { |
|||
checkout([$class: 'SubversionSCM', filterChangelog: false, locations: [[cancelProcessOnExternalsFail: true, credentialsId: 'svn-build', depthOption: 'infinity', ignoreExternalsOption: true, local: '.', remote: "${SVN_Add}"]], quietOperation: true, workspaceUpdater: [$class: 'UpdateUpdater']]) |
|||
|
|||
container('image-builder') { |
|||
sh''' |
|||
find . -depth -name '.svn' -type d -exec rm -rf {} + |
|||
/kaniko/executor --context=${BUILD_WORKSPACE} --dockerfile=./Dockerfile --destination=${IMAGE_NAME}:${IMAGE_VERSION} --cache=false --cleanup |
|||
''' |
|||
} |
|||
|
|||
buildName "${IMAGE_NAME_SHORT}:${IMAGE_VERSION}" |
|||
buildDescription "${IMAGE_NAME}:${IMAGE_VERSION}" |
|||
} |
|||
} |
|||
} |
File diff suppressed because it is too large
@ -0,0 +1,32 @@ |
|||
{ |
|||
"name": "wuyuanbiaoba-web", |
|||
"version": "1.0.0", |
|||
"main": "index.html", |
|||
"scripts": { |
|||
"test": "echo \"Error: no test specified\" && exit 1", |
|||
"start": "peace-rc start", |
|||
"build": "peace-rc build" |
|||
}, |
|||
"author": "附离", |
|||
"license": "ISC", |
|||
"description": "基于 React.18 + Vite.4 预置 Antd.5 的客户端(web)", |
|||
"engines": { |
|||
"node": ">=20.0.0" |
|||
}, |
|||
"devDependencies": { |
|||
"@ant-design/icons": "^5.4.0", |
|||
"antd": "^5.19.0", |
|||
"react": "18.x", |
|||
"react-dom": "18.x", |
|||
"react-redux": "^9.1.2", |
|||
"echarts": "^5.6.0", |
|||
"echarts-for-react": "^3.0.2", |
|||
"react-router-dom": "^6.26.0" |
|||
}, |
|||
"dependencies": { |
|||
"@koa/multer": "^3.0.2", |
|||
"@peace/react_client": "^5.0.1", |
|||
"fs-extra": "^11.2.0", |
|||
"ws": "^8.18.3" |
|||
} |
|||
} |
@ -0,0 +1,18 @@ |
|||
const request = require('superagent') |
|||
|
|||
module.exports.decryption = async (ctx, next) => { |
|||
try { |
|||
const { xunruan } = ctx.config |
|||
const { file } = ctx.request |
|||
let url = `${xunruan.host}/uploadSecret`; |
|||
let res_ = await request.post(url).set('Content-Type', 'application/octet-stream') |
|||
.send(file.buffer) |
|||
.responseType('binary') |
|||
|
|||
ctx.status = 200; |
|||
ctx.body = res_.body |
|||
} catch (error) { |
|||
ctx.status = 400; |
|||
ctx.body = { message: 'decryption error.' }; |
|||
} |
|||
} |
@ -0,0 +1,22 @@ |
|||
|
|||
const multer = require('@koa/multer'); |
|||
const { setupTcpProxy } = require('./tcpProxy'); |
|||
const upload = multer({ |
|||
limits: { fileSize: 500 * 1024 * 1024 } // 将文件大小限制设置为200MB
|
|||
}); |
|||
//
|
|||
const xunruan = require('./controllers/xunruan.js'); |
|||
|
|||
|
|||
|
|||
// ... 路由都写在这里
|
|||
module.exports = async (app, router, conf) => { |
|||
// 讯软相关接口
|
|||
router.post('/xunruan/decryption', |
|||
upload.single('file'), |
|||
xunruan.decryption, |
|||
{ content: '讯软解密', visible: true } |
|||
); |
|||
// 设置TCP代理
|
|||
setupTcpProxy(conf); |
|||
} |
@ -0,0 +1,163 @@ |
|||
const net = require('net'); |
|||
const WebSocket = require('ws'); |
|||
// TCP代理配置
|
|||
const TCP_HOST = process.env.NODE_ENV === 'development'? '10.8.30.179' : '127.0.0.1'; |
|||
const TCP_PORT = 2230; |
|||
|
|||
// 创建独立的WebSocket服务器用于TCP代理
|
|||
function setupTcpProxy(conf) { |
|||
// 从配置中获取端口,如果没有则使用默认端口
|
|||
const wsPort = (conf && conf.port) ? Number(conf.port) + 1 : 5001; // WebSocket端口比HTTP端口大1
|
|||
|
|||
console.log(`准备在端口 ${wsPort} 启动WebSocket服务器`); |
|||
|
|||
const wss = new WebSocket.Server({ |
|||
port: wsPort, |
|||
host: '0.0.0.0', // 监听所有网络接口,允许局域网访问
|
|||
path: '/tcp-proxy' |
|||
}); |
|||
|
|||
wss.on('connection', (ws, request) => { |
|||
console.log(`WebSocket连接建立,来自: ${request.socket.remoteAddress}`); |
|||
|
|||
// 创建TCP连接
|
|||
const tcpClient = new net.Socket(); |
|||
// TCP数据缓冲区
|
|||
let tcpDataBuffer = ''; |
|||
|
|||
// TCP连接成功
|
|||
tcpClient.connect(process.env.TCP_PORT || TCP_PORT, process.env.TCP_HOST || TCP_HOST, () => { |
|||
console.log(`TCP连接已建立到 ${TCP_HOST}:${TCP_PORT}`); |
|||
}); |
|||
|
|||
// TCP接收数据,转发到WebSocket
|
|||
tcpClient.on('data', (data) => { |
|||
let textData; |
|||
// 尝试解析为文本
|
|||
try { |
|||
textData = data.toString('utf8'); |
|||
console.log('收到TCP数据片段:', textData.length, '字节'); |
|||
} catch (e) { |
|||
console.log('TCP数据无法解析为文本'); |
|||
return; |
|||
} |
|||
|
|||
// 将新数据添加到缓冲区
|
|||
tcpDataBuffer += textData; |
|||
|
|||
// 检查是否有完整的消息(以\n\n结尾)
|
|||
let endIndex; |
|||
while ((endIndex = tcpDataBuffer.indexOf('\n\n')) !== -1) { |
|||
// 提取完整消息
|
|||
const completeMessage = tcpDataBuffer.substring(0, endIndex); |
|||
// 从缓冲区移除已处理的消息
|
|||
tcpDataBuffer = tcpDataBuffer.substring(endIndex + 2); |
|||
|
|||
console.log('提取到完整消息:', completeMessage.length, '字节'); |
|||
|
|||
// 转发完整消息到WebSocket
|
|||
if (ws.readyState === WebSocket.OPEN) { |
|||
console.log('准备发送完整消息到WebSocket:', completeMessage.length, '字节'); |
|||
console.log('消息内容:', completeMessage); |
|||
ws.send(completeMessage, (err) => { |
|||
if (err) { |
|||
console.error('WebSocket发送数据错误:', err); |
|||
} else { |
|||
console.log('完整消息已转发到WebSocket客户端'); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
|
|||
console.log('缓冲区剩余数据:', tcpDataBuffer.length, '字节'); |
|||
}); |
|||
|
|||
// WebSocket接收数据,转发到TCP
|
|||
ws.on('message', (data) => { |
|||
// 处理可能的Buffer数据,转换为字符串
|
|||
let messageStr; |
|||
|
|||
if (Buffer.isBuffer(data)) { |
|||
messageStr = data.toString('utf8'); |
|||
// console.log('WebSocket数据(Buffer转字符串):', messageStr);
|
|||
} else if (typeof data === 'string') { |
|||
messageStr = data; |
|||
// console.log('WebSocket数据(字符串):', messageStr);
|
|||
} else { |
|||
console.warn('收到未知类型数据,已忽略:', typeof data); |
|||
return; |
|||
} |
|||
|
|||
// 转发字符串数据到TCP服务器
|
|||
if (tcpClient.writable) { |
|||
console.log('准备发送数据到TCP服务器:', messageStr); |
|||
|
|||
// 检查数据大小
|
|||
const dataSize = Buffer.byteLength(messageStr, 'utf8'); |
|||
console.log(`数据大小: ${dataSize} bytes`); |
|||
|
|||
// 直接发送完整数据
|
|||
tcpClient.write(messageStr + '\n\n', (err) => { |
|||
if (err) { |
|||
console.error('TCP发送数据错误:', err); |
|||
} else { |
|||
console.log('数据已发送到TCP服务器'); |
|||
} |
|||
}); |
|||
} else { |
|||
console.warn('TCP连接不可写,无法发送数据'); |
|||
} |
|||
}); |
|||
|
|||
// TCP连接错误处理
|
|||
tcpClient.on('error', (err) => { |
|||
console.error('TCP连接错误:', err); |
|||
tcpDataBuffer = ''; // 清理缓冲区
|
|||
if (ws.readyState === WebSocket.OPEN) { |
|||
ws.close(1011, 'TCP连接错误'); |
|||
} |
|||
}); |
|||
|
|||
// TCP连接关闭
|
|||
tcpClient.on('close', () => { |
|||
console.log('TCP连接已关闭'); |
|||
tcpDataBuffer = ''; // 清理缓冲区
|
|||
if (ws.readyState === WebSocket.OPEN) { |
|||
ws.close(); |
|||
} |
|||
}); |
|||
|
|||
// WebSocket连接关闭
|
|||
ws.on('close', () => { |
|||
console.log('WebSocket连接已关闭'); |
|||
tcpDataBuffer = ''; // 清理缓冲区
|
|||
if (tcpClient.writable) { |
|||
tcpClient.destroy(); |
|||
} |
|||
}); |
|||
|
|||
// WebSocket错误处理
|
|||
ws.on('error', (err) => { |
|||
console.error('WebSocket错误:', err); |
|||
if (tcpClient.writable) { |
|||
tcpClient.destroy(); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
wss.on('listening', () => { |
|||
console.log(`TCP代理WebSocket服务器已启动在端口 ${wsPort},路径: /tcp-proxy`); |
|||
console.log(`局域网连接地址: ws://[本机IP]:${wsPort}/tcp-proxy`); |
|||
console.log(`本地连接地址: ws://localhost:${wsPort}/tcp-proxy`); |
|||
console.log(`注意:请确保防火墙允许端口 ${wsPort} 的访问`); |
|||
}); |
|||
|
|||
wss.on('error', (err) => { |
|||
console.error('WebSocket服务器错误:', err); |
|||
}); |
|||
|
|||
return wss; |
|||
} |
|||
|
|||
|
|||
module.exports = { setupTcpProxy }; |
Loading…
Reference in new issue