first commit

This commit is contained in:
zhanghao 2026-06-26 10:51:01 +08:00
commit deab8f583b
39 changed files with 8878 additions and 0 deletions

27
.dockerignore Normal file
View File

@ -0,0 +1,27 @@
# pnpm 相关(缓存、依赖目录)
node_modules/
.pnpm-store/
pnpm-lock.yaml.bak
pnpm-debug.log
# 构建产物
dist/
# 环境配置(本地开发用,不放入镜像)
.env
.env.local
.env.development
.env.test
# 版本控制和编辑器文件
.git/
.gitignore
.vscode/
.idea/
# 其他无关文件
*.md
Dockerfile
docker-compose.yml
logs/
temp/

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_DEVICE_ID = 'agv_src2200'

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

27
Dockerfile Normal file
View File

@ -0,0 +1,27 @@
# 使用 Node 基础镜像
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
# 复制源码并构建
COPY . .
RUN pnpm run build
# 生产阶段:只复制必要文件
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/server ./server
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --prod --frozen-lockfile
# 暴露端口(与 Express 监听端口一致)
EXPOSE 3000
# 启动 Express 服务(使用 pnpm start 或 node server/index.js
CMD ["pnpm", "start"]
# 或直接 CMD ["node", "server/index.js"]

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

90
auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,90 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const createPinia: typeof import('pinia').createPinia
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const defineStore: typeof import('pinia').defineStore
const effectScope: typeof import('vue').effectScope
const getActivePinia: typeof import('pinia').getActivePinia
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
const mapState: typeof import('pinia').mapState
const mapStores: typeof import('pinia').mapStores
const mapWritableState: typeof import('pinia').mapWritableState
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const setActivePinia: typeof import('pinia').setActivePinia
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const storeToRefs: typeof import('pinia').storeToRefs
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

41
components.d.ts vendored Normal file
View File

@ -0,0 +1,41 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElB: typeof import('element-plus/es')['ElB']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSlider: typeof import('element-plus/es')['ElSlider']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTag: typeof import('element-plus/es')['ElTag']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SvgIcon: typeof import('./src/components/SvgIcon/index.vue')['default']
}
}

17
docker-compose.yml Normal file
View File

@ -0,0 +1,17 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: inspection-host-computer
ports:
- "3000:3000" # 映射端口
environment:
- NODE_ENV=production
- PORT=3000 # 传给 Express 的环境变量
restart: unless-stopped # 容器意外停止后自动重启
# 如果需要挂载日志或配置,可以加 volumes
# volumes:
# - ./logs:/app/logs

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>inspection-host-computer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "inspection-host-computer",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:client": "vite",
"dev:server": "node server/index.js",
"start": "node server/index.js",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@grpc/grpc-js": "^1.14.4",
"@grpc/proto-loader": "^0.8.1",
"concurrently": "^10.0.3",
"cors": "^2.8.6",
"element-plus": "^2.14.2",
"express": "^5.2.1",
"fast-glob": "^3.3.3",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.38",
"vue-router": "^5.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"sass": "^1.101.0",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.1.0",
"vite-plugin-svg-icons": "^2.0.1",
"vite-svg-loader": "^5.1.1"
},
"packageManager": "pnpm@9.15.9+sha512.68046141893c66fad01c079231128e9afb89ef87e2691d69e4d40eee228988295fd4682181bae55b58418c3a253bde65a505ec7c5f9403ece5cc3cd37dcf2531"
}

4756
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

54
public/bot.svg Normal file
View File

@ -0,0 +1,54 @@
<svg
viewBox="0 0 22 22"
xmlns="http://www.w3.org/2000/svg"
fill="none"
>
<rect width="22" height="22" x="0" y="0" />
<path
d="M11 7.33335L11 3.66669L7.33331 3.66669"
fill-rule="nonzero"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.833333"
/>
<rect
width="14.666667"
height="11.000000"
x="3.666687"
y="7.333344"
rx="1.833333"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.833333"
/>
<path
d="M1.83331 12.8333L3.66665 12.8333"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.833333"
/>
<path
d="M18.3333 12.8333L20.1666 12.8333"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.833333"
/>
<path
d="M13.75 11.9167L13.75 13.75"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.833333"
/>
<path
d="M8.25 11.9167L8.25 13.75"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.833333"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

249
server/index.js Normal file
View File

@ -0,0 +1,249 @@
// server/index.js
import express from 'express'
import cors from 'cors'
import grpc from '@grpc/grpc-js'
import protoLoader from '@grpc/proto-loader'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const app = express()
app.use(express.static(path.join(__dirname, '../dist')));
app.use(cors())
app.use(express.json())
// ========== 加载 Proto ==========
const PROTO_ROOT = path.resolve(__dirname, './proto') // 指向 server/proto/
const PROTO_ENTRY = path.join(PROTO_ROOT, 'cmvr/api/agv_service.proto')
const packageDefinition = protoLoader.loadSync(
PROTO_ENTRY,
{
includeDirs: [PROTO_ROOT , path.join(PROTO_ROOT, 'cmvr/api')], // 关键:让加载器从 PROTO_ROOT 查找 import
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true
}
)
const cmvrProto = grpc.loadPackageDefinition(packageDefinition).cmvr.api
const createGrpcClient = (address) => {
const grpcClient = new cmvrProto.AgvService(
address,
grpc.credentials.createInsecure()
)
return grpcClient
}
// 获取机器人当前地图名称
app.post('/api/agv/getCurrentMapName', async (req, res) => {
const request = {
header: {
device_id: req.body.device_id || 'agv_src1100'
}
};
const grpcClient = createGrpcClient(req.body.ip)
const resp = await new Promise((resolve, reject) => {
grpcClient.getMapStatus(request, (err, res) => {
if (err) {
resolve({
code: 500,
data: `grpc服务端错误`
});
} else {
resolve({
code: 200,
data: res.status
});
}
});
})
if (resp.code === 200) {
res.json(resp)
} else {
res.status(500).json({ error: 'grpc服务端错误' })
}
})
// 获取机器人当前地图名称
app.post('/api/agv/getCurrentMap', async (req, res) => {
const grpcClient = createGrpcClient(req.body.ip)
const request = {
header: {
device_id: req.body.device_id || 'agv_src1100'
},
data: {
map_name: req.body.mapName
}
};
const resp = await new Promise((resolve, reject) => {
grpcClient.RobotConfigDownloadMap(request, (err, res) => {
if (err) {
resolve({
code: 500,
data: `grpc服务端错误`
});
} else {
resolve({
code: 200,
data: res.status
});
}
});
})
if (resp.code === 200) {
res.json(resp)
} else {
res.status(500).json({ error: 'grpc服务端错误' })
}
})
app.post('/api/agv/getRobotState', async (req, res) => {
const request = {
header: {
device_id: req.body.device_id || 'agv_src1100'
}
};
const grpcClient = createGrpcClient(req.body.ip)
const response = await new Promise((resolve, reject) => {
grpcClient.GetRobotLocation(request, (err, res) => {
if (err) {
resolve({
code: 500,
data: `grpc服务端错误`
});
} else {
resolve({
code: 200,
data: res.status
});
}
})
})
const resp = await new Promise((resolve, reject) => {
grpcClient.GetBatteryStatus(request, (err, res) => {
if (err) {
resolve({
code: 500,
data: `grpc服务端错误`
});
} else {
resolve({
code: 200,
data: res.status
});
}
});
})
if (response.code === 200 && resp.code ===200) {
res.json({
code: 200,
data: {
x: response.data.x,
y: response.data.y,
angle: response.data.angle,
confidence: response.data.confidence,
batteryLevel: resp.data.battery_level,
batteryTemp: resp.data.battery_temp,
charging: resp.data.charging,
voltage: resp.data.voltage
}
})
} else {
res.status(500).json({ error: 'grpc服务端错误' })
}
})
app.post('/api/agv/moveRobot', async (req, res) => {
const grpcClient = createGrpcClient(req.body.ip)
const request = {
header: {
device_id: req.body.device_id || 'agv_src1100'
},
data: {
vx: req.body.vx,
vy: req.body.vy,
w: req.body.w,
duration: req.body.duration
}
};
const resp = await new Promise((resolve, reject) => {
grpcClient.RobotMotionControl(request, (err, res) => {
if (err) {
resolve({
code: 500,
data: `grpc服务端错误`
});
} else {
resolve({
code: 200,
data: res.status
});
}
});
})
if (resp.code === 200) {
res.json({
code: 200,
data: resp
})
} else {
res.status(500).json({ error: 'grpc服务端错误' })
}
})
app.post('/api/agv/stopRobot', async (req, res) => {
const grpcClient = createGrpcClient(req.body.ip)
const request = {
header: {
device_id: req.body.device_id || 'agv_src1100'
}
};
const resp = await new Promise((resolve, reject) => {
grpcClient.RobotControlStop(request, (err, res) => {
if (err) {
resolve({
code: 500,
data: `grpc服务端错误`
});
} else {
resolve({
code: 200,
data: res.status
});
}
});
})
if (resp.code === 200) {
res.json({
code: 200,
data: resp
})
} else {
res.status(500).json({ error: 'grpc服务端错误' })
}
})
// ========== 启动 ==========
const PORT = 3000
app.listen(PORT, () => {
console.log(`gRPC 桥接服务已启动: http://localhost:${PORT}`)
})

View File

