Browse Source

代码迁移

master
qinjian 5 days ago
commit
496f0754fd
  1. 17
      .editorconfig
  2. 7
      .gitignore
  3. 3
      .npmrc
  4. 97
      .vscode/launch.json
  5. 3
      .vscode/settings.json
  6. 21
      Dockerfile
  7. 13
      README.md
  8. BIN
      client/assets/favicon.ico
  9. BIN
      client/assets/img/auth_bg.png
  10. BIN
      client/assets/img/fs_logo.png
  11. BIN
      client/assets/img/fs_logo_banner.png
  12. BIN
      client/assets/img/wuyuanbiaoba_template_images/temp_target1.jpg
  13. 7
      client/assets/script/peace.js
  14. 10
      client/entry/client.jsx
  15. 12
      client/entry/server.jsx
  16. 14
      client/index.jsx
  17. 13
      client/src/app.jsx
  18. 36
      client/src/app.less
  19. 35
      client/src/components/errorBoundary.jsx
  20. 7
      client/src/components/index.js
  21. 150
      client/src/components/upload.jsx
  22. 93
      client/src/layout/components/content.jsx
  23. 42
      client/src/layout/components/header.jsx
  24. 76
      client/src/layout/components/sider.jsx
  25. 30
      client/src/layout/container/index.jsx
  26. 20
      client/src/layout/container/index.less
  27. 92
      client/src/layout/index.jsx
  28. 9
      client/src/sections/auth/actions/auth.js
  29. 5
      client/src/sections/auth/actions/index.js
  30. 90
      client/src/sections/auth/container/login.jsx
  31. 4
      client/src/sections/auth/container/login.less
  32. 6
      client/src/sections/auth/index.js
  33. 9
      client/src/sections/auth/router.js
  34. 11
      client/src/sections/home/actions/index.js
  35. 7
      client/src/sections/home/actions/qiniu.js
  36. 45
      client/src/sections/home/container/index.jsx
  37. 7
      client/src/sections/home/index.js
  38. 24
      client/src/sections/home/router.jsx
  39. 9
      client/src/sections/wuyuanbiaoba/actions/index.js
  40. 464
      client/src/sections/wuyuanbiaoba/actions/websocket.jsx
  41. 1209
      client/src/sections/wuyuanbiaoba/components/CameraView.jsx
  42. 296
      client/src/sections/wuyuanbiaoba/components/RealtimeCharts.jsx
  43. 61
      client/src/sections/wuyuanbiaoba/components/RealtimeDataTable.jsx
  44. 228
      client/src/sections/wuyuanbiaoba/components/TargetDetailModal.jsx
  45. 84
      client/src/sections/wuyuanbiaoba/components/TargetList.jsx
  46. 99
      client/src/sections/wuyuanbiaoba/components/TemplateList.jsx
  47. 427
      client/src/sections/wuyuanbiaoba/components/TemplateModal.jsx
  48. 7
      client/src/sections/wuyuanbiaoba/components/index.js
  49. 476
      client/src/sections/wuyuanbiaoba/container/index.jsx
  50. 344
      client/src/sections/wuyuanbiaoba/hooks/useTargetStorage.js
  51. 250
      client/src/sections/wuyuanbiaoba/hooks/useTemplateStorage.js
  52. 7
      client/src/sections/wuyuanbiaoba/index.js
  53. 10
      client/src/sections/wuyuanbiaoba/router.js
  54. 6
      client/src/utils/api.js
  55. 22
      client/src/utils/index.js
  56. 103
      client/src/utils/parseProcessData.js
  57. 32
      config.cjs
  58. 21
      index.html
  59. 22
      jenkinsfile
  60. 5026
      package-lock.json
  61. 32
      package.json
  62. 18
      server/controllers/xunruan.js
  63. 0
      server/index.js
  64. 22
      server/routes.js
  65. 163
      server/tcpProxy/index.js

17
.editorconfig

@ -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

7
.gitignore

@ -0,0 +1,7 @@
node_modules/
dist/
.env
npm-debug.log
.DS_Store
*.log
.idea/

3
.npmrc

@ -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

97
.vscode/launch.json

@ -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",
}
},
]
}

3
.vscode/settings.json

@ -0,0 +1,3 @@
{
"editor.tabSize": 3
}

21
Dockerfile

@ -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"]

13
README.md

@ -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
```

BIN
client/assets/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
client/assets/img/auth_bg.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
client/assets/img/fs_logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
client/assets/img/fs_logo_banner.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
client/assets/img/wuyuanbiaoba_template_images/temp_target1.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

7
client/assets/script/peace.js

@ -0,0 +1,7 @@
console.log(`
,----. ,------. ,------. ,---. ,-----.,------.
' , | | .--. '| .---' / O \\ ' .--./| .---'
| | / | '--' || \`--, | .-. || | | \`--,
' '--'| | | --' |\`-- -. | | | |' '--'\\\| \`---.
\`----' \`--' \`----- '·--''--·\`-----' \`-----'
`);

10
client/entry/client.jsx

@ -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>
)

12
client/entry/server.jsx

@ -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 }
}

14
client/index.jsx

@ -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>
)
}

13
client/src/app.jsx

@ -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;

36
client/src/app.less

