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