@ -0,0 +1,906 @@
/**
* @file agv_command.proto
* @brief AGV Protobuf
* SeerSRC API
*
* @note gRPC AgvService
*/
syntax = "proto3";
import "cmvr/api/common.proto";
package cmvr.api;
// ============================================================================
// 1. 1000, 0x03E8
// ============================================================================
/**
* @brief AGV
* @note API 10000x03E8
*/
message AgvStatusInfo {
optional string id = 1; ///< AGV ID
optional string vehicle_id = 2; ///< "agv_001"
optional string version = 3; ///<
optional string model = 4; ///< "SRC-1100"
optional string dsp_version = 5; ///< DSP
optional string current_ip = 6; ///< IP
optional string mac = 7; ///< MAC
optional int32 rssi = 8; ///< Wi-Fi 0~100
optional int32 ret_code = 9; ///< 0
optional string err_msg = 10; ///<
}
/**
* @brief AGV /
* @note IDheader.device_id AgvStatusInfo
*/
message GetAgvStatusInfoCommand {
message Request {
CommandHeader.Request header = 1; ///< device_id
}
message Feedback {
CommandHeader.Feedback header = 1; ///< success/error_message/timestamp
AgvStatusInfo status = 2; ///< AGV
}
}
// ============================================================================
// 2. 1007, 0x03EF
// ============================================================================
/**
* @brief
* @note API 10070x03EF/
*/
message AgvBatteryStatus {
optional double battery_level = 1; ///< 0~1 0%~100%
optional double battery_temp = 2; ///<
optional bool charging = 3; ///<
optional double voltage = 4; ///< V
optional double current = 5; ///< A
optional double max_charge_voltage = 6; ///< -1
optional double max_charge_current = 7; ///< -1
optional bool manual_charge = 8; ///< SRC-2000
optional bool auto_charge = 9; ///< SRC-2000
optional int32 battery_cycle = 10; ///< BMS
optional string battery_user_data = 11; ///<
optional string extra = 12; ///<
optional int32 ret_code = 13; ///< 0
optional string create_on = 14; ///< ISO 8601
optional string err_msg = 15; ///<
}
/**
* @brief
*/
message RobotStatusBatteryRequestData {
optional bool simple = 1; ///< true=false= false
}
/**
* @brief
*/
message RobotStatusBatteryCommand {
message Request {
CommandHeader.Request header = 1; ///<
RobotStatusBatteryRequestData data = 2; ///<
}
message Feedback {
CommandHeader.Feedback header = 1; ///<
AgvBatteryStatus status = 2; ///<
}
}
// ============================================================================
// 3. 1004, 0x03EC
// ============================================================================
/**
* @brief
* @note API 10040x03EC姿
*/
message AgvRobotLocation {
optional double x = 1; ///< X
optional double y = 2; ///< Y
optional double angle = 3; ///<
optional double confidence = 4; ///< 0~1
optional string current_station = 5; ///< ID
optional string last_station = 6; ///< ID
optional int32 loc_method = 7; ///< 0=, 1=, 2=, 3=...
optional int32 ret_code = 8; ///< 0
optional string create_on = 9; ///<
optional string err_msg = 10; ///<
}
/**
* @brief
*/
message RobotStatusLocCommand {
message Request {
CommandHeader.Request header = 1; ///<
}
message Feedback {
CommandHeader.Feedback header = 1; ///<
AgvRobotLocation status = 2; ///<
}
}
// ============================================================================
// 4. 4011, 0x0FAB
// ============================================================================
/**
* @brief
*/
message RobotConfigDownloadMapRequestData {
optional string map_name = 1; ///<
}
/**
* @brief
*/
message AgvDownloadMapResult {
optional int32 ret_code = 1; ///< 0
optional string create_on = 2; ///<
optional string err_msg = 3; ///<
optional string map_content = 4; ///< JSON
}
/**
* @brief
*/
message RobotConfigDownloadMapCommand {
message Request {
CommandHeader.Request header = 1;
RobotConfigDownloadMapRequestData data = 2;
}
message Feedback {
CommandHeader.Feedback header = 1;
AgvDownloadMapResult status = 2;
}
}
// ============================================================================
// 5. 1300, 0x0514
// ============================================================================
/**
* @brief
*/
message MapFileInfo {
optional string name = 1; ///<
optional string modified = 2; ///<
optional int64 size = 3; ///<
}
/**
* @brief
* @note API 13000x0514
*/
message AgvMapStatus {
optional string current_map = 1; ///<
optional string current_map_md5 = 2; ///< MD5
repeated string maps = 3; ///<
repeated MapFileInfo map_files_info = 4; ///<
optional int32 ret_code = 5; ///< 0
optional string create_on = 6; ///<
optional string err_msg = 7; ///<
}
/**
* @brief
*/
message RobotStatusMapCommand {
message Request {
CommandHeader.Request header = 1;
}
message Feedback {
CommandHeader.Feedback header = 1;
AgvMapStatus status = 2;
}
}
// ============================================================================
// 6. 4010, 0x0FAA
// ============================================================================
/**
* @brief
*/
message RobotConfigUploadMapRequestData {
optional string map_content = 1; ///< JSON
}
/**
* @brief
*/
message AgvUploadMapResult {
optional int32 ret_code = 1; ///< 0
optional string create_on = 2; ///<
optional string err_msg = 3; ///<
}
/**
* @brief
*/
message RobotConfigUploadMapCommand {
message Request {
CommandHeader.Request header = 1;
RobotConfigUploadMapRequestData data = 2;
}
message Feedback {
CommandHeader.Feedback header = 1;
AgvUploadMapResult status = 2;
}
}
// ============================================================================
// 7. 4005, 0x0FA5
// ============================================================================
/**
* @brief
*/
message RobotConfigLockRequestData {
optional string nick_name = 1; ///< /
}
/**
* @brief
*/
message AgvLockResult {
optional int32 ret_code = 1; ///< 0
optional string create_on = 2; ///<
optional string err_msg = 3; ///<
}
/**
* @brief
*/
message RobotConfigLockCommand {
message Request {
CommandHeader.Request header = 1;
RobotConfigLockRequestData data = 2;
}
message Feedback {
CommandHeader.Feedback header = 1;
AgvLockResult status = 2;
}
}
// ============================================================================
// 8. 1060, 0x0424
// ============================================================================
/**
* @brief
* @note API 10600x0424
*/
message AgvCurrentLockStatus {
optional bool locked = 1; ///<
optional string ip = 2; ///< IP
optional int32 port = 3; ///<
optional uint32 type = 4; ///< 0=default, 2=roboshop, 0xDD=srd
optional string nick_name = 5; ///<
optional int64 time_t = 6; ///< Unix
optional string desc = 7; ///<
optional int32 ret_code = 8; ///< 0
optional string create_on = 9; ///<
optional string err_msg = 10; ///<
}
/**
* @brief
*/
message RobotStatusCurrentLockCommand {
message Request {
CommandHeader.Request header = 1;
}
message Feedback {
CommandHeader.Feedback header = 1;
AgvCurrentLockStatus status = 2;
}
}
// ============================================================================
// 9. 2010, 0x07DA
// ============================================================================
/**
* @brief
* @note 20100x07DA
* vx/vy/w steer/real_steer
* duration = -1
*/
message RobotMotionControlRequestData {
optional double vx = 1; ///< X 线m/s
optional double vy = 2; ///< Y 线m/s
optional double w = 3; ///< rad/s
optional double steer = 4; ///< rad
optional double real_steer = 5; ///< steer
optional int64 duration = 6; ///< ms-1
}
/**
* @brief
*/
message RobotMotionControlResult {
optional int32 ret_code = 1; ///< 0
optional string create_on = 2; ///<
optional string err_msg = 3; ///<
}
/**
* @brief
*/
message RobotMotionControlCommand {
message Request {
CommandHeader.Request header = 1;
RobotMotionControlRequestData data = 2;
}
message Feedback {
CommandHeader.Feedback header = 1;
RobotMotionControlResult status = 2;
}
}
// ============================================================================
// 10. 2022, 0x07E6
// ============================================================================
/**
* @brief
* @note 20220x07E6
*/
message RobotLoadMapRequestData {
optional string map_name = 1; ///< -_
}
/**
* @brief
*/
message RobotLoadMapResult {
optional int32 ret_code = 1; ///< 0
optional string create_on = 2; ///<
optional string err_msg = 3; ///<
}
/**
* @brief
*/
message RobotLoadMapCommand {
message Request {
CommandHeader.Request header = 1;
RobotLoadMapRequestData data = 2;
}
message Feedback {
CommandHeader.Feedback header = 1;
RobotLoadMapResult status = 2;
}
}
// ============================================================================
// 11. 1022, 0x03FE
// ============================================================================
/**
* @brief
* @note 10220x03FEloadmap_status: 0=, 1=, 2=
* 2
*/
message RobotQueryLoadMapStatusResult {
optional int32 loadmap_status = 1; ///< 0=, 1=, 2=
optional int32 ret_code = 2; ///< 0
optional string create_on = 3; ///<
optional string err_msg = 4; ///<
}
/**
* @brief
*/
message RobotQueryLoadMapStatusCommand {
message Request {
CommandHeader.Request header = 1;
}
message Feedback {
CommandHeader.Feedback header = 1;
RobotQueryLoadMapStatusResult status = 2;
}
}
// ============================================================================
// 12. 1301, 0x0515
// ============================================================================
/**
* @brief
*/
message StationItem {
optional string id = 1; ///< ID
optional string type = 2; ///< "LocationMark", "ChargePoint"
optional double x = 3; ///< X
optional double y = 4; ///< Y
optional double r = 5; ///<
optional string desc = 6; ///<
optional string executor = 7; ///<
optional string prepoint = 8; ///< ID
optional string recfile = 9; ///<
optional bool spin = 10; ///<
optional bool use_down_pgv = 11; ///< 使 PGV
}
/**
* @brief
*/
message QueryStationListResult {
repeated StationItem stations = 1; ///<
optional int32 ret_code = 2; ///< 0
optional string create_on = 3; ///<
optional string err_msg = 4; ///<
}
/**
* @brief
*/
message QueryStationListCommand {
message Request {
CommandHeader.Request header = 1;
}
message Feedback {
CommandHeader.Feedback header = 1;
QueryStationListResult status = 2;
}
}
// ============================================================================
// 13. 3066, 0x0BFA
// ============================================================================
/**
* @brief
* @note 3066 move_task_list
* source_id id
*/
message MoveTaskItem {
optional string task_id = 1; ///< ID
optional string source_id = 2; ///< ID
optional string id = 3; ///< ID
optional string operation = 4; ///< "JackLoad"
optional double jack_height = 5; ///<
}
/**
* @brief
*/
message RobotGoTargetListRequestData {
repeated MoveTaskItem move_task_list = 1; ///<
}
/**
* @brief
* @note ret_code=0
*/
message RobotGoTargetListResult {
optional int32 ret_code = 1; ///< 0
optional string create_on = 2; ///<
optional string err_msg = 3; ///<
}
/**
* @brief
*/
message RobotGoTargetListCommand {
message Request {
CommandHeader.Request header = 1;
RobotGoTargetListRequestData data = 2;
}
message Feedback {
CommandHeader.Feedback header = 1;
RobotGoTargetListResult status = 2;
}
}
// ============================================================================
// 14. 1020, 0x03FC
// ============================================================================
/**
* @brief 1020
*/
message RobotStatusTaskReqData {
optional bool simple = 1; ///< true= task_statusfalse=
}
/**
* @brief
*/
message NavContainerItem {
optional string container_name = 1; ///<
optional string desc = 2; ///<
optional string goods_id = 3; ///< ID
optional bool has_goods = 4; ///<
}
/**
* @brief 1020
*/
message RobotStatusTaskResData {
optional int32 task_status = 1; ///< 0=NONE, 1=WAITING, 2=RUNNING, 3=SUSPENDED, 4=COMPLETED, 5=FAILED, 6=CANCELED
optional int32 task_type = 2; ///< 0=, 1=, 2=, 3=, 7=
optional string target_id = 3; ///< IDtask_type 2/3
repeated double target_point = 4; ///< [x, y, r]task_type 1
repeated string finished_path = 5; ///<
repeated string unfinished_path = 6; ///<
optional string move_status_info = 7; ///<
repeated NavContainerItem containers = 8; ///<
optional int32 ret_code = 9; ///< 0
optional string create_on = 10; ///<
optional string err_msg = 11; ///<
}
/**
* @brief
*/
message RobotStatusTaskCurrentCommand {
message Request {
CommandHeader.Request header = 1;
RobotStatusTaskReqData data = 2;
}
message Feedback {
CommandHeader.Feedback header = 1;
RobotStatusTaskResData data = 2;
}
}
// ============================================================================
// 15. 1110, 0x0456
// ============================================================================
/**
* @brief 1110
*/
message QueryTaskStatusPackageReqData {
repeated string task_ids = 1; ///< ID +
}
/**
* @brief
*/
message SingleTaskStatusItem {
optional string task_id = 1; ///< ID
optional int32 status = 2; ///< task_status
optional int32 type = 3; ///< task_type
}
/**
* @brief
*/
message TaskStatusPackage {
optional string closest_target = 1; ///< ID
optional string source_name = 2; ///<
optional string target_name = 3; ///<
optional double percentage = 4; ///< 0~100
optional double distance = 5; ///<
optional string info = 6; ///<
repeated SingleTaskStatusItem task_status_list = 7; ///<
}
/**
* @brief 1110
*/
message QueryTaskStatusPackageResData {
optional TaskStatusPackage task_status_package = 1; ///<
optional int32 ret_code = 2; ///< 0
optional string create_on = 3; ///<
optional string err_msg = 4; ///<
}
/**
* @brief
*/
message RobotStatusTaskPackageCommand {
message Request {
CommandHeader.Request header = 1;
QueryTaskStatusPackageReqData data = 2;
}
message Feedback {
CommandHeader.Feedback header = 1;
QueryTaskStatusPackageResData data = 2;
}
}
// ============================================================================
// 16. 3051, 0x0BEB
// ============================================================================
/**
* @brief DI
*/
message DIItem {
int32 id = 1; ///< DI
bool status = 2; ///< true=, false=
}
/**
* @brief DO
*/
message DOItem {
int32 id = 1; ///< DO
bool status = 2; ///< true=, false=
}
/**
* @brief
*/
message SoundArgs {
optional string name = 1; ///<
optional int32 loop = 2; ///< 0=, 1=
optional int32 stop = 3; ///< 1=
}
/**
* @brief WaitDI
*/
message WaitDIArgs {
repeated DIItem DI = 1; ///< DI
optional double timeout = 2; ///< 0
}
/**
* @brief SetDO
*/
message SetDOArgs {
repeated DOItem do_list = 1; ///< DO
}
/**
* @brief PGV
*/
message PgvParam {
optional bool use_pgv = 1; ///< 使 PGV
optional bool use_down_pgv = 2; ///< 使 PGV
optional double pgv_adjust_dist = 3; ///<
optional double pgv_adjust_cx = 4; ///< X
optional double pgv_adjust_cy = 5; ///< Y
optional double pgv_x_adjust = 6; ///< X
}
/**
* @brief +
*/
message FreeGoPoint {
double x = 1; ///< X
double y = 2; ///< Y
double theta = 3; ///<
}
/**
* @brief
*/
message ScriptArgs {
map<string, string> str_kv = 1; ///<
map<string, double> num_kv = 2; ///<
repeated DOItem do_list = 3; ///< DO
repeated DIItem di_list = 4; ///< DI
}
/**
* @brief 3051
* @warning /
*
* warning/error
* freego
*/
message RobotGoTargetReqData {
// -------- --------
string source_id = 1; ///< ID"SELF_POSITION"
string id = 2; ///< ID"SELF_POSITION" operation
optional string task_id = 3; ///< ID
// -------- --------
optional double angle = 4; ///<
optional string method = 5; ///< "forward" "backward"
optional double max_speed = 6; ///< 线m/s0 使
optional double max_wspeed = 7; ///< rad/s
optional double max_acc = 8; ///< m/s²
optional double max_wacc = 9; ///< rad/s²
optional int64 duration = 10; ///<
optional int32 orientation = 11; ///< 使
optional bool spin = 12; ///<
optional int64 delay = 13; ///< 0
optional int32 start_rot_dir = 14; ///< -1=, 0=, 1=
optional int32 end_rot_dir = 15; ///< -1=, 0=, 1=
optional double reach_dist = 16; ///<
optional double reach_angle = 17; ///<
optional string skill_name = 18; ///< "Action" "GotoSpecifiedPose"
// -------- PGV --------
optional PgvParam pgv = 19; ///<
// -------- --------
optional string operation = 20; ///< JackLoad/ForkUnload/RollerLoad/HookLoad/WaitDI/SetDO/sound/Script
optional double jack_height = 21; ///<
optional double start_height = 22; ///<
optional double end_height = 23; ///<
optional double fork_mid_height = 24; ///<
optional double fork_dist = 25; ///<
optional string direction = 26; ///< "left"/"right"/"front"/"back"
optional bool recognize = 27; ///<
optional string recfile = 28; ///< "shelf/s0002.shelf"
// -------- --------
optional SoundArgs sounds_args = 29; ///<
// -------- WaitDI / SetDO --------
optional WaitDIArgs wait_di_args = 30; ///< WaitDI
optional SetDOArgs set_do_args = 31; ///< SetDO
// -------- --------
optional string script_name = 32; ///<
optional ScriptArgs script_args = 33; ///<
optional int32 script_stage = 34; ///< 0=, 1=, 2=, 3=
// -------- GoByOdometer --------
optional double move_angle = 35; ///<
optional double speed_w = 36; ///< rad/s
optional int32 loc_mode = 37; ///< 1=, 0=
// -------- --------
optional FreeGoPoint freego = 38; ///< id
}
/**
* @brief 3051
*/
message RobotGoTargetResData {
optional int32 ret_code = 1; ///< 0
optional string create_on = 2; ///<
optional string err_msg = 3; ///<
}
/**
* @brief
*/
message RobotGoTargetCommand {
message Request {
CommandHeader.Request header = 1;
RobotGoTargetReqData data = 2;
}
message Feedback {
CommandHeader.Feedback header = 1;
RobotGoTargetResData data = 2;
}
}
// ============================================================================
// 17. 2000, 0x07D0
// ============================================================================
/**
* @brief
* @note 20000x07D0
*/
message RobotControlStopRequestData
{
}
/**
* @brief
*/
message RobotControlStopResult
{
optional int32 ret_code = 1; ///< 0
optional string create_on = 2; ///<
optional string err_msg = 3; ///<
}
/**
* @brief
*/
message RobotControlStopCommand
{
message Request
{
CommandHeader.Request header = 1;
RobotControlStopRequestData data = 2;
}
message Feedback
{
CommandHeader.Feedback header = 1;
RobotControlStopResult status = 2;
}
}
// ============================================================================
// 18. // 3001/3002/3003
// ============================================================================
/**
* @brief 3001, 0x0BB9
* @note
*/
message RobotTaskPauseCommand {
message Request {
CommandHeader.Request header = 1;
// data
}
message Feedback {
CommandHeader.Feedback header = 1;
/**
* @brief
*/
message Result {
optional int32 ret_code = 1; ///< 0
optional string create_on = 2; ///<
optional string err_msg = 3; ///<
}
Result status = 2;
}
}
/**
* @brief 3002, 0x0BBA
* @note
*/
message RobotTaskResumeCommand {
message Request {
CommandHeader.Request header = 1;
// data
}
message Feedback {
CommandHeader.Feedback header = 1;
/**
* @brief
*/
message Result {
optional int32 ret_code = 1; ///< 0
optional string create_on = 2; ///<
optional string err_msg = 3; ///<
}
Result status = 2;
}
}
/**
* @brief 3003, 0x0BBB
* @note
*/
message RobotTaskCancelCommand {
message Request {
CommandHeader.Request header = 1;
// data
}
message Feedback {
CommandHeader.Feedback header = 1;
/**
* @brief
*/
message Result {
optional int32 ret_code = 1; ///< 0
optional string create_on = 2; ///<
optional string err_msg = 3; ///<
}
Result status = 2;
}
}