@ -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);
}

35
client/src/components/errorBoundary.jsx

@ -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;

7
client/src/components/index.js

@ -0,0 +1,7 @@
// import Upload from "./upload";
import ErrorBoundary from "./errorBoundary";
export {
// Upload,
ErrorBoundary,
};

150
client/src/components/upload.jsx

@ -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 >
)
}

93
client/src/layout/components/content.jsx

@ -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> */}
</>
);
}

42
client/src/layout/components/header.jsx

@ -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>
);
}

76
client/src/layout/components/sider.jsx

@ -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>
);
}

30
client/src/layout/container/index.jsx

@ -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>
);
}

20
client/src/layout/container/index.less

@ -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)
}
}

92
client/src/layout/index.jsx

@ -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;
}

9
client/src/sections/auth/actions/auth.js

@ -0,0 +1,9 @@
import { basicAction, apiTable } from "@u"
const login = basicAction.post(apiTable.login, {
// tip: false,
tipFail: "登录失败",
reducerName: "user"
});
export default { login }

5
client/src/sections/auth/actions/index.js

@ -0,0 +1,5 @@
import actions from "./auth";
export default {
...actions
}

90
client/src/sections/auth/container/login.jsx

@ -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>
);
}

4
client/src/sections/auth/container/login.less

@ -0,0 +1,4 @@
.login {
background: url("@assets/img/auth_bg.png") no-repeat fixed 0 0;
background-size: cover;
}

6
client/src/sections/auth/index.js

@ -0,0 +1,6 @@
import route from './router';
import actions from './actions';
export default {
route: route,
actions: actions,
};

9
client/src/sections/auth/router.js

@ -0,0 +1,9 @@
import Login from './container/login'
export default {
type: 'outer',
route: {
key: 'signin',
path: "/signin",
component: Login
}
};

11
client/src/sections/home/actions/index.js

@ -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,
}

7
client/src/sections/home/actions/qiniu.js

@ -0,0 +1,7 @@
import { basicAction, apiTable } from "@u"
const qiniuToken = basicAction.get(apiTable.qiniuToken, {
tip: false,
});
export default { qiniuToken }

45
client/src/sections/home/container/index.jsx

@ -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>
);
}

7
client/src/sections/home/index.js

@ -0,0 +1,7 @@
import route from './router'
import actions from './actions'
export default {
route: route,
actions: actions,
}

24
client/src/sections/home/router.jsx

@ -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,
},
],
},
};

9
client/src/sections/wuyuanbiaoba/actions/index.js

@ -0,0 +1,9 @@
// 导出WebSocket相关功能
export {
WebSocketProvider,
useWebSocket,
useWebSocketSubscription,
useWebSocketMessage
} from './websocket.jsx';
export default {};

464
client/src/sections/wuyuanbiaoba/actions/websocket.jsx

@ -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;

1209
client/src/sections/wuyuanbiaoba/components/CameraView.jsx

File diff suppressed because it is too large

296
client/src/sections/wuyuanbiaoba/components/RealtimeCharts.jsx

@ -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
);
// XYnull
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;

61
client/src/sections/wuyuanbiaoba/components/RealtimeDataTable.jsx

@ -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;

228
client/src/sections/wuyuanbiaoba/components/TargetDetailModal.jsx

@ -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;

84
client/src/sections/wuyuanbiaoba/components/TargetList.jsx

@ -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;

99
client/src/sections/wuyuanbiaoba/components/TemplateList.jsx

@ -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;

427
client/src/sections/wuyuanbiaoba/components/TemplateModal.jsx

@ -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, // 使base64URL
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",
}}
>
支持JPGPNG格式文件大小不超过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;

7
client/src/sections/wuyuanbiaoba/components/index.js

@ -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';

476
client/src/sections/wuyuanbiaoba/container/index.jsx

@ -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;

344
client/src/sections/wuyuanbiaoba/hooks/useTargetStorage.js

@ -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;

250
client/src/sections/wuyuanbiaoba/hooks/useTemplateStorage.js

@ -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;

7
client/src/sections/wuyuanbiaoba/index.js

@ -0,0 +1,7 @@
import route from './router';
import actions from './actions';
export default {
route: route,
actions: actions,
};

10
client/src/sections/wuyuanbiaoba/router.js

@ -0,0 +1,10 @@
import Wuyuanbiaoba from './container/index'
export default {
type: 'outer',
route: {
key: 'wuyuanbiaoba',
path: "/wuyuanbiaoba",
component: Wuyuanbiaoba
}
};

6
client/src/utils/api.js

@ -0,0 +1,6 @@
export const apiTable = {
login: "/login",
logout: "/logout",
qiniuToken: '/qiniu/token'
}

22
client/src/utils/index.js

@ -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 }

103
client/src/utils/parseProcessData.js

@ -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
}

32
config.cjs

@ -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'
},
}

21
index.html

@ -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>

22
jenkinsfile

@ -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}"
}
}
}

5026
package-lock.json

File diff suppressed because it is too large

32
package.json

@ -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"
}
}

18
server/controllers/xunruan.js

@ -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
server/index.js

22
server/routes.js

@ -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);
}

163
server/tcpProxy/index.js

@ -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…
Cancel
Save