first commit
27
.dockerignore
Normal 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/
|
||||||
24
.gitignore
vendored
Normal 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
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
27
Dockerfile
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
54
public/bot.svg
Normal 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
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal 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
@ -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}`)
|
||||||
|
})
|
||||||
906
server/proto/cmvr/api/agv_command.proto
Normal file
@ -0,0 +1,906 @@
|
|||||||
|
/**
|
||||||
|
* @file agv_command.proto
|
||||||
|
* @brief AGV 控制相关的 Protobuf 消息定义。
|
||||||
|
* 涵盖仙工智能(Seer)SRC 系列控制器的主要 API 命令,
|
||||||
|
* 包括状态查询、电池、位置、地图管理、控制权、运动控制、导航任务等。
|
||||||
|
* @note 所有命令均遵循仙工协议帧格式,通过 gRPC 服务(AgvService)对外暴露。
|
||||||
|
*/
|
||||||
|
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
import "cmvr/api/common.proto";
|
||||||
|
|
||||||
|
package cmvr.api;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 1. 基本信息查询(命令码 1000, 0x03E8)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief AGV 基本信息响应结构。
|
||||||
|
* @note 对应仙工 API 1000(0x03E8)的响应体,包含设备标识、软硬件版本、网络状态等。
|
||||||
|
*/
|
||||||
|
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 请求携带设备 ID(header.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 1007(0x03EF),支持简单/全量两种模式。
|
||||||
|
*/
|
||||||
|
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 1004(0x03EC),返回世界坐标系下的位姿。
|
||||||
|
*/
|
||||||
|
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 1300(0x0514),返回当前地图及所有地图列表。
|
||||||
|
*/
|
||||||
|
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 1060(0x0424)。
|
||||||
|
*/
|
||||||
|
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 命令码 2010(0x07DA)。下发后会强制取消当前自动导航任务。
|
||||||
|
* 多舵轮设备仅 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 命令码 2022(0x07E6)。目标地图必须已存在于机器人中。
|
||||||
|
*/
|
||||||
|
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 命令码 1022(0x03FE)。loadmap_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_status,false=全量
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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; ///< 目标站点 ID(task_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/s),0 使用默认
|
||||||
|
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 命令码 2000(0x07D0)。下发零速度指令,终止当前持续运动。
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
server/proto/cmvr/api/agv_service.proto
Normal 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);
|
||||||
|
}
|
||||||
43
server/proto/cmvr/api/common.proto
Normal 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
@ -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
@ -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
|
After Width: | Height: | Size: 13 KiB |
9
src/assets/icons/locate-fixed.svg
Normal 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 |
1
src/assets/icons/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
7
src/assets/icons/zoom-in.svg
Normal 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 |
6
src/assets/icons/zoom-out.svg
Normal 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
|
After Width: | Height: | Size: 8.5 KiB |
1
src/assets/vue.svg
Normal 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
@ -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
@ -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
@ -0,0 +1,41 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
// 唯一id:robot
|
||||||
|
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
@ -0,0 +1,9 @@
|
|||||||
|
* {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,body, #app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
42
src/styles/vars.scss
Normal 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
@ -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
@ -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><-</el-button>
|
||||||
|
<el-button circle>RZ</el-button>
|
||||||
|
<el-button circle>>+</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>
|
||||||
556
src/views/chassis/Chassis.vue
Normal 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>
|
||||||
491
src/views/chassis/MapCanvas.vue
Normal 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>
|
||||||
614
src/views/chassis/canvasUtils.js
Normal 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
@ -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 *;`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||