View File

@ -0,0 +1,74 @@
/**
* @file agv_service.proto
* @brief AGV服务的gRPC接口AGV相关命令
* gRPCAGVServiceImpl
*/
syntax = "proto3";
import "cmvr/api/agv_command.proto";
package cmvr.api;
service AgvService {
//
rpc GetStatusInfo(GetAgvStatusInfoCommand.Request) returns (GetAgvStatusInfoCommand.Feedback);
rpc GetBatteryStatus(RobotStatusBatteryCommand.Request) returns (RobotStatusBatteryCommand.Feedback);
rpc GetRobotLocation(RobotStatusLocCommand.Request) returns (RobotStatusLocCommand.Feedback);
rpc RobotConfigDownloadMap(RobotConfigDownloadMapCommand.Request) returns (RobotConfigDownloadMapCommand.Feedback);
rpc GetMapStatus(RobotStatusMapCommand.Request) returns (RobotStatusMapCommand.Feedback);
rpc RobotConfigUploadMap(RobotConfigUploadMapCommand.Request) returns (RobotConfigUploadMapCommand.Feedback);
//
rpc RobotConfigLock(RobotConfigLockCommand.Request) returns (RobotConfigLockCommand.Feedback);
//
rpc GetCurrentLockStatus(RobotStatusCurrentLockCommand.Request) returns (RobotStatusCurrentLockCommand.Feedback);
//
rpc RobotMotionControl(RobotMotionControlCommand.Request) returns (RobotMotionControlCommand.Feedback);
// 2022
rpc RobotLoadMap(RobotLoadMapCommand.Request) returns (RobotLoadMapCommand.Feedback);
// 1022
rpc QueryLoadMapStatus(RobotQueryLoadMapStatusCommand.Request) returns (RobotQueryLoadMapStatusCommand.Feedback);
// 1301
rpc QueryStationList(QueryStationListCommand.Request) returns (QueryStationListCommand.Feedback);
// 3066
rpc RobotGoTargetList(RobotGoTargetListCommand.Request) returns (RobotGoTargetListCommand.Feedback);
// 1020 robot_status_task_req
// 1020 robot_status_task_req
rpc RobotStatusTaskCurrent(RobotStatusTaskCurrentCommand.Request) returns (RobotStatusTaskCurrentCommand.Feedback);
// 1110 robot_status_task_status_package_req
rpc RobotStatusTaskPackage(RobotStatusTaskPackageCommand.Request) returns (RobotStatusTaskPackageCommand.Feedback);
// 3051 robot_task_gotarget_req 0x0BEB
rpc RobotGoTarget(RobotGoTargetCommand.Request) returns (RobotGoTargetCommand.Feedback);
// 0x07D0
rpc RobotControlStop(RobotControlStopCommand.Request) returns (RobotControlStopCommand.Feedback);
// 3001 (0x0BB9)
rpc RobotTaskPause(RobotTaskPauseCommand.Request) returns (RobotTaskPauseCommand.Feedback);
// 3002 (0x0BBA)
rpc RobotTaskResume(RobotTaskResumeCommand.Request) returns (RobotTaskResumeCommand.Feedback);
// 3003 (0x0BBB)
rpc RobotTaskCancel(RobotTaskCancelCommand.Request) returns (RobotTaskCancelCommand.Feedback);
}

View File

@ -0,0 +1,43 @@
syntax = "proto3";
package cmvr.api;
import "google/protobuf/timestamp.proto";
message DeviceLifecycle {
enum Lifecycle {
STATE_INIT = 0;
STATE_READY = 1;
STATE_RUNNING = 2;
STATE_ERROR = 3;
STATE_ESTOP = 4;
STATE_STOP = 5;
}
Lifecycle state = 1;
}
message CommandHeader {
message Request {
string device_id = 1; //
google.protobuf.Timestamp timestamp = 2; //
}
message Feedback {
bool success = 1; //
string error_message = 2; //
google.protobuf.Timestamp timestamp = 3; //
}
}
message ConfigParam {
string param_name = 1;
oneof param_value {
int32 int_value = 2; //
double double_value = 3; //
string string_value = 4; //
bool bool_value = 5; //
bytes bytes_value = 6; //
}
}

12
src/App.vue Normal file
View File

@ -0,0 +1,12 @@
<template>
<router-view />
</template>
<script setup>
// useRouter / useRoute
const router = useRouter()
</script>
<style lang="scss" scoped>
</style>

35
src/api/agv.js Normal file
View File

@ -0,0 +1,35 @@
const fetchFn = async (url, params = {}, method = 'GET', headers = { 'Content-Type': 'application/json' }) => {
const res = await fetch(url, {
method: method,
headers: headers,
body: JSON.stringify(params)
})
const data = await res.json()
return data
}
/**
* 机器人前往目标站点
* @param {Object} params
* @param {string} [params.deviceId] - 设备ID默认 agv_src1100
*/
export function getCurrentMapName(params) {
return fetchFn('/api/agv/getCurrentMapName', params, 'POST')
}
export function getCurrentMap(params) {
return fetchFn('/api/agv/getCurrentMap', params, 'POST')
}
export function getRobotState(params) {
return fetchFn('/api/agv/getRobotState', params, 'POST')
}
export function moveRobot(params) {
return fetchFn('/api/agv/moveRobot', params, 'POST')
}
export function stopRobot(params) {
return fetchFn('/api/agv/stopRobot', params, 'POST')
}

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,9 @@
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16.000000" height="16.000000" fill="none" customFrame="#000000">
<rect id="locate-fixed" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<line id="直线 6" x1="1.33337402" x2="3.33337402" y1="8" y2="8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333333" />
<line id="直线 7" x1="12.666626" x2="14.666626" y1="8" y2="8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333333" />
<line id="直线 8" x1="0" x2="2" y1="0" y2="0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333333" transform="matrix(0,1,-1,0,8,1.33331)" />
<line id="直线 9" x1="0" x2="2" y1="0" y2="0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333333" transform="matrix(0,1,-1,0,8,12.6667)" />
<circle id="椭圆 4" cx="8.00004101" cy="8.00001049" r="4.66666698" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333333" />
<circle id="椭圆 5" cx="8" cy="8" r="2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333333" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1,7 @@
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16.000000" height="16.000000" fill="none" customFrame="#000000">
<rect id="zoom-in" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<circle id="椭圆 2" cx="7.33333349" cy="7.33333349" r="5.33333349" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333333" />
<line id="直线 1" x1="0" x2="4.10121965" y1="0" y2="0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333333" transform="matrix(-0.707107,-0.707107,0.707107,-0.707107,14,14)" />
<line id="直线 2" x1="0" x2="4" y1="0" y2="0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333333" transform="matrix(0,1,-1,0,7.33337,5.33334)" />
<line id="直线 3" x1="5.33337402" x2="9.33337402" y1="7.33334351" y2="7.33334351" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333333" />
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,6 @@
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16.000000" height="16.000000" fill="none" customFrame="#000000">
<rect id="zoom-out" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<circle id="椭圆 3" cx="7.33333349" cy="7.33333349" r="5.33333349" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333333" />
<line id="直线 4" x1="0" x2="4.10121965" y1="0" y2="0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333333" transform="matrix(-0.707107,-0.707107,0.707107,-0.707107,14,14)" />
<line id="直线 5" x1="5.33337402" x2="9.33337402" y1="7.33334351" y2="7.33334351" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333333" />
</svg>

After

Width:  |  Height:  |  Size: 835 B

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

16
src/main.js Normal file
View File

@ -0,0 +1,16 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from '@/router'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
// 创建pinia实例
const pinia = createPinia()
// 持久化插件
pinia.use(piniaPluginPersistedstate)
createApp(App)
.use(pinia)
.use(router)
.mount('#app')

22
src/router/index.js Normal file
View File

@ -0,0 +1,22 @@
import { createRouter, createWebHistory } from 'vue-router'
// 页面组件
import Home from '@/views/Home.vue'
const routes = [
{
path: '/',
redirect: '/home'
},
{
path: '/home',
name: 'Home',
component: Home
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
export default router

41
src/stores/robot.js Normal file
View File

@ -0,0 +1,41 @@
import { defineStore } from 'pinia'
// 唯一idrobot
export const useRobotStore = defineStore('robot', {
state: () => ({
ip: '192.168.0.100:50052',
position: { x: 0, y: 0, angle: 0 },
battery: 100,
status: 'idle'
}),
getters: {
// 获取机器人坐标
getPos(state) {
return `X:${state.position.x} Y:${state.position.y}`
},
isLowPower(state) {
return state.battery < 20
}
},
actions: {
setIp(str) {
this.ip = str
},
// 更新机器人位置
setPosition(x, y, angle) {
this.position.x = x
this.position.y = y
this.position.angle = angle
},
// 修改状态
setStatus(val) {
this.status = val
},
// 重置仓库
resetRobot() {
this.$reset()
}
},
// 开启本地存储
persist: true
})

9
src/style.css Normal file
View File

@ -0,0 +1,9 @@
* {
padding: 0;
margin: 0;
}
html,body, #app {
width: 100%;
height: 100%;
}

42
src/styles/vars.scss Normal file
View File

@ -0,0 +1,42 @@
// 全局自定义变量
$primary: #409eff;
$text: #c0cfdf;
$text-dim: #475a72;
$text-bright:#e8f0f8;
$text-gray: #666;
$bg-deep: #060a12;
$bg-body: #0a0f1a;
$bg-card: #0f1628;
$bg-card-hi: #141d32;
$border: rgba(0,195,255,0.08);
$border-hi: rgba(0,195,255,0.22);
$accent: #00c3ff;
$accent-dim: rgba(0,195,255,0.14);
$danger: #ff2d55;
$success: #00e676;
$font-h: 'Rajdhani', sans-serif;
$font-b: 'Noto Sans SC', 'PingFang SC', sans-serif;
$font-m: 'JetBrains Mono', monospace;
$radius: 10px;
$topbar-bg: #1e2a3a;
$topbar-text: #a8b2c7;
$transition: 0.25s ease;
$shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
$text-secondary: #606266;
$border-light: #f0f2f5;
$text-primary: #2c3e50;
$text-muted: #909399;
$radius-sm: 6px;
$primary-dark: #337ecc;
$border-color: #e4e7ed;
// 自定义混合
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}

189
src/views/Home.vue Normal file
View File

@ -0,0 +1,189 @@
<template>
<div class="common-layout">
<el-container>
<el-header>
<div class="header-container">
<div class="left">
<div class="date">
<div>{{ currentTime }}</div>
<div class="week">{{ weekText }}</div>
</div>
<div class="binary-strip">{{ binaryText }}</div>
</div>
<div class="application-title">两江鱼复智能网联新能源汽车产业基地上位机</div>
<div class="right">
<div class="binary-strip">{{ binaryText }}</div>
<div class="btn-container">
<button class="nav-btn" :class="{ active: activePage === 'chassis' }" @click="handleBtn('chassis')">底盘控制</button>
<button class="nav-btn" :class="{ active: activePage === 'mechanicalArm' }" @click="handleBtn('mechanicalArm')">机械臂控制</button>
<button class="nav-btn" :class="{ active: activePage === 'manualTakeover' }" @click="handleBtn('manualTakeover')">相机</button>
</div>
</div>
</div>
</el-header>
<el-main>
<Chassis v-if="activePage === 'chassis'" />
<MechanicalArm v-if="activePage === 'mechanicalArm'" />
</el-main>
</el-container>
</div>
</template>
<script setup>
import Chassis from './chassis/Chassis.vue';
import MechanicalArm from './MechanicalArm.vue';
const currentTime = ref('')
const weekText = ref('')
const binaryText = ref('13513034532438213')
const weekNames = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六'];
const updateClock = () => {
const now = new Date();
const y = now.getFullYear();
const mo = String(now.getMonth()+1).padStart(2,'0');
const d = String(now.getDate()).padStart(2,'0');
const h = String(now.getHours()).padStart(2,'0');
const mi = String(now.getMinutes()).padStart(2,'0');
const s = String(now.getSeconds()).padStart(2,'0');
currentTime.value = `${y}-${mo}-${d} ${h}:${mi}:${s}`
weekText.value = weekNames[now.getDay()]
}
const genBinary = (len) => {
let str = '';
for (let i = 0; i < len; i++) str += Math.round(Math.random()).toString();
return str;
}
const animateBinary = () => {
binaryText.value = genBinary(30)
}
const activePage = ref('chassis')
const handleBtn = (page) => {
activePage.value = page
emits("activePage", page);
}
let clockTimer, binaryTimer;
onMounted(() => {
updateClock();
clockTimer = setInterval(updateClock, 1000);
animateBinary()
binaryTimer = setInterval(animateBinary, 400);
})
onUnmounted(() => {
clearInterval(clockTimer)
clearInterval(binaryTimer)
})
</script>
<style lang="scss" scoped>
.common-layout {
width: 100%;
height: 100%;
.el-container {
width: 100%;
height: 100%;
background-color: #000d2e;
.el-header {
border-bottom: 1px solid #00c2ff4d;
}
.header-container {
height: 100%;
display: flex;
gap: 20px;
margin: 0 20px;
.left {
flex: 1;
display: flex;
justify-content: space-evenly;
.date {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
color: #00c2ff;
font-size: 16px;
font-weight: 600;
letter-spacing: 2px;
.week {
font-size: 18px;
}
}
}
.application-title {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
font-weight: 700;
text-align: center;
color: #00c2ff;
letter-spacing: 4px;
white-space: nowrap;
}
.right {
flex: 1;
display: flex;
justify-content: space-evenly;
.btn-container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px ;
.nav-btn {
background: rgba(0, 60, 140, 0.5);
border: 1px solid rgba(0, 194, 255, 0.35);
color: #a0d8f0;
padding: 6px 20px;
border-radius: 3px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.active {
background: rgba(0, 120, 220, 0.7);
border-color: #00c2ff;
color: #fff;
box-shadow: 0 0 10px rgba(0, 194, 255, 0.5);
}
}
}
.binary-strip {
display: flex;
align-items: center;
justify-content: center;
font-family: 'Orbitron', monospace;
font-size: 10px;
color: rgba(0, 194, 255, 0.3);
letter-spacing: 2px;
white-space: nowrap;
overflow: hidden;
}
}
.el-main {
padding: 0;
}
}
}
</style>

385
src/views/MechanicalArm.vue Normal file
View File

@ -0,0 +1,385 @@
<template>
<div class="robot-teach-container">
<!-- 主体内容区 -->
<div class="main-wrap">
<!-- 左侧数据面板 -->
<div class="left-panel">
<!-- TCP 笛卡尔坐标 -->
<div class="panel-card">
<div class="row dual-row">
<div class="coord-item">
<span class="key">X</span>
<el-input v-model.number="tcp.x" suffix="mm" />
</div>
<div class="coord-item">
<span class="key">RX</span>
<el-input v-model.number="tcp.rx" suffix="°" />
</div>
</div>
<div class="row dual-row">
<div class="coord-item">
<span class="key">Y</span>
<el-input v-model.number="tcp.y" suffix="mm" />
</div>
<div class="coord-item">
<span class="key">RY</span>
<el-input v-model.number="tcp.ry" suffix="°" />
</div>
</div>
<div class="row dual-row">
<div class="coord-item">
<span class="key">Z</span>
<el-input v-model.number="tcp.z" suffix="mm" />
</div>
<div class="coord-item">
<span class="key">RZ</span>
<el-input v-model.number="tcp.rz" suffix="°" />
</div>
</div>
</div>
<!-- 关节 J1-J6 -->
<div class="panel-card joint-card">
<div class="joint-row" v-for="(item, idx) in jointList" :key="idx">
<span class="joint-key">{{ item.name }}</span>
<el-input v-model.number="item.val" />
<div class="btn-group">
<el-button size="small">-</el-button>
<div class="range-tip">{{ item.range }}</div>
<el-button size="small">+</el-button>
</div>
</div>
</div>
<!-- 速度滑块 -->
<div class="speed-bar">
<span class="label">速度</span>
<el-slider v-model="speed" :min="1" :max="100" />
<div class="speed-num">
<el-button size="small">-</el-button>
<span>{{ speed }} %</span>
<el-button size="small">+</el-button>
</div>
</div>
</div>
<!-- 中间3D机械臂空白区域 -->
<div class="center-view">
<div class="robot-3d-box">
<!-- 此处后续接入Three.js/URDF机械臂模型当前留白 -->
<div class="axis-line x-axis"></div>
<div class="axis-line y-axis"></div>
<div class="axis-line z-axis"></div>
<div class="empty-tip">机械臂3D可视化区域</div>
</div>
<!-- 底部功能按钮 -->
<div class="center-bottom-btns">
<el-button type="success" size="large">零力示教</el-button>
<el-button size="large">回到原点</el-button>
<el-button size="large">Z轴对齐</el-button>
</div>
</div>
<!-- 右侧摇杆操作面板 -->
<div class="right-panel">
<!-- Z轴加减 -->
<div class="z-ctrl">
<el-button circle>+</el-button>
<el-button circle>Z</el-button>
<el-button circle>-</el-button>
</div>
<!-- XY方向摇杆 -->
<div class="joystick">
<div class="joy-top">+X</div>
<div class="joy-row">
<div class="joy-left">-Y</div>
<div class="joy-center"></div>
<div class="joy-right">+Y</div>
</div>
<div class="joy-bottom">-X</div>
</div>
<!-- RZ旋转 -->
<div class="rz-ctrl">
<el-button circle>&lt;-</el-button>
<el-button circle>RZ</el-button>
<el-button circle>&gt;+</el-button>
</div>
<!-- RX RY摇杆 -->
<div class="joystick">
<div class="joy-top">+RX</div>
<div class="joy-row">
<div class="joy-left">-RY</div>
<div class="joy-center"></div>
<div class="joy-right">+RY</div>
</div>
<div class="joy-bottom">-RX</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
//
const coordMode = ref('TCP')
const baseCoord = ref('Base')
const useTool = ref(false)
const stepMode = ref('continuous')
// TCP
const tcp = ref({
x: 46.691,
y: 385.321,
z: 141.877,
rx: -174.595,
ry: 4.212,
rz: -72.937
})
// 6
const jointList = ref([
{ name: 'J1', val: 84.605, range: '-360°~360°' },
{ name: 'J2', val: -7.826, range: '-135°~135°' },
{ name: 'J3', val: 136.306, range: '-153°~153°' },
{ name: 'J4', val: -6.773, range: '-360°~360°' },
{ name: 'J5', val: 30.087, range: '-180°~180°' },
{ name: 'J6', val: -16.567, range: '-360°~360°' },
])
//
const speed = ref(56)
</script>
<style lang="scss" scoped>
.robot-teach-container {
height: 100%;
background: $bg-deep;
color: $text;
font-family: $font-b;
overflow: hidden;
user-select: none;
//
.main-wrap {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: 320px 1fr 320px;
gap: 16px;
overflow: hidden;
}
//
.left-panel {
padding: 10px;
display: flex;
flex-direction: column;
gap: 20px;
overflow-y: auto;
background: $bg-body;
.panel-card {
margin-top: 20px;
background: $bg-card;
border-radius: 8px;
padding: 14px;
box-shadow: $shadow;
.row {
display: flex;
margin-bottom: 12px;
gap: 10px;
&.dual-row {
.coord-item {
flex: 1;
}
}
.coord-item {
display: flex;
align-items: center;
gap: 8px;
.key {
width: 32px;
font-weight: bold;
font-size: 16px;
}
:deep(.el-input__inner) {
background: #2d323e;
color: #fff;
}
}
}
}
.joint-card {
.joint-row {
display: flex;
align-items: center;
margin-bottom: 10px;
gap: 8px;
.joint-key {
width: 32px;
font-weight: bold;
}
:deep(.el-input) {
width: 80px;
}
:deep(.el-input__inner) {
background: #2d323e;
border: 1px solid $border-color;
color: #fff;
}
.btn-group {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
.range-tip {
font-size: 12px;
color: $text-gray;
}
}
}
}
.speed-bar {
background: $bg-card;
border-radius: 8px;
padding: 14px;
box-shadow: $shadow;
display: flex;
align-items: center;
gap: 12px;
.speed-num {
display: flex;
align-items: center;
gap: 6px;
}
}
}
// 3D
.center-view {
display: flex;
flex-direction: column;
gap: 16px;
.robot-3d-box {
flex: 1;
background: $bg-card;
border-radius: 8px;
box-shadow: $shadow;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
.empty-tip {
color: $text-gray;
font-size: 14px;
}
// 线
.axis-line {
position: absolute;
&.x-axis {
width: 200px;
height: 2px;
background: #f56c6c;
left: 50%;
top: 60%;
transform: translateX(-50%);
}
&.y-axis {
width: 200px;
height: 2px;
background: #67c23a;
left: 50%;
top: 60%;
transform: translateX(-50%);
}
&.z-axis {
width: 2px;
height: 220px;
background: #409eff;
left: 50%;
top: 10%;
}
}
}
.center-bottom-btns {
display: flex;
gap: 12px;
justify-content: center;
}
}
//
.right-panel {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
.z-ctrl, .rz-ctrl {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.joystick {
width: 140px;
height: 140px;
background: $bg-card;
border-radius: 8px;
box-shadow: 0 0 0 1px $shadow;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 10px;
.joy-top, .joy-bottom {
color: $primary;
font-weight: bold;
}
.joy-row {
width: 100%;
display: flex;
justify-content: space-between;
.joy-left { color: #67c23a; }
.joy-right { color: #f56c6c; }
.joy-center {
width: 30px;
height: 30px;
border-radius: 50%;
background: #333842;
}
}
}
.point-btns {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
}
}
// element-plus
:deep(.el-input__wrapper) {
background-color: #2d323e;
box-shadow: $shadow;
.el-input__inner {
color: #fff;
}
}
:deep(.el-slider__runway) {
background: #333842;
}
</style>

View File

@ -0,0 +1,556 @@
<template>
<div class="chassis-control">
<div class="main-wrap">
<section class="map-area">
<div class="area-bar">
<span class="area-bar__title">
<svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" class="area-bar__icon">
<polygon points="1 4.5 1 15 6 12.5 12 15 17 12.5 17 1.5 12 4 6 1.5" />
<line x1="6" y1="1.5" x2="6" y2="12.5" />
<line x1="12" y1="4" x2="12" y2="15" />
</svg>
MAP VIEW
</span>
<div class="map-controller">
<div class="btn-container">
<el-icon :size="16" color="#00D4FF" @click="zoomCanvas(true)"><zoom-out /></el-icon>
</div>
<div class="btn-container">
<el-icon :size="16" color="#00D4FF" @click="zoomCanvas(false)"><zoom-in /></el-icon>
</div>
<div class="btn-container">
<!-- @click="centerCanvasView" -->
<el-icon :size="16" color="#00D4FF" ><Location /></el-icon>
</div>
</div>
</div>
<div class="map-body">
<!-- <canvas ref="mapCanvas" /> -->
<MapCanvas :mapName="mapName" :isDragging="!isDragging" />
</div>
</section>
<aside class="panel">
<!-- Direction -->
<div class="card">
<div class="card__head">
<h3>方向控制</h3>
<el-tag size="small" effect="dark" type="info">长按持续移动</el-tag>
</div>
<div class="dpad">
<button class="dpad__btn dpad__btn--up"
@mousedown="onPress('forward')" @mouseup="onRelease" @mouseleave="onRelease"
@touchstart.prevent="onPress('forward')" @touchend.prevent="onRelease">
<svg viewBox="0 0 14 14"><path d="M7 1L13 11H1Z" fill="currentColor" /></svg>
<small>前进</small>
</button>
<button class="dpad__btn dpad__btn--lt"
@mousedown="onPress('left')" @mouseup="onRelease"
@touchstart.prevent="onPress('left')" @touchend.prevent="onRelease">
<svg viewBox="0 0 14 14"><path d="M1 7L11 1V13Z" fill="currentColor" /></svg>
<small>左转</small>
</button>
<div class="dpad__core"><span /></div>
<button class="dpad__btn dpad__btn--rt"
@mousedown="onPress('right')" @mouseup="onRelease"
@touchstart.prevent="onPress('right')" @touchend.prevent="onRelease">
<svg viewBox="0 0 14 14"><path d="M13 7L3 1V13Z" fill="currentColor" /></svg>
<small>右转</small>
</button>
<button class="dpad__btn dpad__btn--dn"
@mousedown="onPress('backward')" @mouseup="onRelease"
@touchstart.prevent="onPress('backward')" @touchend.prevent="onRelease">
<svg viewBox="0 0 14 14"><path d="M7 13L1 3H13Z" fill="currentColor" /></svg>
<small>后退</small>
</button>
</div>
</div>
<!-- Parameters -->
<div class="card">
<div class="card__head"><h3>参数设置</h3></div>
<div class="param" v-for="p in paramList" :key="p.key">
<div class="param__head">
<span>{{ p.label }}</span>
<b class="param__val">{{ formatVal(p) }}</b>
</div>
<el-slider
v-model="p.model"
:min="p.min" :max="p.max" :step="p.step"
:show-tooltip="false"
/>
</div>
</div>
<div class="card">
<el-switch
v-model="isDragging"
size="large"
inline-prompt
active-text="开启地图选点"
inactive-text="关闭地图选点"
/>
<div v-if="isDragging">
<el-row :gutter="20">
<el-col :span="6">X:</el-col>
<el-col :span="18"><el-input /></el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="6">Y:</el-col>
<el-col :span="18"><el-input /></el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="6">Angle:</el-col>
<el-col :span="18"><el-input /></el-col>
</el-row>
<el-button type="danger" round size="large" class="estop" plain>
紧急停止
</el-button>
</div>
</div>
</aside>
</div>
<footer class="status-bar">
<div class="chip" v-for="s in statusItems" :key="s.label">
<span class="chip__label">{{ s.label }}</span>
<span class="chip__val" :class="{ 'chip__val--ok': s.ok }">{{ s.value }}</span>
</div>
</footer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { ZoomIn, ZoomOut, Location } from '@element-plus/icons-vue'
import { useRobotStore } from '@/stores/robot'
import { getCurrentMapName, moveRobot, stopRobot } from '@/api/agv.js'
import MapCanvas from './MapCanvas.vue'
const VITE_DEVICE_ID = import.meta.env.VITE_DEVICE_ID
const robotStore = useRobotStore()
const maxSpeed = ref(0.6)
const rotationAngle = ref(0)
const paramList = reactive([
{ key: 'speed', label: '速度', model: maxSpeed, min: 0, max: 1, step: 0.1, unit: 'm/s', digits: 1 },
{ key: 'rot', label: '旋转角度', model: rotationAngle, min: -1, max: 1, step: 0.1, unit: 'm/s', digits: 0 },
])
const statusItems = [
{ label: '位置', value: `${robotStore.position.x},${robotStore.position.y}` },
{ label: '航向', value: robotStore.position.angle },
{ label: '电量', value: `${robotStore.battery * 100} %`, ok: true },
]
function formatVal(p) {
const v = typeof p.model === 'object' ? p.model.value : p.model
return (typeof v === 'number' ? v.toFixed(p.digits) : v) + ' ' + p.unit
}
const isDragging = ref(false)
const moveRobotData = ref({
vx: 0,
vy: 0,
w: 0,
duration: -1
})
/* long-press stubs */
const onPress = async (_dir) => {
if (_dir === 'forward') {
moveRobotData.value.vx = maxSpeed.value
} else if (_dir === 'backward') {
moveRobotData.value.vx = -maxSpeed.value
} else if (_dir === 'left') {
moveRobotData.value.vy = maxSpeed.value
} else {
moveRobotData.value.vy = -maxSpeed.value
}
moveRobotData.value.w = rotationAngle.value
const res = await moveRobot({
ip: robotStore.ip,
deviceId: VITE_DEVICE_ID,
...moveRobotData.value
})
}
function onRelease() {
moveRobotData.value.vx = 0;
moveRobotData.value.vy = 0;
stopRobot({
ip: robotStore.ip,
deviceId: VITE_DEVICE_ID
})
}
const mapName = ref('')
const getRobotCurrentMapName = async () => {
const result = await getCurrentMapName({
ip: robotStore.ip,
deviceId: VITE_DEVICE_ID,
})
if (result.code === 200) {
mapName.value = result.data.current_map
}
}
onMounted(() => {
getRobotCurrentMapName()
})
onBeforeUnmount(() => { })
</script>
<style lang="scss" scoped>
.chassis-control {
display: flex;
flex-direction: column;
height: 100%;
background: $bg-deep;
color: $text;
font-family: $font-b;
overflow: hidden;
user-select: none;
.main-wrap {
flex: 1;
display: flex;
min-height: 0;
gap: 0;
.map-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid $border;
.area-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 16px;
background: $bg-card;
border-bottom: 1px solid $border;
flex-shrink: 0;
&__title {
display: flex;
align-items: center;
gap: 8px;
font-family: $font-h;
font-size: 13px;
font-weight: 700;
letter-spacing: 1.5px;
color: $text-dim;
text-transform: uppercase;
}
&__icon {
width: 16px;
height: 16px;
}
&__actions {
display: flex;
gap: 4px;
}
.map-controller {
display: flex;
align-items: center;
gap: 12px;
.btn-container {
width: 32px;
height: 32px;
border-radius: 8px;
background: #1E3A5F;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
&:hover {
border-color: #00D4FF;
cursor: pointer;
}
}
}
:deep(.el-button) {
font-size: 12px;
color: $text-dim;
border-color: $border;
background: transparent;
&:hover {
color: $accent;
border-color: $border-hi;
}
}
}
.map-body {
flex: 1;
position: relative;
overflow: hidden;
background: $bg-deep;
// canvas {
// position: absolute;
// inset: 0;
// }
}
}
.panel {
width: 360px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
background: $bg-body;
.card {
padding: 18px 20px;
border-bottom: 1px solid $border;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
font-family: $font-h;
font-size: 14px;
font-weight: 700;
letter-spacing: 1.2px;
color: $text-dim;
margin: 0;
text-transform: uppercase;
}
}
:deep(.el-tag) {
font-size: 10px;
height: 20px;
background: rgba(0,195,255,0.08);
border-color: $border;
color: $accent;
}
.dpad {
display: grid;
grid-template-columns: repeat(3, 76px);
grid-template-rows: repeat(3, 64px);
gap: 6px;
justify-content: center;
margin-bottom: 18px;
&__btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
border: 1px solid $border;
border-radius: 8px;
background: $bg-card;
color: $text;
cursor: pointer;
transition: all .15s ease;
svg {
width: 14px;
height: 14px;
}
small {
font-size: 10px;
letter-spacing: .5px;
color: $text-dim;
}
&:hover {
border-color: $border-hi;
background: $bg-card-hi;
color: $accent;
}
&:active {
background: $accent;
color: $bg-deep;
border-color: $accent;
box-shadow: 0 0 24px $accent-dim;
transform: scale(.94);
small {
color: $bg-deep;
}
}
&--up {
grid-column: 2;
grid-row: 1;
}
&--lt {
grid-column: 1;
grid-row: 2;
}
&--rt {
grid-column: 3;
grid-row: 2;
}
&--dn {
grid-column: 2;
grid-row: 3;
}
}
&__core {
grid-column: 2;
grid-row: 2;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid $border;
border-radius: 8px;
background: rgba(0,195,255,0.03);
span {
width: 10px;
height: 10px;
border-radius: 50%;
background: $accent;
opacity: .3;
}
}
}
.estop {
width: 100%;
height: 42px !important;
font-size: 14px;
font-weight: 600;
letter-spacing: 1px;
border-color: $danger !important;
color: $danger !important;
background: rgba(255,45,85,0.06) !important;
&:hover {
background: rgba(255,45,85,0.14) !important;
}
}
.param {
margin-bottom: 18px;
&:last-child {
margin-bottom: 0;
}
&__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
font-size: 13px;
color: $text-dim;
}
&__val {
font-family: $font-m;
font-size: 13px;
font-weight: 500;
color: $accent;
}
:deep(.el-slider) {
.el-slider__runway {
height: 4px;
background: rgba(255,255,255,0.05);
border-radius: 2px;
}
.el-slider__bar {
height: 4px;
background: $accent;
border-radius: 2px;
}
.el-slider__button-wrapper {
top: -16px;
}
.el-slider__button {
width: 14px;
height: 14px;
border: 2px solid $accent;
background: $bg-deep;
box-shadow: 0 0 8px $accent-dim;
transition: box-shadow .2s;
&:hover {
box-shadow: 0 0 14px rgba(0,195,255,0.35);
}
}
}
}
}
}
}
.status-bar {
display: flex;
align-items: center;
gap: 16px;
height: 38px;
padding: 0 24px;
background: $bg-card;
border-top: 1px solid $border;
flex-shrink: 0;
.chip {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 12px;
border-radius: 4px;
background: rgba(255,255,255,0.02);
border: 1px solid $border;
&__label {
font-size: 10px;
color: $text-dim;
letter-spacing: .5px;
}
&__val {
font-family: $font-m;
font-size: 11px;
color: $text-bright;
font-weight: 500;
&--ok {
color: $success;
}
}
}
}
}
</style>

View File

@ -0,0 +1,491 @@
<template>
<div class="container">
<canvas id="map-canvas"></canvas>
<div id="tooltip">
<div class="tooltip-title"></div>
<div class="tooltip-detail"></div>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, watch } from 'vue';
import {
worldToScreen,
screenToWorld,
drawGrid,
drawGridlines,
drawAdvancedAreaList,
drawAdvancedLineList,
drawNormalLines,
drawAdvancedCurveList,
drawAdvancedPointList,
drawMapBoundary,
buildOccupancyGridImage,
MobileRobot
} from './canvasUtils';
import { getCurrentMap, getRobotState } from '@/api/agv.js'
import { ElMessage } from 'element-plus';
import { useRobotStore } from '@/stores/robot'
const props = defineProps({
mapName: {
type: String,
default: ''
},
isDragging: {
type: Boolean,
default: true
}
})
const VITE_DEVICE_ID = import.meta.env.VITE_DEVICE_ID
const robotStore = useRobotStore()
/**
* 应用全局状态对象集中管理所有可变数据
* - parsedMap: 解析后的地图数据结构
* - layerVisibility: 各图层的可见性开关
* - mouseState: 鼠标位置与拖拽状态
* - isMapLoaded: 地图是否已加载完成
*/
const applicationState = {
parsedMap: null, //
gridOffscreen: null, // Canvas
hoveredElement: null, // {kind, index}
animationTimeSeconds: 0, //
camera: { //
centerX: 0, // X
centerY: 0, // Y
pixelsPerMeter: 20, //
},
canvasWidthPx: 0, // Canvas CSS
canvasHeightPx: 0, // Canvas CSS
devicePixelRatio: 1, // DPI > 1
mouseState: { //
screenX: 0, // Canvas X
screenY: 0, // Canvas Y
worldX: 0, // X
worldY: 0, // Y
isDragging: false, //
},
isMapLoaded: false, // ,
layerVisibility: { //
grid: true, //
edges: true, //
gridlines: true, // 1线
normalLines: true, // normalLineList
advLines: true, // advancedLineList
curves: true, // advancedCurveList
points: true, // advancedPointList
areas: true, // advancedAreaList,
robots: true
},
robots: [] //
};
let mapCanvas; // DOM
let canvasCtx; // 2D
const init = () => {
mapCanvas = document.getElementById('map-canvas');
canvasCtx = mapCanvas.getContext('2d');
}
/**
* 根据容器尺寸和设备像素比调整 Canvas 大小
* 在窗口 resize 时自动调用
*/
function resizeCanvasToContainer() {
const containerRect = mapCanvas.parentElement.getBoundingClientRect(); //
devicePixelRatio = window.devicePixelRatio || 1; //
applicationState.canvasWidthPx = containerRect.width; //
applicationState.canvasHeightPx = containerRect.height; // = -
mapCanvas.width = applicationState.canvasWidthPx * devicePixelRatio; // Canvas
mapCanvas.height = applicationState.canvasHeightPx * devicePixelRatio; // Canvas
mapCanvas.style.width = applicationState.canvasWidthPx + 'px'; // CSS
mapCanvas.style.height = applicationState.canvasHeightPx + 'px'; // CSS
}
/**
* 解析 SEER smap JSON 文件为内部地图数据结构
*
* @param {Object} rawJson - 原始 JSON 解析后的对象
* @returns {Object} 解析后的统一地图数据结构
*/
function parseSmapJson(rawJson) {
// header
const header = rawJson.header || rawJson; // header
const minimumPos = header.minPos || { x: 0, y: 0 }; //
const maximumPos = header.maxPos || { x: 1, y: 1 }; //
const resolution = header.resolution || 0.05; //
//
const worldWidth = maximumPos.x - minimumPos.x; //
const worldHeight = maximumPos.y - minimumPos.y; //
const gridWidthPx = Math.ceil(worldWidth / resolution) + 1; //
const gridHeightPx = Math.ceil(worldHeight / resolution) + 1;//
//
const parsedMap = {
name: header.mapName || 'Untitled', //
version: header.version || '', //
mapType: header.mapType || '', // "2D-Map"
resolution: resolution, // /
originX: minimumPos.x, // X
originY: minimumPos.y, // Y
maxX: maximumPos.x, // X
maxY: maximumPos.y, // Y
gridWidth: gridWidthPx, //
gridHeight: gridHeightPx, //
// normalPosList:
normalPositions: rawJson.normalPosList || [],
// normalLineList: {startPos:{x,y}, endPos:{x,y}}
normalLines: rawJson.normalLineList || [],
// advancedPointList: {className, instanceName, pos:{x,y}, dir, desc, property:[]}
advancedPoints: rawJson.advancedPointList || [],
// advancedLineList: {className, instanceName, line:{startPos, endPos}}
advancedLines: rawJson.advancedLineList || [],
// advancedCurveList: {className, instanceName, controlPos1, controlPos2, startPos, endPos}
advancedCurves: rawJson.advancedCurveList || [],
// advancedAreaList: {className, instanceName, dir, posGroup:[], attribute:{}}
advancedAreas: rawJson.advancedAreaList || [],
};
return parsedMap;
}
/**
* 加载地图到应用构建栅格图像设置相机更新UI
* @param {Object} parsedMap - parseSmapJson 的输出
*/
async function loadMapIntoApplication(parsedMap) {
await new Promise(resolve => setTimeout(resolve, 30)); // UI
//
applicationState.parsedMap = parsedMap;
//
applicationState.gridOffscreen = buildOccupancyGridImage(parsedMap, applicationState.layerVisibility.edges, applicationState);
//
applicationState.camera.centerX = (parsedMap.originX + parsedMap.maxX) / 2; //
applicationState.camera.centerY = (parsedMap.originY + parsedMap.maxY) / 2; //
// 使
const worldWidthMeters = parsedMap.maxX - parsedMap.originX; //
const worldHeightMeters = parsedMap.maxY - parsedMap.originY; //
const fitScaleX = applicationState.canvasWidthPx / worldWidthMeters; //
const fitScaleY = applicationState.canvasHeightPx / worldHeightMeters; //
applicationState.camera.pixelsPerMeter = Math.max(1, Math.max(fitScaleX, fitScaleY) * 1); //
// UI
applicationState.isMapLoaded = true;
}
/**
* 主渲染函数 requestAnimationFrame 循环调用
* 按层顺序绘制栅格 网格线 区域 基础线 高级线 曲线 边界
*
* @param {number} timestamp - requestAnimationFrame 提供的时间戳毫秒
*/
function renderFrame(timestamp) {
//
applicationState.animationTimeSeconds = (timestamp || 0) / 1000;
// Canvas
canvasCtx.setTransform(applicationState.devicePixelRatio, 0, 0, applicationState.devicePixelRatio, 0, 0);
canvasCtx.clearRect(0, 0, 10000, 10000); //
//
if (!applicationState.isMapLoaded) {
requestAnimationFrame(renderFrame); //
return;
}
const map = applicationState.parsedMap; //
const layers = applicationState.layerVisibility; //
// 1:
if (layers.grid && applicationState.gridOffscreen) {
drawGrid(canvasCtx, map, applicationState);
}
// 2: 线1
if (layers.gridlines) {
drawGridlines(canvasCtx, applicationState)
}
// 3: advancedAreaList
if (layers.areas) {
drawAdvancedAreaList(canvasCtx, map, applicationState)
}
// 4: normalLineList线 #00e5ff
if (layers.normalLines) {
drawNormalLines(canvasCtx, map, applicationState);
}
// 5: advancedLineList线 ForbiddenLine
if (layers.advLines) {
drawAdvancedLineList(canvasCtx, map, applicationState);
}
// 6: advancedCurveList线绿 #76ff03
if (layers.curves) {
drawAdvancedCurveList(canvasCtx, map, applicationState);
}
// 7: advancedPointList
if (layers.points) {
drawAdvancedPointList(canvasCtx, map, applicationState);
}
if (layers.robots) {
drawAllRobots(canvasCtx)
}
// 线
drawMapBoundary(canvasCtx, map, applicationState);
//
requestAnimationFrame(renderFrame);
}
const drawAllRobots = (ctx) => {
if (!applicationState.layerVisibility.robots) return;
for (const robot of applicationState.robots) {
const screenPos = worldToScreen(robot.x, robot.y, applicationState);
//
if (screenPos.x + 20 < 0 || screenPos.x - 20 > applicationState.canvasWidthPx || screenPos.y + 20 < 0 || screenPos.y - 20 > applicationState.canvasHeightPx) {
continue;
}
robot.draw(ctx, screenPos.x, screenPos.y);
}
}
const bindEvent = () => {
//
mapCanvas.addEventListener('mousemove', (mouseEvent) => {
// console.log('mouseEvent.clientX', mouseEvent.clientX)
const canvasRect = mapCanvas.getBoundingClientRect(); // Canvas
// console.log('canvasRect.left', canvasRect.left)
applicationState.mouseState.screenX = mouseEvent.clientX - canvasRect.left; // Canvas X
applicationState.mouseState.screenY = mouseEvent.clientY - canvasRect.top; // Canvas Y
//
const worldPos = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY, applicationState);
applicationState.mouseState.worldX = worldPos.x;
applicationState.mouseState.worldY = worldPos.y;
//
if (applicationState.mouseState.isDragging) {
applicationState.camera.centerX -= mouseEvent.movementX / applicationState.camera.pixelsPerMeter; //
applicationState.camera.centerY += mouseEvent.movementY / applicationState.camera.pixelsPerMeter; // Y
}
let hit = hitTestRobots(worldPos.x, worldPos.y, applicationState);
updateTooltipDisplay(hit);
});
mapCanvas.addEventListener('click', (mouseEvent) => {
const canvasRect = mapCanvas.getBoundingClientRect(); // Canvas
const screenX = mouseEvent.clientX - canvasRect.left; // Canvas X
const screenY = mouseEvent.clientY - canvasRect.top; // Canvas Y
const worldPos = screenToWorld(screenX, screenY, applicationState);
})
//
mapCanvas.addEventListener('mousedown', () => {
if (!props.isDragging) {
applicationState.mouseState.isDragging = false;
return
}
applicationState.mouseState.isDragging = true; //
});
//
mapCanvas.addEventListener('mouseup', () => {
applicationState.mouseState.isDragging = false; //
});
// Tooltip
mapCanvas.addEventListener('mouseleave', () => {
applicationState.mouseState.isDragging = false;
});
//
mapCanvas.addEventListener('wheel', (wheelEvent) => {
wheelEvent.preventDefault(); //
const zoomFactor = wheelEvent.deltaY > 0 ? 0.88 : 1.12; // ==
const mouseWorldBefore = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY, applicationState); //
applicationState.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, applicationState.camera.pixelsPerMeter * zoomFactor)); //
const mouseWorldAfter = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY, applicationState); //
// 使
applicationState.camera.centerX += mouseWorldBefore.x - mouseWorldAfter.x;
applicationState.camera.centerY += mouseWorldBefore.y - mouseWorldAfter.y;
}, { passive: false }); // passive:false preventDefault
}
const updateTooltipDisplay = (hitResult) => {
const tooltipEl = document.getElementById('tooltip');
if (!hitResult) {
tooltipEl.style.display = 'none';
applicationState.hoveredElement = null;
return;
}
applicationState.hoveredElement = hitResult;
let title = '', details = '';
if (hitResult.kind === 'robot') {
const robot = hitResult.robot;
title = `${robot.name}`;
details = `<div class="tooltip-detail">位置: (${robot.x.toFixed(3)}, ${robot.y.toFixed(3)})</div>
<div class="tooltip-detail">朝向: ${(robot.angle * 180 / Math.PI).toFixed(1)}°</div>
<div class="tooltip-detail">ID: ${robot.id}</div>`;
}
tooltipEl.innerHTML = `<div class="tooltip-title" style="color:#00e5a0">${title}</div>${details}`;
tooltipEl.style.display = 'block';
tooltipEl.style.left = Math.min(applicationState.mouseState.screenX + 14, applicationState.canvasWidthPx - 220) + 'px';
tooltipEl.style.top = Math.max(4, applicationState.mouseState.screenY - 8) + 'px';
}
const hitTestRobots = (worldX, worldY) => {
for (let i = 0; i < applicationState.robots.length; i++) {
const robot = applicationState.robots[i];
if (robot.hitTest(worldX, worldY)) {
return { kind: 'robot', index: i, robot: robot };
}
}
return null;
}
const zoomCanvas = (zoomIn) => {
const zoomFactor = zoomIn ? 1.12 : 0.88;
const worldBefore = screenToWorld(applicationState.canvasWidthPx / 2, applicationState.canvasHeightPx / 2, applicationState);
applicationState.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, applicationState.camera.pixelsPerMeter * zoomFactor));
const worldAfter = screenToWorld(applicationState.canvasWidthPx / 2, applicationState.canvasHeightPx / 2, applicationState);
applicationState.camera.centerX += worldBefore.x - worldAfter.x;
applicationState.camera.centerY += worldBefore.y - worldAfter.y;
}
const centerCanvasView = () => {
const map = applicationState.parsedMap;
if (!map) return;
applicationState.camera.centerX = (map.originX + map.maxX) / 2;
applicationState.camera.centerY = (map.originY + map.maxY) / 2;
}
const loadSourceMap = async (mapName) => {
const res = await getCurrentMap({
ip: robotStore.ip,
deviceId: VITE_DEVICE_ID,
mapName: mapName
})
if (res.code === 200) {
try {
const rawJsonObject = JSON.parse(res.data.map_content); // JSON
const parsedMap = parseSmapJson(rawJsonObject); //
await loadMapIntoApplication(parsedMap); //
setTimeout(() => {
bindEvent()
getRobot()
}, 300)
} catch (parseError) {
ElMessage.error('Parse error: ' + parseError.message); //
}
}
};
/**
*
* @param id 机器人编号
* @param name 机器人名称
* @param angleRad 角度
* @param color 颜色
*/
const addRobot = (id, name,x, y, angleRad, color) => {
const robot = new MobileRobot(id, name, x, y, angleRad, color);
applicationState.robots.push(robot);
}
const moveRobot = (robot, dx, dy, dtheta = 0) => {
robot.x += dx;
robot.y += dy;
robot.angle += dtheta;
}
const getRobot = async () => {
const res = await getRobotState({
ip: robotStore.ip,
deviceId: VITE_DEVICE_ID,
})
if (res.code === 200) {
robotStore.setPosition(res.data.x, res.data.y, res.data.angle)
robotStore.battery = res.data.batteryLevel
addRobot(robotStore.ip, robotStore.ip, res.data.x, res.data.y, res.data.angle, '#00D4FF')
}
}
onMounted(() => {
init()
})
watch(() => props.mapName,
(newVal) => {
if (props.mapName) {
loadSourceMap(props.mapName)
requestAnimationFrame(renderFrame);
window.addEventListener('resize', resizeCanvasToContainer); //
resizeCanvasToContainer(); //
}
}
)
defineExpose({
zoomCanvas,
centerCanvasView
})
onUnmounted(() => {
window.removeEventListener('resize', resizeCanvasToContainer)
canvasCtx = null
})
</script>
<style lang="scss" scoped>
.container {
width: 100%;
height: 100%;
position: relative;
#tooltip {
position: absolute;
display: none;
background: #0b1120;
border: 1px solid #2a3a5c;
padding: 8px 12px;
border-radius: 3px;
pointer-events: none;
z-index: 20;
font-size: 10px;
min-width: 180px;
max-width: 280px;
box-shadow: 0 4px 16px rgba(0, 0, 0, .5);
.tooltip-title {
font-weight: 700;
font-size: 11px;
margin-bottom: 5px;
}
:deep(.tooltip-detail) {
color: #94a3b8;
padding: 2px 0;
font-size: 9px;
word-break: break-all;
}
}
}
</style>

View File

@ -0,0 +1,614 @@
/**
* 6位十六进制颜色字符串转为 CSS rgba 字符串
* @param {string} hex - "#ff6e40"
* @param {number} alpha - 透明度 0~1
* @returns {string} CSS rgba 字符串
*/
function hexToCssRgba(hexString, alpha) {
const r = parseInt(hexString.slice(1, 3), 16); // 提取 R 分量
const g = parseInt(hexString.slice(3, 5), 16); // 提取 G 分量
const b = parseInt(hexString.slice(5, 7), 16); // 提取 B 分量
return `rgba(${r},${g},${b},${alpha})`;
}
/**
* 将世界坐标转换为 Canvas 屏幕像素坐标
* 注意世界坐标 Y 轴向上Canvas Y 轴向下因此需要翻转
* @param {number} worldX - 世界坐标 X
* @param {number} worldY - 世界坐标 Y
* @returns {{x: number, y: number}} Canvas 上的屏幕像素坐标
*/
export const worldToScreen = (worldX, worldY, dataStore) => {
const store = dataStore ;
const camera = store.camera
const screenX = (worldX - camera.centerX) * camera.pixelsPerMeter + store.canvasWidthPx / 2; // 水平偏移 + 居中
const screenY = -(worldY - camera.centerY) * camera.pixelsPerMeter + store.canvasHeightPx / 2; // 垂直翻转 + 居中
return { x: screenX, y: screenY };
};
/**
* Canvas 屏幕像素坐标反算为世界坐标
* worldToScreen 互为逆运算
* @param {number} screenX - Canvas 像素 X
* @param {number} screenY - Canvas 像素 Y
* @returns {{x: number, y: number}} 世界坐标
*/
export const screenToWorld = (screenX, screenY, dataStore) => {
const store = dataStore ;
const camera = store.camera;
const worldX = (screenX - store.canvasWidthPx / 2) / camera.pixelsPerMeter + camera.centerX; // 水平反算
const worldY = -(screenY - store.canvasHeightPx / 2) / camera.pixelsPerMeter + camera.centerY; // 垂直反算(带翻转)
return { x: worldX, y: worldY };
};
/**
* 0xAARRGGBB 整数颜色解码为 {r, g, b, a} 对象
* @param {number} argbInt - 32 ARGB 整数 0xCCFF0040
* @returns {{r:number, g:number, b:number, a:number}|null} RGBA 分量 (0~255)无效输入返回 null
*/
export const decodeArgbInteger = (argbInt) => {
if (typeof argbInt !== 'number') return null; // 非数字类型直接返回
return {
a: (argbInt >>> 24) & 0xFF, // 高8位Alpha 透明度
r: (argbInt >>> 16) & 0xFF, // 次高8位Red
g: (argbInt >>> 8) & 0xFF, // 次低8位Green
b: argbInt & 0xFF, // 低8位Blue
};
}
/**
* decodeArgbInteger 的结果转为 CSS rgba() 字符串
* @param {{r,g,b,a}} colorObj - RGBA 分量对象
* @param {number} [alphaOverride] - 可选覆盖 alpha 0~1
* @returns {string|null} CSS rgba 字符串 "rgba(255,0,64,0.3)"
*/
export const toCssRgba = (colorObj, alphaOverride) => {
if (!colorObj) return null; // 空对象返回 null
const alpha = alphaOverride !== undefined // 如果提供了覆盖值
? alphaOverride // 使用覆盖值
: colorObj.a / 255; // 否则将 0~255 映射到 0~1
return `rgba(${colorObj.r},${colorObj.g},${colorObj.b},${alpha})`;
}
/**
* 栅格底图绘制函数
* 将预渲染的离屏 Canvas 图像绘制到主 Canvas 并根据当前地图范围进行缩放和平移
* @param {*} canvasCtx
* @param {*} map
* @param {*} gridOffscreen
*/
export const drawGrid = (canvasCtx, map, dataStore) => {
const store = dataStore ;
const topLeftScreen = worldToScreen(map.originX, map.maxY, store); // 图像左上角(世界坐标→屏幕)
const bottomRightScreen = worldToScreen(map.maxX, map.originY, store); // 图像右下角(世界坐标→屏幕)
canvasCtx.imageSmoothingEnabled = false; // 关闭抗锯齿(保持像素清晰)
canvasCtx.drawImage(
store.gridOffscreen, // 离屏 Canvas 图像源
topLeftScreen.x, topLeftScreen.y, // 目标左上角
bottomRightScreen.x - topLeftScreen.x, // 目标宽度
bottomRightScreen.y - topLeftScreen.y // 目标高度
);
}
/**
* 网格辅助线每1米一条
* @param {*} canvasCtx
* @param {*} canvasWidthPx
* @param {*} canvasHeightPx
*/
export const drawGridlines = (canvasCtx, dataStore) => {
// 计算当前可视范围的世界坐标
const store = dataStore ;
const viewTopLeft = screenToWorld(0, 0, store); // 视口左上角世界坐标
const viewBottomRight = screenToWorld(store.canvasWidthPx, store.canvasHeightPx, store); // 视口右下角世界坐标
const viewMinX = Math.floor(Math.min(viewTopLeft.x, viewBottomRight.x)); // 可视X最小整数
const viewMaxX = Math.ceil(Math.max(viewTopLeft.x, viewBottomRight.x)); // 可视X最大整数
const viewMinY = Math.floor(Math.min(viewTopLeft.y, viewBottomRight.y)); // 可视Y最小整数
const viewMaxY = Math.ceil(Math.max(viewTopLeft.y, viewBottomRight.y)); // 可视Y最大整数
// 绘制1米间隔的浅色网格线
canvasCtx.strokeStyle = 'rgba(30,50,80,0.25)'; // 淡蓝灰色
canvasCtx.lineWidth = 0.5; // 细线
canvasCtx.beginPath();
for (let worldX = viewMinX; worldX <= viewMaxX; worldX++) { // 逐列绘制垂直线
const screenPos = worldToScreen(worldX, 0, store);
canvasCtx.moveTo(screenPos.x, 0); // 从画布顶部
canvasCtx.lineTo(screenPos.x, store.canvasHeightPx); // 到画布底部
}
for (let worldY = viewMinY; worldY <= viewMaxY; worldY++) { // 逐行绘制水平线
const screenPos = worldToScreen(0, worldY, store);
canvasCtx.moveTo(0, screenPos.y); // 从画布左边
canvasCtx.lineTo(store.canvasWidthPx, screenPos.y); // 到画布右边
}
canvasCtx.stroke();
}
/**
* 绘制高级区域列表
* @param {*} canvasCtx
* @param {*} map
* @param {*} dataStore
*/
export const drawAdvancedAreaList = (canvasCtx, map, dataStore) => {
const store = dataStore ;
for (let areaIndex = 0; areaIndex < map.advancedAreas.length; areaIndex++) {
const area = map.advancedAreas[areaIndex]; // 当前区域
const vertices = area.posGroup || []; // 多边形顶点数组
if (vertices.length < 3) continue; // 少于3个顶点无法构成多边形跳过
// ── 颜色:优先使用 attribute 中的 ARGB 整数,否则用默认品红色 ──
let fillColorCss = 'rgba(224,64,251,0.12)'; // 默认填充(品红半透明)
let strokeColorCss = 'rgba(224,64,251,0.5)'; // 默认描边(品红较不透明)
if (area.attribute) {
const brushColor = decodeArgbInteger(area.attribute.colorBrush); // 解码填充色
const penColor = decodeArgbInteger(area.attribute.colorPen); // 解码描边色
if (brushColor) {
// 使用原始 alpha 值的一半作为填充透明度,最小 0.06
fillColorCss = toCssRgba(brushColor, Math.max(0.06, (brushColor.a / 255) * 0.5));
}
if (penColor) {
strokeColorCss = toCssRgba(penColor, 0.6); // 描边60%不透明
}
}
// ── 绘制多边形 ──
canvasCtx.fillStyle = fillColorCss; // 设置填充色
canvasCtx.strokeStyle = strokeColorCss; // 设置描边色
canvasCtx.lineWidth = 1.2; // 描边宽度
canvasCtx.beginPath();
const firstVertexScreen = worldToScreen(vertices[0].x, vertices[0].y, store); // 第一个顶点的屏幕位置
canvasCtx.moveTo(firstVertexScreen.x, firstVertexScreen.y); // 移动到起点
for (let i = 1; i < vertices.length; i++) { // 依次连接后续顶点
const vertexScreen = worldToScreen(vertices[i].x, vertices[i].y, store);
canvasCtx.lineTo(vertexScreen.x, vertexScreen.y);
}
canvasCtx.closePath(); // 闭合路径
canvasCtx.fill(); // 填充
canvasCtx.stroke(); // 描边
// ── 在多边形质心处绘制区域名称标签 ──
let centroidWorldX = 0, centroidWorldY = 0; // 质心世界坐标
for (const v of vertices) { centroidWorldX += v.x; centroidWorldY += v.y; } // 累加顶点坐标
centroidWorldX /= vertices.length; // 求平均得到质心 X
centroidWorldY /= vertices.length; // 求平均得到质心 Y
const centroidScreen = worldToScreen(centroidWorldX, centroidWorldY, store);
canvasCtx.font = '500 8px "JetBrains Mono"'; // 标签字体
canvasCtx.fillStyle = strokeColorCss; // 与描边同色
canvasCtx.textAlign = 'center'; // 居中对齐
canvasCtx.fillText(area.instanceName || '', centroidScreen.x, centroidScreen.y + 3); // 绘制文字
}
};
/**
* 绘制高级线列表
* @param {*} canvasCtx
* @param {*} map
*/
export const drawAdvancedLineList = (canvasCtx, map, dataStore) => {
const store = dataStore ;
for (let advLineIndex = 0; advLineIndex < map.advancedLines.length; advLineIndex++) {
const advLine = map.advancedLines[advLineIndex]; // 当前高级线
const lineData = advLine.line; // 内部的 line 对象
if (!lineData || !lineData.startPos || !lineData.endPos) continue; // 缺少端点则跳过
const startScreen = worldToScreen(lineData.startPos.x, lineData.startPos.y, store);
const endScreen = worldToScreen(lineData.endPos.x, lineData.endPos.y, store);
// ── 根据 className 决定颜色和线型 ──
let lineColorHex = '#ff6e40'; // 默认橙色
let lineDashPattern = []; // 默认实线(无虚线)
const classNameLower = (advLine.className || '').toLowerCase();
if (classNameLower.includes('forbidden')) { // 禁行线 → 红色虚线
lineColorHex = '#ff1744';
lineDashPattern = [8, 4]; // 虚线模式8px实 4px空
} else if (classNameLower.includes('virtual')) { // 虚拟线 → 橙色虚线
lineColorHex = '#ffab40';
lineDashPattern = [4, 4];
}
// 绘制高级线
canvasCtx.save();
canvasCtx.shadowColor = lineColorHex; // 辉光色与线条同色
canvasCtx.shadowBlur = 4; // 辉光半径
canvasCtx.strokeStyle = lineColorHex; // 线条颜色
canvasCtx.lineWidth = 2.5; // 线宽(比基础线粗)
canvasCtx.setLineDash(lineDashPattern); // 设置虚线模式
canvasCtx.globalAlpha = 0.8; // 80%不透明
canvasCtx.beginPath();
canvasCtx.moveTo(startScreen.x, startScreen.y);
canvasCtx.lineTo(endScreen.x, endScreen.y);
canvasCtx.stroke();
canvasCtx.setLineDash([]); // 恢复实线
canvasCtx.restore();
// 在中点绘制名称标签
// const midpointScreenX = (startScreen.x + endScreen.x) / 2;
// const midpointScreenY = (startScreen.y + endScreen.y) / 2;
// canvasCtx.font = '500 8px "JetBrains Mono"';
// canvasCtx.fillStyle = hexToCssRgba(lineColorHex, 0.55); // 55%不透明的文字
// canvasCtx.textAlign = 'center';
// canvasCtx.fillText(advLine.instanceName || advLine.className || '', midpointScreenX, midpointScreenY - 6);
}
};
/**
* 绘制基础直线列表
* @param {*} canvasCtx
* @param {*} map
* @param {*} dataStore
*/
export const drawNormalLines = (canvasCtx, map, dataStore) => {
const store = dataStore ;
for (let lineIndex = 0; lineIndex < map.normalLines.length; lineIndex++) {
const line = map.normalLines[lineIndex]; // 当前直线
const startScreen = worldToScreen(line.startPos.x, line.startPos.y, store); // 起点屏幕位置
const endScreen = worldToScreen(line.endPos.x, line.endPos.y, store); // 终点屏幕位置
// 绘制发光直线
canvasCtx.save();
canvasCtx.shadowColor = '#00e5ff'; // 阴影颜色(产生辉光)
canvasCtx.shadowBlur = 5; // 辉光扩散半径
canvasCtx.strokeStyle = '#00e5ff'; // 线条颜色:青色
canvasCtx.lineWidth = 2; // 线宽
canvasCtx.globalAlpha = 0.8; // 80%不透明
canvasCtx.beginPath();
canvasCtx.moveTo(startScreen.x, startScreen.y); // 从起点
canvasCtx.lineTo(endScreen.x, endScreen.y); // 到终点
canvasCtx.stroke();
canvasCtx.restore();
// 在中点绘制方向箭头
const midpointX = (startScreen.x + endScreen.x) / 2; // 中点X
const midpointY = (startScreen.y + endScreen.y) / 2; // 中点Y
const arrowAngle = Math.atan2(endScreen.y - startScreen.y, endScreen.x - startScreen.x); // 线段方向角
const arrowLength = 5; // 箭头长度
canvasCtx.fillStyle = 'rgba(0,229,255,0.6)'; // 箭头填充色
canvasCtx.beginPath();
canvasCtx.moveTo(midpointX + Math.cos(arrowAngle) * arrowLength, midpointY + Math.sin(arrowAngle) * arrowLength); // 箭头尖端
canvasCtx.lineTo(midpointX + Math.cos(arrowAngle + 2.5) * 4, midpointY + Math.sin(arrowAngle + 2.5) * 4); // 左翼
canvasCtx.lineTo(midpointX + Math.cos(arrowAngle - 2.5) * 4, midpointY + Math.sin(arrowAngle - 2.5) * 4); // 右翼
canvasCtx.closePath();
canvasCtx.fill();
}
};
/**
* 绘制高级曲线列表
* @param {*} canvasCtx
* @param {*} map
* @param {*} dataStore
*/
export const drawAdvancedCurveList = (canvasCtx, map, dataStore) => {
const store = dataStore ;
for (let curveIndex = 0; curveIndex < map.advancedCurves.length; curveIndex++) {
const curve = map.advancedCurves[curveIndex]; // 当前曲线
// 提取起点和终点的 pos注意startPos/endPos 是 {instanceName, pos:{x,y}} 结构)
const startPoint = curve.startPos?.pos; // 起点世界坐标
const endPoint = curve.endPos?.pos; // 终点世界坐标
const controlPoint1 = curve.controlPos1; // 第一控制点(直接 {x,y}
const controlPoint2 = curve.controlPos2; // 第二控制点(直接 {x,y}
if (!startPoint || !endPoint) continue; // 缺少端点则跳过
// 转换为屏幕坐标
const startScreen = worldToScreen(startPoint.x, startPoint.y, store);
const endScreen = worldToScreen(endPoint.x, endPoint.y, store);
const cp1Screen = controlPoint1 ? worldToScreen(controlPoint1.x, controlPoint1.y, store) : null; // 可能不存在
const cp2Screen = controlPoint2 ? worldToScreen(controlPoint2.x, controlPoint2.y, store) : null;
// ── 绘制贝塞尔曲线(虚线)──
canvasCtx.save();
canvasCtx.shadowColor = '#76ff03'; // 酸绿色辉光
canvasCtx.shadowBlur = 5;
canvasCtx.strokeStyle = '#76ff03'; // 酸绿色线条
canvasCtx.lineWidth = 2;
canvasCtx.setLineDash([8, 5]); // 虚线模式
canvasCtx.globalAlpha = 0.85;
canvasCtx.beginPath();
canvasCtx.moveTo(startScreen.x, startScreen.y); // 起点
if (cp1Screen && cp2Screen) {
// 有2个控制点 → 三次贝塞尔 (cubic bezier)
canvasCtx.bezierCurveTo(cp1Screen.x, cp1Screen.y, cp2Screen.x, cp2Screen.y, endScreen.x, endScreen.y);
} else if (cp1Screen) {
// 仅1个控制点 → 二次贝塞尔 (quadratic bezier)
canvasCtx.quadraticCurveTo(cp1Screen.x, cp1Screen.y, endScreen.x, endScreen.y);
} else {
// 无控制点 → 直线
canvasCtx.lineTo(endScreen.x, endScreen.y);
}
canvasCtx.stroke();
canvasCtx.setLineDash([]); // 恢复实线
canvasCtx.restore();
// ── 在 t=0.5 处绘制方向箭头 ──
if (cp1Screen && cp2Screen) {
const t = 0.5; // 参数 t = 0.5(曲线中点)
const oneMinusT = 0.5; // 1-t = 0.5
// 三次贝塞尔公式计算中点坐标
const arrowX = oneMinusT ** 3 * startScreen.x + 3 * oneMinusT ** 2 * t * cp1Screen.x + 3 * oneMinusT * t ** 2 * cp2Screen.x + t ** 3 * endScreen.x;
const arrowY = oneMinusT ** 3 * startScreen.y + 3 * oneMinusT ** 2 * t * cp1Screen.y + 3 * oneMinusT * t ** 2 * cp2Screen.y + t ** 3 * endScreen.y;
// 三次贝塞尔的一阶导数(切线方向)
const tangentX = 3 * oneMinusT ** 2 * (cp1Screen.x - startScreen.x) + 6 * oneMinusT * t * (cp2Screen.x - cp1Screen.x) + 3 * t ** 2 * (endScreen.x - cp2Screen.x);
const tangentY = 3 * oneMinusT ** 2 * (cp1Screen.y - startScreen.y) + 6 * oneMinusT * t * (cp2Screen.y - cp1Screen.y) + 3 * t ** 2 * (endScreen.y - cp2Screen.y);
const tangentAngle = Math.atan2(tangentY, tangentX); // 切线角度
// 绘制箭头
canvasCtx.fillStyle = 'rgba(118,255,3,0.7)';
canvasCtx.beginPath();
canvasCtx.moveTo(arrowX + Math.cos(tangentAngle) * 5, arrowY + Math.sin(tangentAngle) * 5);
canvasCtx.lineTo(arrowX + Math.cos(tangentAngle + 2.5) * 4, arrowY + Math.sin(tangentAngle + 2.5) * 4);
canvasCtx.lineTo(arrowX + Math.cos(tangentAngle - 2.5) * 4, arrowY + Math.sin(tangentAngle - 2.5) * 4);
canvasCtx.closePath();
canvasCtx.fill();
// 绘制控制点小圆(辅助调试用)
canvasCtx.fillStyle = 'rgba(118,255,3,0.25)'; // 浅绿色半透明
for (const controlScreen of [cp1Screen, cp2Screen]) {
canvasCtx.beginPath();
canvasCtx.arc(controlScreen.x, controlScreen.y, 2.5, 0, Math.PI * 2); // 半径2.5px的小圆
canvasCtx.fill();
}
}
// ── 曲线名称标签 ──
// canvasCtx.font = '500 7px "JetBrains Mono"';
// canvasCtx.fillStyle = 'rgba(118,255,3,0.5)';
// canvasCtx.textAlign = 'center';
// const labelScreenY = Math.min(startScreen.y, endScreen.y) - 8; // 标签放在最高点上方
// canvasCtx.fillText(curve.instanceName || '', (startScreen.x + endScreen.x) / 2, labelScreenY);
}
};
/**
* 绘制高级点列表
* @param {*} canvasCtx
* @param {*} map
* @param {*} dataStore
*/
export const drawAdvancedPointList = (canvasCtx, map, dataStore) => {
const store = dataStore ;
for (let pointIndex = 0; pointIndex < map.advancedPoints.length; pointIndex++) {
const advPoint = map.advancedPoints[pointIndex]; // 当前高级点
const pointScreen = worldToScreen(advPoint.pos.x, advPoint.pos.y, store); // 屏幕位置
// 判断是否被鼠标悬停
const isHovered = store.hoveredElement &&
store.hoveredElement.kind === 'point' &&
store.hoveredElement.index === pointIndex;
// ── 根据 className 决定颜色 ──
let pointColorHex = '#ffd600'; // 默认琥珀色
const classNameLower = (advPoint.className || '').toLowerCase();
if (classNameLower.includes('landmark') || classNameLower.includes('land')) {
pointColorHex = '#ffd600'; // LandMark → 琥珀色
} else if (classNameLower.includes('charge')) {
pointColorHex = '#22c55e'; // 充电点 → 绿色
} else if (classNameLower.includes('load')) {
pointColorHex = '#3b82f6'; // 装载点 → 蓝色
} else if (classNameLower.includes('work') || classNameLower.includes('process')) {
pointColorHex = '#a855f7'; // 工位 → 紫色
} else if (classNameLower.includes('station')) {
pointColorHex = '#00e5ff'; // 站点 → 青色
}
// ── 绘制菱形标记 ──
const diamondRadius = isHovered ? 8 : 5; // 悬停时放大
const pulseAlpha = isHovered ? Math.sin(store.animationTimeSeconds * 5) * 0.2 + 0.8 : 1; // 悬停时脉冲闪烁
canvasCtx.save();
canvasCtx.globalAlpha = pulseAlpha; // 应用透明度
canvasCtx.shadowColor = pointColorHex; // 辉光颜色
canvasCtx.shadowBlur = isHovered ? 18 : 8; // 悬停时增强辉光
// 绘制旋转45度的正方形菱形
canvasCtx.fillStyle = pointColorHex;
canvasCtx.beginPath();
canvasCtx.moveTo(pointScreen.x, pointScreen.y - diamondRadius); // 上顶点
canvasCtx.lineTo(pointScreen.x + diamondRadius * 0.7, pointScreen.y); // 右顶点
canvasCtx.lineTo(pointScreen.x, pointScreen.y + diamondRadius); // 下顶点
canvasCtx.lineTo(pointScreen.x - diamondRadius * 0.7, pointScreen.y); // 左顶点
canvasCtx.closePath();
canvasCtx.fill();
// 中心白色小圆点(指示精确位置)
canvasCtx.shadowBlur = 0;
canvasCtx.fillStyle = '#fff';
canvasCtx.globalAlpha = 0.9;
canvasCtx.beginPath();
canvasCtx.arc(pointScreen.x, pointScreen.y, 1.8, 0, Math.PI * 2); // 半径1.8px
canvasCtx.fill();
canvasCtx.restore();
// ── 方向箭头(基于 dir 字段,弧度)──
if (advPoint.dir !== undefined && advPoint.dir !== null) {
// dir 可能是字符串smap 样例中为 "-1.5759999999999958")或数字
const dirRadians = typeof advPoint.dir === 'string' ? parseFloat(advPoint.dir) : advPoint.dir;
if (!isNaN(dirRadians)) { // 有效方向角
const arrowLengthPx = 12; // 箭头长度(屏幕像素)
canvasCtx.strokeStyle = hexToCssRgba(pointColorHex, 0.5); // 半透明的线条
canvasCtx.lineWidth = 1;
canvasCtx.beginPath();
canvasCtx.moveTo(pointScreen.x, pointScreen.y); // 从点中心出发
// 注意:世界坐标 Y 向上,但 atan2 使用的 dir 是标准数学角,屏幕 Y 翻转后取负
canvasCtx.lineTo(
pointScreen.x + Math.cos(dirRadians) * arrowLengthPx,
pointScreen.y - Math.sin(dirRadians) * arrowLengthPx // Y 翻转
);
canvasCtx.stroke();
}
}
// ── 名称标签 ──
canvasCtx.font = '500 7px "JetBrains Mono"';
canvasCtx.fillStyle = hexToCssRgba(pointColorHex, 0.7);
canvasCtx.textAlign = 'center';
canvasCtx.fillText(advPoint.instanceName || '', pointScreen.x, pointScreen.y - diamondRadius - 4);
}
}
/**
* 绘制地图边界
* @param {*} canvasCtx
* @param {*} map
* @param {*} dataStore
*/
export const drawMapBoundary = (canvasCtx, map, dataStore) => {
const store = dataStore ;
const boundaryTopLeft = worldToScreen(map.originX, map.maxY, store); // 边界左上角屏幕位置
const boundaryBottomRight = worldToScreen(map.maxX, map.originY, store); // 边界右下角屏幕位置
canvasCtx.strokeStyle = 'rgba(0,229,160,0.1)'; // 极淡的青绿色
canvasCtx.lineWidth = 1;
canvasCtx.setLineDash([5, 4]); // 虚线
canvasCtx.strokeRect(
boundaryTopLeft.x, boundaryTopLeft.y,
boundaryBottomRight.x - boundaryTopLeft.x,
boundaryBottomRight.y - boundaryTopLeft.y
);
canvasCtx.setLineDash([]); // 恢复实线
};
/**
* normalPosList 构建占据栅格图像
*
* 原理normalPosList 中每个点标记一个自由通行格子
* 未出现在列表中的格子 = 障碍物墙壁
* 墙壁格子如果紧邻自由格子则标记为"墙壁边缘"渲染高亮
*
* @param {Object} parsedMap - parseSmapJson 的输出
* @param {boolean} showEdgeGlow - 是否计算墙壁边缘高亮效果
* @returns {HTMLCanvasElement} 离屏 Canvas 元素包含渲染好的栅格图像
*/
export const buildOccupancyGridImage = (parsedMap, showEdgeGlow) => {
const gridW = parsedMap.gridWidth; // 栅格宽度(像素)
const gridH = parsedMap.gridHeight; // 栅格高度(像素)
const originX = parsedMap.originX; // 世界坐标原点 X
const originY = parsedMap.originY; // 世界坐标原点 Y
const resolution = parsedMap.resolution; // 分辨率
// ── 第一步:遍历 normalPosList标记自由空间 ──
const occupancyGrid = new Uint8Array(gridW * gridH); // 0=墙壁, 1=自由空间
for (const freeSpacePoint of parsedMap.normalPositions) {
// 将世界坐标转换为栅格像素坐标
const gridColX = Math.floor((freeSpacePoint.x - originX) / resolution); // 列号(从左到右)
const pixelRowFromBottom = Math.floor((freeSpacePoint.y - originY) / resolution); // 行号(从下到上,世界坐标系)
const imageRowY = gridH - 1 - pixelRowFromBottom; // 翻转为图像行号(从上到下)
// 安全边界检查后标记为自由空间
if (gridColX >= 0 && gridColX < gridW && imageRowY >= 0 && imageRowY < gridH) {
occupancyGrid[imageRowY * gridW + gridColX] = 1; // 标记该格为自由
}
}
// ── 第二步:遍历每个格子,按类型着色 ──
const offscreenCanvas = document.createElement('canvas'); // 创建离屏 Canvas
offscreenCanvas.width = gridW; // 设置尺寸
offscreenCanvas.height = gridH;
const offscreenCtx = offscreenCanvas.getContext('2d'); // 获取上下文
const imageData = offscreenCtx.createImageData(gridW, gridH);// 创建 ImageData
const pixelData = imageData.data; // 像素数据数组 (RGBA 扁平排列)
// 定义三种颜色
const FREE_SPACE_R = 10, FREE_SPACE_G = 16, FREE_SPACE_B = 30; // 自由空间:深蓝黑
const WALL_INTERIOR_R = 22, WALL_INTERIOR_G = 36, WALL_INTERIOR_B = 64; // 墙壁内部:深蓝
const WALL_EDGE_R = 45, WALL_EDGE_G = 130, WALL_EDGE_B = 255; // 墙壁边缘:亮蓝
for (let rowY = 0; rowY < gridH; rowY++) { // 逐行扫描
for (let colX = 0; colX < gridW; colX++) { // 逐列扫描
const gridIndex = rowY * gridW + colX; // 一维数组索引
const pixelOffset = gridIndex << 2; // RGBA 偏移×4
if (occupancyGrid[gridIndex] === 1) {
// ── 自由空间 ──
pixelData[pixelOffset] = FREE_SPACE_R; // R
pixelData[pixelOffset + 1] = FREE_SPACE_G; // G
pixelData[pixelOffset + 2] = FREE_SPACE_B; // B
pixelData[pixelOffset + 3] = 255; // A不透明
} else {
// ── 障物(墙壁)── 判断是否为边缘
let isAdjacentToFreeSpace = false; // 是否紧邻自由空间
if (showEdgeGlow) { // 仅在启用边缘效果时检测
if (rowY > 0 && occupancyGrid[(rowY - 1) * gridW + colX] === 1) isAdjacentToFreeSpace = true; // 上方
else if (rowY < gridH - 1 && occupancyGrid[(rowY + 1) * gridW + colX] === 1) isAdjacentToFreeSpace = true; // 下方
else if (colX > 0 && occupancyGrid[rowY * gridW + colX - 1] === 1) isAdjacentToFreeSpace = true; // 左方
else if (colX < gridW - 1 && occupancyGrid[rowY * gridW + colX + 1] === 1) isAdjacentToFreeSpace = true; // 右方
}
if (isAdjacentToFreeSpace) {
// 墙壁边缘:亮蓝色
pixelData[pixelOffset] = WALL_EDGE_R;
pixelData[pixelOffset + 1] = WALL_EDGE_G;
pixelData[pixelOffset + 2] = WALL_EDGE_B;
pixelData[pixelOffset + 3] = 255;
} else {
// 墙壁内部:深蓝色
pixelData[pixelOffset] = WALL_INTERIOR_R;
pixelData[pixelOffset + 1] = WALL_INTERIOR_G;
pixelData[pixelOffset + 2] = WALL_INTERIOR_B;
pixelData[pixelOffset + 3] = 255;
}
}
}
}
// 将 ImageData 绘制到离屏 Canvas 上
offscreenCtx.putImageData(imageData, 0, 0);
return offscreenCanvas;
}
export class MobileRobot {
constructor(id, name, x, y, angleRad = 0, color= "#00D4FF", imageUrl = null) {
this.id = id;
this.name = name;
this.x = x; // 世界坐标X (米)
this.y = y; // 世界坐标Y (米)
this.angle = angleRad; // 朝向弧度
this.imageUrl = imageUrl || '/bot.svg';
// 预加载图片 (可选)
this.imgElement = null;
if (this.imageUrl) {
this.init(color, this.imageUrl)
}
}
async init(color, imageUrl) {
const response = await fetch(imageUrl);
let svgText = await response.text();
// 替换颜色
svgText = svgText.replace(/currentColor/g, color);
svgText = svgText.replace(/stroke="[^"]*"/g, `stroke="${color}"`);
// 创建Blob URL
const blob = new Blob([svgText], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
this.imgElement = new Image();
this.imgElement.src = url;
}
// 命中检测: 检查世界坐标是否在机器人区域内 (半径0.35米)
hitTest(worldX, worldY, pixelsPerMeter){
const dx = worldX - this.x;
const dy = worldY - this.y;
const distance = Math.hypot(dx, dy);
const hitRadius = 0.35; // 米
return distance < hitRadius;
}
// 绘制到canvas上下文 (屏幕坐标)
draw(ctx, screenX, screenY, scale = 1) {
const size = 24; // 绘制大小px
if (this.imgElement && this.imgElement.complete) {
ctx.save();
ctx.translate(screenX, screenY);
ctx.rotate(-this.angle); // canvas Y向下, 角度翻转适配
ctx.drawImage(this.imgElement, -size / 2, -size / 2, size, size);
ctx.restore();
}
// 名字标签
ctx.font = '12px "JetBrains Mono"';
ctx.fillStyle = '#a0c0f0';
ctx.shadowBlur = 0;
ctx.fillText(this.name, screenX, screenY - 20);
}
// 更新位置
setPosition(x, y) { this.x = x; this.y = y; }
setAngle(rad) { this.angle = rad; }
}

48
vite.config.js Normal file
View File

@ -0,0 +1,48 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
plugins: [
vue(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()],
dts: true
}),
Components({
resolvers: [
ElementPlusResolver({
importStyle: 'sass' // 使用scss源码支持自定义主题变量
})
],
dts: true
}),
],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
},
css: {
preprocessorOptions: {
scss: {
// 全局注入scss变量/混合所有页面直接使用无需手动import
additionalData: `@use "@/styles/vars.scss" as *;`
}
}
}
})