add:AGV信息

This commit is contained in:
2026-03-24 09:41:44 +08:00
parent d6ed94fd0c
commit 0cbba7e8e3
49 changed files with 5074 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
import request from '@/utils/request'
export function query(params) {
return request({
url: 'api/agvCar/list',
method: 'get',
params
})
}
export function getById(id) {
return request({
url: `api/agvCar/${id}`,
method: 'get'
})
}
export function add(data) {
return request({
url: 'api/agvCar',
method: 'post',
data
})
}
export function edit(data) {
return request({
url: 'api/agvCar',
method: 'put',
data
})
}
export function del(ids) {
return request({
url: 'api/agvCar',
method: 'delete',
data: ids
})
}
export default { query, getById, add, edit, del }

View File

@@ -0,0 +1,42 @@
import request from '@/utils/request'
export function query(params) {
return request({
url: 'api/baseDevice/list',
method: 'get',
params
})
}
export function getById(id) {
return request({
url: `api/baseDevice/${id}`,
method: 'get'
})
}
export function add(data) {
return request({
url: 'api/baseDevice',
method: 'post',
data
})
}
export function edit(data) {
return request({
url: 'api/baseDevice',
method: 'put',
data
})
}
export function del(ids) {
return request({
url: 'api/baseDevice',
method: 'delete',
data: ids
})
}
export default { query, getById, add, edit, del }

View File

@@ -0,0 +1,42 @@
import request from '@/utils/request'
export function getComponentList() {
return request({
url: '/api/baseDevice/component',
method: 'get'
})
}
export function saveMapLayout(data) {
return request({
url: 'api/mapLayout',
method: 'post',
data
})
}
export function saveDeviceLayout(data) {
return request({
url: 'api/deviceLayout',
method: 'post',
data
})
}
export function getMapLayoutList(params) {
return request({
url: 'api/mapLayout/list',
method: 'get',
params
})
}
export function getDeviceLayoutList(params) {
return request({
url: 'api/deviceLayout/list',
method: 'get',
params
})
}
export default { getComponentList, saveMapLayout, saveDeviceLayout, getMapLayoutList, getDeviceLayoutList }

View File

@@ -0,0 +1,27 @@
import request from '@/utils/request'
export function getMapList(params) {
return request({
url: 'api/mapLayout/list',
method: 'get',
params
})
}
export function getMapDetail(id) {
return request({
url: 'api/mapLayout/initLayout',
method: 'get',
params: { id }
})
}
export function deleteMap(ids) {
return request({
url: 'api/mapLayout',
method: 'delete',
data: ids
})
}
export default { getMapList, getMapDetail, deleteMap }

View File

@@ -0,0 +1,18 @@
import request from '@/utils/request'
export function getMapDetail(id) {
return request({
url: 'api/mapLayout/initLayout',
method: 'get',
params: { id }
})
}
export function getAgvStatus() {
return request({
url: 'api/mapLayout/status',
method: 'get'
})
}
export default { getMapDetail, getAgvStatus }

View File

@@ -0,0 +1,39 @@
import request from '@/utils/request'
export function query(params) {
return request({
url: 'api/localStorage/list',
method: 'get',
params
})
}
export function upload(data) {
return request({
url: 'api/localStorage/upload',
method: 'post',
headers: {
'Content-Type': 'multipart/form-data'
},
data
})
}
export function download(storageId) {
return request({
url: 'api/localStorage/download',
method: 'get',
params: { storageId },
responseType: 'blob'
})
}
export function del(ids) {
return request({
url: 'api/localStorage',
method: 'delete',
data: ids
})
}
export default { query, upload, download, del }

View File

@@ -0,0 +1,407 @@
<template>
<div class="app-container">
<div class="head-container">
<el-input
v-model="queryParams.carId"
clearable
size="mini"
placeholder="请输入车辆ID"
style="width: 200px; margin-right: 10px;"
@keyup.enter.native="handleQuery"
/>
<el-input
v-model="queryParams.type"
clearable
size="mini"
placeholder="请输入车辆类型"
style="width: 200px; margin-right: 10px;"
@keyup.enter.native="handleQuery"
/>
<el-button
size="mini"
type="primary"
icon="el-icon-search"
@click="handleQuery"
>
查询
</el-button>
<el-button
size="mini"
icon="el-icon-refresh"
@click="handleReset"
>
重置
</el-button>
<el-button
size="mini"
type="primary"
icon="el-icon-plus"
@click="handleAdd"
>
新增
</el-button>
<el-button
size="mini"
type="danger"
icon="el-icon-delete"
:disabled="selections.length === 0"
@click="handleDelete"
>
删除
</el-button>
</div>
<el-table
v-loading="loading"
:data="tableData"
style="width: 100%; margin-top: 20px;"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="icon" label="车辆图标" width="120" align="center">
<template slot-scope="scope">
<img
v-if="scope.row.icon"
:src="getIconUrl(scope.row.fileId)"
style="width: 60px; height: 60px; object-fit: contain;"
@error="handleImageError"
>
<span v-else style="color: #999;">无图标</span>
</template>
</el-table-column>
<el-table-column prop="carId" label="车辆ID" width="200" show-overflow-tooltip />
<el-table-column prop="type" label="车辆类型" width="150" />
<el-table-column prop="icon" label="图标地址" show-overflow-tooltip />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-edit"
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
style="color: #f56c6c;"
@click="handleDeleteSingle(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
:current-page="page.current"
:page-sizes="[10, 20, 50, 100]"
:page-size="page.size"
:total="page.total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 20px; text-align: right;"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
<el-dialog
:title="dialogTitle"
:visible.sync="showDialog"
width="600px"
:close-on-click-modal="false"
@close="handleDialogClose"
>
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="车辆ID" prop="carId">
<el-input v-model="form.carId" placeholder="请输入车辆ID" :disabled="isEdit" />
</el-form-item>
<el-form-item label="车辆类型" prop="type">
<el-input v-model="form.type" placeholder="请输入车辆类型" />
</el-form-item>
<el-form-item label="车辆图标" prop="icon">
<el-upload
class="icon-uploader"
action=""
:show-file-list="false"
:before-upload="beforeIconUpload"
:http-request="handleIconUpload"
accept="image/*"
>
<img v-if="form.iconPreview" :src="form.iconPreview" class="icon-preview">
<i v-else class="el-icon-plus icon-uploader-icon" />
</el-upload>
<div class="upload-tip">支持上传jpgpng等图片格式大小不超过2MB</div>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
确定
</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { query, add, edit, del } from '@/api/layout/car'
import { upload } from '@/api/localStorage'
import { parseTime } from '@/utils/index'
export default {
name: 'Car',
data() {
return {
loading: false,
submitting: false,
tableData: [],
selections: [],
showDialog: false,
dialogTitle: '',
isEdit: false,
queryParams: {
carId: '',
type: ''
},
page: {
current: 1,
size: 10,
total: 0
},
form: {
id: null,
carId: '',
type: '',
icon: '',
iconPreview: '',
fileId: ''
},
rules: {
carId: [
{ required: true, message: '请输入车辆ID', trigger: 'blur' }
],
icon: [
{ required: true, message: '请上传车辆图标', trigger: 'change' }
]
},
uploadingIcon: false
}
},
mounted() {
this.loadData()
},
methods: {
parseTime,
getIconUrl(fileId) {
if (!fileId) return ''
return `${process.env.NODE_ENV === 'production' ? window.g.prod.VUE_APP_BASE_API : ''}/api/localStorage/download?storageId=${fileId}`
},
handleImageError(e) {
e.target.style.display = 'none'
},
loadData() {
this.loading = true
const params = {
page: this.page.current,
size: this.page.size,
...this.queryParams
}
query(params).then(res => {
this.tableData = res.records || []
this.page.total = res.total || 0
this.page.current = res.current || 1
this.loading = false
}).catch(() => {
this.loading = false
})
},
handleQuery() {
this.page.current = 1
this.loadData()
},
handleReset() {
this.queryParams = {
carId: '',
type: ''
}
this.page.current = 1
this.loadData()
},
handleSizeChange(val) {
this.page.size = val
this.page.current = 1
this.loadData()
},
handleCurrentChange(val) {
this.page.current = val
this.loadData()
},
handleSelectionChange(val) {
this.selections = val
},
handleAdd() {
this.isEdit = false
this.dialogTitle = '新增车辆'
this.resetForm()
this.showDialog = true
},
handleEdit(row) {
this.isEdit = true
this.dialogTitle = '编辑车辆'
// 如果有icon使用icon生成图标URL
const iconPreview = row.fileId ? this.getIconUrl(row.fileId) : ''
this.form = {
...row,
iconPreview: iconPreview
}
this.showDialog = true
},
handleDialogClose() {
this.resetForm()
},
resetForm() {
this.form = {
id: null,
carId: '',
type: '',
icon: '',
iconPreview: '',
fileId: ''
}
if (this.$refs.form) {
this.$refs.form.clearValidate()
}
},
handleSubmit() {
this.$refs.form.validate(valid => {
if (valid) {
this.submitting = true
const request = this.isEdit ? edit(this.form) : add(this.form)
request.then(() => {
this.$message.success(this.isEdit ? '编辑成功' : '新增成功')
this.showDialog = false
this.loadData()
this.submitting = false
}).catch(() => {
this.submitting = false
})
}
})
},
handleDeleteSingle(row) {
this.$confirm('确认删除该车辆吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
del([row.id]).then(() => {
this.$message.success('删除成功')
this.loadData()
})
})
},
handleDelete() {
this.$confirm(`确认删除选中的 ${this.selections.length} 个车辆吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const ids = this.selections.map(item => item.id)
del(ids).then(() => {
this.$message.success('删除成功')
this.loadData()
})
})
},
beforeIconUpload(file) {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
this.$message.error('只能上传图片文件!')
return false
}
if (!isLt2M) {
this.$message.error('图片大小不能超过 2MB!')
return false
}
return true
},
handleIconUpload(params) {
const formData = new FormData()
formData.append('file', params.file)
this.uploadingIcon = true
// 创建本地预览URL
const reader = new FileReader()
reader.onload = (e) => {
this.form.iconPreview = e.target.result
}
reader.readAsDataURL(params.file)
upload(formData).then(res => {
this.form.icon = res.path
this.form.fileId = res.id.toString()
this.$message.success('图片上传成功')
this.uploadingIcon = false
this.$refs.form.validateField('icon')
}).catch(() => {
this.uploadingIcon = false
this.form.iconPreview = null
})
}
}
}
</script>
<style scoped>
.app-container {
padding: 20px;
}
.head-container {
margin-bottom: 20px;
}
.icon-uploader {
display: inline-block;
}
.icon-uploader ::v-deep .el-upload {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s;
}
.icon-uploader ::v-deep .el-upload:hover {
border-color: #409EFF;
}
.icon-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 120px;
height: 120px;
line-height: 120px;
text-align: center;
display: block;
}
.icon-preview {
width: 120px;
height: 120px;
display: block;
object-fit: contain;
}
.upload-tip {
font-size: 12px;
color: #999;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,536 @@
<template>
<div class="app-container">
<div class="head-container">
<el-input
v-model="queryParams.name"
clearable
size="mini"
placeholder="请输入设备名称"
style="width: 200px; margin-right: 10px;"
@keyup.enter.native="handleQuery"
/>
<el-input
v-model="queryParams.code"
clearable
size="mini"
placeholder="请输入设备编码"
style="width: 200px; margin-right: 10px;"
@keyup.enter.native="handleQuery"
/>
<el-input
v-model="queryParams.type"
clearable
size="mini"
placeholder="请输入设备类型"
style="width: 200px; margin-right: 10px;"
@keyup.enter.native="handleQuery"
/>
<el-button
size="mini"
type="primary"
icon="el-icon-search"
@click="handleQuery"
>
查询
</el-button>
<el-button
size="mini"
icon="el-icon-refresh"
@click="handleReset"
>
重置
</el-button>
<el-button
size="mini"
type="primary"
icon="el-icon-plus"
@click="handleAdd"
>
新增
</el-button>
<el-button
size="mini"
type="danger"
icon="el-icon-delete"
:disabled="selections.length === 0"
@click="handleDelete"
>
删除
</el-button>
</div>
<el-table
v-loading="loading"
:data="tableData"
style="width: 100%; margin-top: 20px;"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="icon" label="设备图标" width="100" align="center">
<template slot-scope="scope">
<img
v-if="scope.row.fileId"
:src="getIconUrl(scope.row.fileId)"
style="width: 50px; height: 50px; object-fit: contain;"
@error="handleImageError"
>
<span v-else style="color: #999;">无图标</span>
</template>
</el-table-column>
<el-table-column prop="code" label="设备编码" width="150" show-overflow-tooltip />
<el-table-column prop="name" label="设备名称" width="150" show-overflow-tooltip />
<el-table-column prop="type" label="设备类型" width="120" />
<el-table-column prop="description" label="设备描述" show-overflow-tooltip />
<el-table-column prop="region" label="所属区域" width="120" />
<el-table-column prop="x" label="X坐标" width="80" />
<el-table-column prop="y" label="Y坐标" width="80" />
<el-table-column prop="angle" label="角度" width="80" />
<el-table-column prop="size" label="比例" width="80" />
<el-table-column prop="isUsed" label="状态" width="80" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.isUsed ? 'success' : 'danger'">
{{ scope.row.isUsed ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="160">
<template slot-scope="scope">
{{ parseTime(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-edit"
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
style="color: #f56c6c;"
@click="handleDeleteSingle(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
:current-page="page.current"
:page-sizes="[10, 20, 50, 100]"
:page-size="page.size"
:total="page.total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 20px; text-align: right;"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
<el-dialog
:title="dialogTitle"
:visible.sync="showDialog"
width="800px"
:close-on-click-modal="false"
@close="handleDialogClose"
>
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
<el-row>
<el-col :span="12">
<el-form-item label="设备编码" prop="code">
<el-input v-model="form.code" placeholder="请输入设备编码" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设备名称" prop="name">
<el-input v-model="form.name" placeholder="请输入设备名称" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="设备类型" prop="type">
<el-input v-model="form.type" placeholder="请输入设备类型" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="所属区域" prop="region">
<el-input v-model="form.region" placeholder="请输入所属区域" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="设备描述">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入设备描述"
/>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="设备图标" prop="icon">
<el-upload
class="icon-uploader"
action=""
:show-file-list="false"
:before-upload="beforeIconUpload"
:http-request="handleIconUpload"
accept="image/*"
>
<img v-if="form.iconPreview" :src="form.iconPreview" class="icon-preview">
<i v-else class="el-icon-plus icon-uploader-icon" />
</el-upload>
<div class="upload-tip">支持上传jpgpng等图片格式大小不超过2MB</div>
</el-form-item>
</el-col>
</el-row>
<el-row v-if="isEdit">
<el-col :span="6">
<el-form-item label="X坐标">
<el-input-number v-model="form.x" :min="0" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="Y坐标">
<el-input-number v-model="form.y" :min="0" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="角度">
<el-input-number v-model="form.angle" :min="0" :max="360" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="放大比例">
<el-input-number v-model="form.size" :min="1" style="width: 100%;" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="扩展信息">
<el-input
v-model="form.editParam"
type="textarea"
:rows="3"
placeholder="请输入设备扩展信息"
/>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="是否启用">
<el-switch v-model="form.isUsed" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
确定
</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { query, add, edit, del } from '@/api/layout/device'
import { upload } from '@/api/localStorage'
import { parseTime } from '@/utils/index'
export default {
name: 'Device',
data() {
return {
loading: false,
submitting: false,
tableData: [],
selections: [],
showDialog: false,
dialogTitle: '',
isEdit: false,
queryParams: {
name: '',
code: '',
type: '',
region: ''
},
page: {
current: 1,
size: 10,
total: 0
},
form: {
id: null,
code: '',
name: '',
type: '',
description: '',
editParam: '',
icon: '',
iconPreview: '',
fileId: '',
region: '',
x: 0,
y: 0,
angle: 0,
size: 1,
isUsed: true
},
rules: {
code: [
{ required: true, message: '请输入设备编码', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入设备名称', trigger: 'blur' }
],
type: [
{ required: true, message: '请输入设备类型', trigger: 'blur' }
],
icon: [
{ required: true, message: '请上传设备图标', trigger: 'change' }
]
},
uploadingIcon: false
}
},
mounted() {
this.loadData()
},
methods: {
parseTime,
getIconUrl(fileId) {
if (!fileId) return ''
return `${process.env.NODE_ENV === 'production' ? window.g.prod.VUE_APP_BASE_API : ''}/api/localStorage/download?storageId=${fileId}`
},
handleImageError(e) {
e.target.style.display = 'none'
},
loadData() {
this.loading = true
const params = {
page: this.page.current,
size: this.page.size,
...this.queryParams
}
query(params).then(res => {
this.tableData = res.records || []
this.page.total = res.total || 0
this.page.current = res.current || 1
this.loading = false
}).catch(() => {
this.loading = false
})
},
handleQuery() {
this.page.current = 1
this.loadData()
},
handleReset() {
this.queryParams = {
name: '',
code: '',
type: '',
region: ''
}
this.page.current = 1
this.loadData()
},
handleSizeChange(val) {
this.page.size = val
this.page.current = 1
this.loadData()
},
handleCurrentChange(val) {
this.page.current = val
this.loadData()
},
handleSelectionChange(val) {
this.selections = val
},
handleAdd() {
this.isEdit = false
this.dialogTitle = '新增设备'
this.resetForm()
this.showDialog = true
},
handleEdit(row) {
this.isEdit = true
this.dialogTitle = '编辑设备'
// 如果有fileId使用fileId生成图标URL否则直接使用icon
const iconPreview = row.fileId ? this.getIconUrl(row.fileId) : ''
this.form = {
...row,
iconPreview: iconPreview
}
this.showDialog = true
},
handleDialogClose() {
this.resetForm()
},
resetForm() {
this.form = {
id: null,
code: '',
name: '',
type: '',
description: '',
editParam: '',
icon: '',
iconPreview: '',
fileId: '',
region: '',
x: 0,
y: 0,
angle: 0,
size: 1,
isUsed: true
}
if (this.$refs.form) {
this.$refs.form.clearValidate()
}
},
handleSubmit() {
this.$refs.form.validate(valid => {
if (valid) {
this.submitting = true
const request = this.isEdit ? edit(this.form) : add(this.form)
request.then(() => {
this.$message.success(this.isEdit ? '编辑成功' : '新增成功')
this.showDialog = false
this.loadData()
this.submitting = false
}).catch(() => {
this.submitting = false
})
}
})
},
handleDeleteSingle(row) {
this.$confirm('确认删除该设备吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
del([row.id]).then(() => {
this.$message.success('删除成功')
this.loadData()
})
})
},
handleDelete() {
this.$confirm(`确认删除选中的 ${this.selections.length} 个设备吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const ids = this.selections.map(item => item.id)
del(ids).then(() => {
this.$message.success('删除成功')
this.loadData()
})
})
},
beforeIconUpload(file) {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
this.$message.error('只能上传图片文件!')
return false
}
if (!isLt2M) {
this.$message.error('图片大小不能超过 2MB!')
return false
}
return true
},
handleIconUpload(params) {
const formData = new FormData()
formData.append('file', params.file)
this.uploadingIcon = true
// 创建本地预览URL
const reader = new FileReader()
reader.onload = (e) => {
this.form.iconPreview = e.target.result
}
reader.readAsDataURL(params.file)
upload(formData).then(res => {
this.form.icon = res.path
this.form.fileId = res.id.toString()
this.$message.success('图片上传成功')
this.uploadingIcon = false
this.$refs.form.validateField('icon')
}).catch(() => {
this.uploadingIcon = false
this.form.iconPreview = null
})
}
}
}
</script>
<style scoped>
.app-container {
padding: 20px;
}
.head-container {
margin-bottom: 20px;
}
.icon-uploader {
display: inline-block;
}
.icon-uploader ::v-deep .el-upload {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s;
}
.icon-uploader ::v-deep .el-upload:hover {
border-color: #409EFF;
}
.icon-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 120px;
height: 120px;
line-height: 120px;
text-align: center;
display: block;
}
.icon-preview {
width: 120px;
height: 120px;
display: block;
object-fit: contain;
}
.upload-tip {
font-size: 12px;
color: #999;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,675 @@
<template>
<div class="map-editor-container">
<!-- 左侧设备组件列表 -->
<div class="left-panel">
<div class="panel-header">
<h3>设备组件</h3>
<el-input
v-model="searchText"
size="small"
placeholder="搜索设备"
prefix-icon="el-icon-search"
clearable
/>
</div>
<div class="component-list">
<div
v-for="item in filteredComponents"
:key="item.id"
class="component-item"
draggable="true"
@dragstart="handleDragStart($event, item)"
>
<img
v-if="item.fileId"
:src="getIconUrl(item.fileId)"
class="component-icon"
>
<div class="component-info">
<div class="component-name">{{ item.name }}</div>
<div class="component-type">{{ item.type }}</div>
</div>
</div>
</div>
</div>
<!-- 右侧地图编辑区 -->
<div class="right-panel">
<div class="toolbar">
<el-button type="primary" size="small" icon="el-icon-check" @click="handleSave">
保存
</el-button>
<el-button size="small" icon="el-icon-refresh-left" @click="handleReset">
重置
</el-button>
</div>
<div
class="map-canvas"
@drop="handleDrop"
@dragover="handleDragOver"
@click="handleCanvasClick"
@mousedown="handleCanvasMouseDown"
>
<div class="map-container" :style="containerStyle">
<!-- 地图背景 -->
<img
v-if="mapImage"
ref="mapImg"
:src="mapImage"
:style="mapStyle"
class="map-background"
@load="handleMapLoad"
>
<!-- 地图上的设备组件 -->
<div
v-for="(device, index) in devicesOnMap"
:key="index"
class="device-on-map"
:class="{ active: selectedDeviceIndex === index }"
:style="getDeviceStyle(device)"
@click.stop="handleDeviceClick(index)"
@mousedown="handleDeviceMouseDown($event, index)"
>
<img :src="getIconUrl(device.fileId)" class="device-image" :style="getDeviceImageStyle(device)">
<!-- 操作按钮 -->
<div v-if="selectedDeviceIndex === index" class="device-controls">
<el-button
circle
size="mini"
icon="el-icon-refresh-left"
@click.stop="rotateDevice(index, -15)"
/>
<el-button
circle
size="mini"
icon="el-icon-refresh-right"
@click.stop="rotateDevice(index, 15)"
/>
<el-button
circle
size="mini"
icon="el-icon-zoom-in"
@click.stop="scaleDevice(index, 0.1)"
/>
<el-button
circle
size="mini"
icon="el-icon-zoom-out"
@click.stop="scaleDevice(index, -0.1)"
/>
<el-button
circle
size="mini"
type="danger"
icon="el-icon-delete"
@click.stop="removeDevice(index)"
/>
</div>
</div>
<!-- 原点标记 -->
<div v-if="mapOrigin" class="origin-marker" :style="originStyle">
<i class="el-icon-location" />
</div>
</div>
<!-- 无地图提示 -->
<div v-if="!mapImage" class="empty-map">
<i class="el-icon-picture-outline" />
<p>请上传地图</p>
</div>
</div>
</div>
<!-- 右下角工具栏 -->
<div class="bottom-toolbar">
<el-upload
action=""
:show-file-list="false"
:before-upload="beforeMapUpload"
:http-request="handleMapUpload"
accept="image/*"
>
<el-button size="small" icon="el-icon-upload2">上传地图</el-button>
</el-upload>
<el-button size="small" icon="el-icon-zoom-in" @click="zoomIn">放大</el-button>
<el-button size="small" icon="el-icon-zoom-out" @click="zoomOut">缩小</el-button>
<el-button size="small" icon="el-icon-refresh" @click="resetZoom">100%</el-button>
<el-button
size="small"
icon="el-icon-location"
:type="settingOrigin ? 'primary' : ''"
@click="toggleOriginSetting"
>
{{ settingOrigin ? '点击设置原点' : '设置原点' }}
</el-button>
</div>
</div>
</div>
</template>
<script>
import { getComponentList, saveMapLayout, saveDeviceLayout } from '@/api/layout/map'
import { upload } from '@/api/localStorage'
import { loading } from 'vxe-table'
export default {
name: 'MapEditor',
data() {
return {
searchText: '',
components: [],
devicesOnMap: [],
mapImage: null,
mapImageId: null,
mapImageUrl: null,
mapWidth: 0,
mapHeight: 0,
mapScale: 1,
mapOrigin: null,
selectedDeviceIndex: null,
draggingDevice: null,
dragOffset: { x: 0, y: 0 },
settingOrigin: false,
isDraggingCanvas: false,
canvasOffset: { x: 0, y: 0 },
dragStartPos: { x: 0, y: 0 }
}
},
computed: {
filteredComponents() {
if (!this.searchText) return this.components
const text = this.searchText.toLowerCase()
return this.components.filter(item =>
item.name.toLowerCase().includes(text) ||
item.code.toLowerCase().includes(text) ||
item.type.toLowerCase().includes(text)
)
},
mapStyle() {
return {
transform: `scale(${this.mapScale})`,
transformOrigin: 'top left'
}
},
containerStyle() {
return {
transform: `translate(${this.canvasOffset.x}px, ${this.canvasOffset.y}px)`,
cursor: this.isDraggingCanvas ? 'grabbing' : 'grab'
}
},
originStyle() {
if (!this.mapOrigin) return {}
return {
left: this.mapOrigin.x * this.mapScale + 'px',
top: this.mapOrigin.y * this.mapScale + 'px'
}
}
},
mounted() {
this.loadComponents()
document.addEventListener('mousemove', this.handleMouseMove)
document.addEventListener('mouseup', this.handleMouseUp)
},
beforeDestroy() {
document.removeEventListener('mousemove', this.handleMouseMove)
document.removeEventListener('mouseup', this.handleMouseUp)
},
methods: {
getIconUrl(fileId) {
if (!fileId) return ''
return `${process.env.NODE_ENV === 'production' ? window.g.prod.VUE_APP_BASE_API : ''}/api/localStorage/download?storageId=${fileId}`
},
loadComponents() {
getComponentList().then(res => {
this.components = res || []
})
},
handleDragStart(event, item) {
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData('component', JSON.stringify(item))
},
handleDragOver(event) {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
},
handleDrop(event) {
event.preventDefault()
const componentData = event.dataTransfer.getData('component')
if (!componentData) return
const component = JSON.parse(componentData)
const rect = event.currentTarget.getBoundingClientRect()
const x = (event.clientX - rect.left - this.canvasOffset.x) / this.mapScale
const y = (event.clientY - rect.top - this.canvasOffset.y) / this.mapScale
this.devicesOnMap.push({
deviceId: component.id,
code: component.code,
name: component.name,
type: component.type,
icon: component.icon,
fileId: component.fileId,
x: x,
y: y,
angle: 0,
size: 1
})
},
getDeviceStyle(device) {
return {
left: device.x * this.mapScale + 'px',
top: device.y * this.mapScale + 'px',
transform: `translate(-50%, -50%)`,
transformOrigin: 'center'
}
},
getDeviceImageStyle(device) {
return {
transform: `rotate(${device.angle}deg) scale(${device.size})`
}
},
handleDeviceClick(index) {
this.selectedDeviceIndex = index
},
handleCanvasClick(event) {
if (this.settingOrigin) {
return
}
// 只有在没有拖拽画布时才取消选择
if (!this.isDraggingCanvas) {
this.selectedDeviceIndex = null
}
},
handleCanvasMouseDown(event) {
// 如果点击的是设备,不处理画布拖拽
if (event.target.closest('.device-on-map')) {
return
}
// 右键或中键拖拽画布
if (event.button === 1 || event.button === 2 || (event.button === 0 && !this.mapImage)) {
return
}
// 如果有地图,左键也可以拖拽画布
if (this.mapImage && event.button === 0) {
this.isDraggingCanvas = true
this.dragStartPos = {
x: event.clientX - this.canvasOffset.x,
y: event.clientY - this.canvasOffset.y
}
event.preventDefault()
}
},
handleDeviceMouseDown(event, index) {
event.stopPropagation()
this.draggingDevice = index
const device = this.devicesOnMap[index]
this.dragOffset = {
x: (event.clientX - this.canvasOffset.x) / this.mapScale - device.x,
y: (event.clientY - this.canvasOffset.y) / this.mapScale - device.y
}
},
handleMouseMove(event) {
if (this.isDraggingCanvas) {
this.canvasOffset = {
x: event.clientX - this.dragStartPos.x,
y: event.clientY - this.dragStartPos.y
}
} else if (this.draggingDevice !== null) {
const device = this.devicesOnMap[this.draggingDevice]
device.x = (event.clientX - this.canvasOffset.x) / this.mapScale - this.dragOffset.x
device.y = (event.clientY - this.canvasOffset.y) / this.mapScale - this.dragOffset.y
this.$forceUpdate()
}
},
handleMouseUp() {
this.isDraggingCanvas = false
this.draggingDevice = null
},
rotateDevice(index, angle) {
this.devicesOnMap[index].angle += angle
},
scaleDevice(index, delta) {
const device = this.devicesOnMap[index]
device.size = Math.max(0.1, Math.min(5, device.size + delta))
},
removeDevice(index) {
this.devicesOnMap.splice(index, 1)
this.selectedDeviceIndex = null
},
beforeMapUpload(file) {
const isImage = file.type.startsWith('image/')
if (!isImage) {
this.$message.error('只能上传图片文件!')
return false
}
return true
},
handleMapUpload(params) {
const formData = new FormData()
formData.append('file', params.file)
upload(formData).then(res => {
this.mapImageId = res.id
this.mapImageUrl = res.path
const reader = new FileReader()
reader.onload = (e) => {
this.mapImage = e.target.result
}
reader.readAsDataURL(params.file)
this.$message.success('地图上传成功')
})
},
handleMapLoad(event) {
this.mapWidth = event.target.naturalWidth
this.mapHeight = event.target.naturalHeight
},
zoomIn() {
this.mapScale = Math.min(3, this.mapScale + 0.1)
},
zoomOut() {
this.mapScale = Math.max(0.1, this.mapScale - 0.1)
},
resetZoom() {
this.mapScale = 1
},
toggleOriginSetting() {
this.settingOrigin = !this.settingOrigin
if (this.settingOrigin) {
const canvas = document.querySelector('.map-canvas')
const handler = (event) => {
const rect = canvas.getBoundingClientRect()
this.mapOrigin = {
x: (event.clientX - rect.left - this.canvasOffset.x) / this.mapScale,
y: (event.clientY - rect.top - this.canvasOffset.y) / this.mapScale
}
this.settingOrigin = false
canvas.removeEventListener('click', handler)
this.$message.success('原点设置成功')
}
canvas.addEventListener('click', handler)
}
},
handleSave() {
if (!this.mapImage) {
this.$message.warning('请先上传地图')
return
}
if (!this.mapOrigin) {
this.$message.warning('请先设置地图原点')
return
}
if (this.devicesOnMap.length === 0) {
this.$message.warning('请至少添加一个设备组件')
return
}
let loading
this.$confirm('确认保存地图布局吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
loading = this.$loading({
lock: true,
text: '保存中...',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'
})
// 保存设备布局
const newList = this.devicesOnMap.map(device => ({
...device,
deviceId: device.deviceId,
code: device.code,
name: device.name,
type: device.type,
x: Math.round(device.x),
y: Math.round(device.y),
angle: Math.round(device.angle),
size: Math.round(device.size * 100),
icon: device.icon,
fileId: device.fileId
}))
// 保存地图信息
const mapData = {
mapId: this.mapImageId.toString(),
mapName: '地图' + new Date().getTime(),
version: '1.0',
url: this.mapImageUrl,
width: this.mapWidth,
height: this.mapHeight,
x: Math.round(this.mapOrigin.x),
y: Math.round(this.mapOrigin.y),
enable: true,
device: newList
}
console.log('保存地图数据:', mapData)
console.log('设备列表:', newList)
saveMapLayout(mapData).then(mapRes => {
debugger
loading.close()
this.$message.success('地图保存成功')
}).catch(err => {
loading.close()
console.error('地图保存失败:', err)
this.$message.error('地图保存失败: ' + (err.message || '未知错误'))
})
})
},
handleReset() {
this.$confirm('确认重置地图吗? 所有未保存的修改将丢失', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.devicesOnMap = []
this.mapImage = null
this.mapImageId = null
this.mapOrigin = null
this.mapScale = 1
this.canvasOffset = { x: 0, y: 0 }
this.selectedDeviceIndex = null
this.$message.success('已重置')
})
}
}
}
</script>
<style scoped>
.map-editor-container {
display: flex;
height: calc(100vh - 84px);
background: #f5f5f5;
}
.left-panel {
width: 20%;
min-width: 250px;
max-width: 350px;
background: white;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.panel-header {
padding: 16px;
border-bottom: 1px solid #e0e0e0;
}
.panel-header h3 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 500;
}
.component-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.component-item {
display: flex;
align-items: center;
padding: 12px;
margin-bottom: 8px;
background: #fafafa;
border: 1px solid #e0e0e0;
border-radius: 4px;
cursor: move;
transition: all 0.3s;
}
.component-item:hover {
background: #f0f0f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.component-icon {
width: 40px;
height: 40px;
object-fit: contain;
margin-right: 12px;
}
.component-info {
flex: 1;
}
.component-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.component-type {
font-size: 12px;
color: #999;
}
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
}
.toolbar {
padding: 12px 16px;
background: white;
border-bottom: 1px solid #e0e0e0;
}
.map-canvas {
flex: 1;
position: relative;
overflow: hidden;
background: #fff;
background-image:
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
}
.map-container {
position: relative;
width: fit-content;
height: fit-content;
min-width: 100%;
min-height: 100%;
will-change: transform;
}
.map-background {
display: block;
max-width: none;
user-select: none;
pointer-events: none;
}
.device-on-map {
position: absolute;
cursor: move;
user-select: none;
}
.device-on-map.active {
z-index: 1000;
}
.device-image {
width: 50px;
height: 50px;
object-fit: contain;
pointer-events: none;
}
.device-controls {
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 4px;
background: white;
padding: 4px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
white-space: nowrap;
}
.origin-marker {
position: absolute;
width: 20px;
height: 20px;
color: #f56c6c;
font-size: 20px;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 999;
}
.empty-map {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #999;
}
.empty-map i {
font-size: 64px;
margin-bottom: 16px;
}
.empty-map p {
font-size: 16px;
margin: 0;
}
.bottom-toolbar {
position: absolute;
bottom: 16px;
right: 16px;
display: flex;
gap: 8px;
background: white;
padding: 8px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1001;
pointer-events: auto;
}
.bottom-toolbar ::v-deep .el-button {
pointer-events: auto;
}
</style>

View File

@@ -0,0 +1,423 @@
<template>
<div class="app-container">
<div class="head-container">
<el-input
v-model="queryParams.mapName"
clearable
size="mini"
placeholder="请输入地图名称"
style="width: 200px; margin-right: 10px;"
@keyup.enter.native="handleQuery"
/>
<el-input
v-model="queryParams.mapId"
clearable
size="mini"
placeholder="请输入地图ID"
style="width: 200px; margin-right: 10px;"
@keyup.enter.native="handleQuery"
/>
<el-button
size="mini"
type="primary"
icon="el-icon-search"
@click="handleQuery"
>
查询
</el-button>
<el-button
size="mini"
icon="el-icon-refresh"
@click="handleReset"
>
重置
</el-button>
<el-button
size="mini"
type="danger"
icon="el-icon-delete"
:disabled="selections.length === 0"
@click="handleDelete"
>
删除
</el-button>
</div>
<el-table
v-loading="loading"
:data="tableData"
style="width: 100%; margin-top: 20px;"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="mapId" label="地图ID" width="120" />
<el-table-column prop="mapName" label="地图名称" width="200" show-overflow-tooltip />
<el-table-column prop="version" label="版本" width="120" />
<el-table-column prop="width" label="宽度" width="100" />
<el-table-column prop="height" label="高度" width="100" />
<el-table-column prop="x" label="原点X" width="100" />
<el-table-column prop="y" label="原点Y" width="100" />
<el-table-column prop="enable" label="状态" width="100" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.enable ? 'success' : 'danger'">
{{ scope.row.enable ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-view"
@click="handleViewDetail(scope.row)"
>
详情
</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
style="color: #f56c6c;"
@click="handleDeleteSingle(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
:current-page="page.current"
:page-sizes="[10, 20, 50, 100]"
:page-size="page.size"
:total="page.total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 20px; text-align: right;"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
<!-- 地图详情对话框 -->
<el-dialog
title="地图详情"
:visible.sync="showDetailDialog"
width="90%"
top="5vh"
:close-on-click-modal="false"
>
<div v-if="mapDetail" class="map-detail-container">
<div class="detail-info">
<el-descriptions :column="3" border>
<el-descriptions-item label="地图ID">{{ mapDetail.mapId }}</el-descriptions-item>
<el-descriptions-item label="地图名称">{{ mapDetail.mapName || '-' }}</el-descriptions-item>
<el-descriptions-item label="版本">{{ mapDetail.version || '-' }}</el-descriptions-item>
<el-descriptions-item label="宽度">{{ mapDetail.width }}px</el-descriptions-item>
<el-descriptions-item label="高度">{{ mapDetail.height }}px</el-descriptions-item>
<el-descriptions-item label="原点坐标">X: {{ mapDetail.x }}, Y: {{ mapDetail.y }}</el-descriptions-item>
<el-descriptions-item label="设备数量" :span="3">{{ mapDetail.device ? mapDetail.device.length : 0 }} </el-descriptions-item>
</el-descriptions>
</div>
<div class="map-preview">
<div class="preview-toolbar">
<el-button size="small" icon="el-icon-zoom-in" @click="previewScale += 0.1">放大</el-button>
<el-button size="small" icon="el-icon-zoom-out" @click="previewScale = Math.max(0.1, previewScale - 0.1)">缩小</el-button>
<el-button size="small" icon="el-icon-refresh" @click="previewScale = 1">重置</el-button>
<span style="margin-left: 10px;">缩放: {{ Math.round(previewScale * 100) }}%</span>
</div>
<div class="preview-canvas">
<div class="preview-container" :style="previewContainerStyle">
<!-- 地图背景 -->
<img
v-if="mapDetail.url"
:src="getMapUrl(mapDetail.mapId)"
:style="previewMapStyle"
class="preview-map-image"
>
<!-- 设备组件 -->
<div
v-for="(device, index) in mapDetail.device"
:key="index"
class="preview-device"
:style="getPreviewDeviceStyle(device)"
>
<img
:src="getDeviceIconUrl(device)"
class="preview-device-image"
:style="getPreviewDeviceImageStyle(device)"
>
<div class="device-label">{{ device.name }}</div>
</div>
<!-- 原点标记 -->
<div class="preview-origin" :style="getPreviewOriginStyle()">
<i class="el-icon-location" />
</div>
</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import { getMapList, getMapDetail, deleteMap } from '@/api/layout/mapManage'
import { parseTime } from '@/utils/index'
export default {
name: 'MapManage',
data() {
return {
loading: false,
tableData: [],
selections: [],
queryParams: {
mapName: '',
mapId: ''
},
page: {
current: 1,
size: 10,
total: 0
},
showDetailDialog: false,
mapDetail: null,
previewScale: 1
}
},
computed: {
previewContainerStyle() {
return {
transform: `scale(${this.previewScale})`,
transformOrigin: 'top left'
}
},
previewMapStyle() {
if (!this.mapDetail) return {}
return {
width: this.mapDetail.width + 'px',
height: this.mapDetail.height + 'px'
}
}
},
mounted() {
this.loadData()
},
methods: {
parseTime,
getMapUrl(mapId) {
return `${process.env.NODE_ENV === 'production' ? window.g.prod.VUE_APP_BASE_API : ''}/api/localStorage/download?storageId=${mapId}`
},
getDeviceIconUrl(device) {
// 从icon路径中提取文件名然后通过fileId获取
// 这里假设有fileId字段如果没有需要从icon路径解析
if (device.icon) {
// 简单处理直接使用deviceId作为fileId需要根据实际情况调整
return `${process.env.NODE_ENV === 'production' ? window.g.prod.VUE_APP_BASE_API : ''}/api/localStorage/download?storageId=${device.fileId}`
}
return ''
},
loadData() {
this.loading = true
const params = {
page: this.page.current,
size: this.page.size,
...this.queryParams
}
getMapList(params).then(res => {
this.tableData = res.records || []
this.page.total = res.total || 0
this.page.current = res.current || 1
this.loading = false
}).catch(() => {
this.loading = false
})
},
handleQuery() {
this.page.current = 1
this.loadData()
},
handleReset() {
this.queryParams = {
mapName: '',
mapId: ''
}
this.page.current = 1
this.loadData()
},
handleSizeChange(val) {
this.page.size = val
this.page.current = 1
this.loadData()
},
handleCurrentChange(val) {
this.page.current = val
this.loadData()
},
handleSelectionChange(val) {
this.selections = val
},
handleViewDetail(row) {
const loading = this.$loading({
lock: true,
text: '加载中...',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'
})
getMapDetail(row.id).then(res => {
this.mapDetail = res
this.previewScale = 1
this.showDetailDialog = true
loading.close()
}).catch(() => {
loading.close()
})
},
getPreviewDeviceStyle(device) {
return {
left: device.x + 'px',
top: device.y + 'px',
transform: 'translate(-50%, -50%)'
}
},
getPreviewDeviceImageStyle(device) {
const scale = device.size / 100
return {
transform: `rotate(${device.angle}deg) scale(${scale})`
}
},
getPreviewOriginStyle() {
if (!this.mapDetail) return {}
return {
left: this.mapDetail.x + 'px',
top: this.mapDetail.y + 'px'
}
},
handleDeleteSingle(row) {
this.$confirm('确认删除该地图吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteMap([row.id]).then(() => {
this.$message.success('删除成功')
this.loadData()
})
})
},
handleDelete() {
this.$confirm(`确认删除选中的 ${this.selections.length} 个地图吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const ids = this.selections.map(item => item.id)
deleteMap(ids).then(() => {
this.$message.success('删除成功')
this.loadData()
})
})
}
}
}
</script>
<style scoped>
.app-container {
padding: 20px;
}
.head-container {
margin-bottom: 20px;
}
.map-detail-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.detail-info {
margin-bottom: 20px;
}
.map-preview {
border: 1px solid #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
.preview-toolbar {
padding: 12px;
background: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
gap: 8px;
}
.preview-canvas {
height: 600px;
overflow: auto;
background: #fff;
background-image:
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
position: relative;
}
.preview-container {
position: relative;
width: fit-content;
height: fit-content;
min-width: 100%;
min-height: 100%;
}
.preview-map-image {
display: block;
user-select: none;
}
.preview-device {
position: absolute;
cursor: default;
}
.preview-device-image {
width: 50px;
height: 50px;
object-fit: contain;
display: block;
}
.device-label {
position: absolute;
bottom: -20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 8px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
}
.preview-origin {
position: absolute;
width: 20px;
height: 20px;
color: #f56c6c;
font-size: 24px;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 999;
}
</style>

View File

@@ -0,0 +1,969 @@
<template>
<div class="screen-container">
<!-- 顶部标题栏 -->
<div class="screen-header">
<div class="header-left">
<i class="el-icon-monitor" />
<span class="title">AGV实时监控看板</span>
</div>
<div class="header-center">
<span class="time">{{ currentTime }}</span>
</div>
<div class="header-right">
<el-select v-model="selectedMapId" size="small" placeholder="选择地图" @change="handleMapChange">
<el-option
v-for="map in mapList"
:key="map.id"
:label="map.mapName || `地图${map.id}`"
:value="map.id"
/>
</el-select>
<el-button size="small" :type="autoRefresh ? 'success' : 'info'" @click="toggleAutoRefresh">
<i :class="autoRefresh ? 'el-icon-video-play' : 'el-icon-video-pause'" />
{{ autoRefresh ? '自动刷新' : '已暂停' }}
</el-button>
</div>
</div>
<!-- 主体内容 -->
<div class="screen-body">
<!-- 左侧统计面板 -->
<div class="left-panel">
<div class="stat-card">
<div class="stat-icon" style="background: #67c23a;">
<i class="el-icon-success" />
</div>
<div class="stat-content">
<div class="stat-value">{{ agvStats.normal }}</div>
<div class="stat-label">正常运行</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: #909399;">
<i class="el-icon-coffee-cup" />
</div>
<div class="stat-content">
<div class="stat-value">{{ agvStats.rest }}</div>
<div class="stat-label">休息待机</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: #f56c6c;">
<i class="el-icon-warning" />
</div>
<div class="stat-content">
<div class="stat-value">{{ agvStats.error }}</div>
<div class="stat-label">异常故障</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: #409eff;">
<i class="el-icon-data-line" />
</div>
<div class="stat-content">
<div class="stat-value">{{ agvStats.total }}</div>
<div class="stat-label">AGV总数</div>
</div>
</div>
<!-- AGV列表 -->
<div class="agv-list-panel">
<div class="panel-title">AGV列表</div>
<div class="agv-list">
<div
v-for="agv in agvList"
:key="agv.carId"
class="agv-item"
:class="{ active: selectedAgv && selectedAgv.carId === agv.carId }"
@click="handleAgvClick(agv)"
>
<div class="agv-item-header">
<span class="agv-id">{{ agv.carId }}</span>
<el-tag :type="getStatusTagType(agv.status)" size="mini">
{{ agv.statusInfo }}
</el-tag>
</div>
<div class="agv-item-info">
<div class="info-row">
<span class="label">任务:</span>
<span class="value">{{ agv.taskCode || '-' }}</span>
</div>
<div class="info-row">
<span class="label">电量:</span>
<span class="value">
<el-progress
:percentage="agv.power"
:color="getPowerColor(agv.power)"
:show-text="false"
style="width: 60px;"
/>
{{ agv.power }}%
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 中间地图区域 -->
<div class="center-panel">
<div class="map-toolbar">
<el-button-group>
<el-button size="small" icon="el-icon-zoom-in" @click="zoomIn">放大</el-button>
<el-button size="small" icon="el-icon-zoom-out" @click="zoomOut">缩小</el-button>
<el-button size="small" icon="el-icon-refresh" @click="resetZoom">重置</el-button>
</el-button-group>
<span class="zoom-info">{{ Math.round(mapScale * 100) }}%</span>
</div>
<div class="map-canvas" @mousedown="handleCanvasMouseDown">
<div class="map-container" :style="containerStyle">
<!-- 地图背景 -->
<img
v-if="mapDetail && mapDetail.mapId"
:src="getMapUrl(mapDetail.mapId)"
:style="mapStyle"
class="map-image"
>
<!-- 静态设备组件 -->
<div
v-for="(device, index) in staticDevices"
:key="'device-' + index"
class="static-device"
:style="getDeviceStyle(device)"
>
<img
:src="getDeviceIconUrl(device)"
class="device-image"
:style="getDeviceImageStyle(device)"
>
</div>
<!-- AGV车辆 -->
<div
v-for="agv in agvList"
:key="'agv-' + agv.carId"
class="agv-vehicle"
:class="{
active: selectedAgv && selectedAgv.carId === agv.carId,
'status-normal': agv.status === 2 && !isRequestAction(agv.action),
'status-rest': agv.status === 1,
'status-error': agv.status === 3,
'status-request': isRequestAction(agv.action)
}"
:style="getAgvStyle(agv)"
@click="handleAgvClick(agv)"
>
<img
:src="getAgvIcon(agv)"
class="agv-image"
:style="getAgvImageStyle(agv)"
>
<div class="agv-label">
<div class="agv-id-label">{{ agv.carId }}</div>
<div class="agv-power-label" :class="getPowerClass(agv.power)">
<i class="el-icon-lightning" /> {{ agv.power }}%
</div>
</div>
</div>
<!-- 原点标记 -->
<div v-if="mapDetail" class="origin-marker" :style="originStyle">
<i class="el-icon-location" />
</div>
</div>
<!-- 无地图提示 -->
<div v-if="!mapDetail" class="empty-map">
<i class="el-icon-map-location" />
<p>请选择地图</p>
</div>
</div>
</div>
<!-- 右侧详情面板 -->
<div v-if="selectedAgv" class="right-panel">
<div class="panel-title">
AGV详情
<el-button
type="text"
icon="el-icon-close"
style="float: right; color: #fff;"
@click="selectedAgv = null"
/>
</div>
<div class="agv-detail">
<div class="detail-section">
<div class="section-title">基本信息</div>
<div class="detail-item">
<span class="label">车辆ID:</span>
<span class="value">{{ selectedAgv.carId }}</span>
</div>
<div class="detail-item">
<span class="label">车辆类型:</span>
<span class="value">{{ selectedAgv.type }}</span>
</div>
<div class="detail-item">
<span class="label">状态:</span>
<el-tag :type="getStatusTagType(selectedAgv.status)">
{{ selectedAgv.statusInfo }}
</el-tag>
</div>
</div>
<div class="detail-section">
<div class="section-title">任务信息</div>
<div class="detail-item">
<span class="label">任务号:</span>
<span class="value">{{ selectedAgv.taskCode || '-' }}</span>
</div>
<div class="detail-item">
<span class="label">动作状态:</span>
<span class="value">{{ selectedAgv.action || '-' }}</span>
</div>
<div class="detail-item">
<span class="label">交互信息:</span>
<span class="value">{{ selectedAgv.actionInfo || '-' }}</span>
</div>
</div>
<div class="detail-section">
<div class="section-title">位置信息</div>
<div class="detail-item">
<span class="label">坐标X:</span>
<span class="value">{{ selectedAgv.x }}</span>
</div>
<div class="detail-item">
<span class="label">坐标Y:</span>
<span class="value">{{ selectedAgv.y }}</span>
</div>
<div class="detail-item">
<span class="label">航向角:</span>
<span class="value">{{ selectedAgv.angle }}°</span>
</div>
</div>
<div class="detail-section">
<div class="section-title">电量信息</div>
<el-progress
:percentage="selectedAgv.power"
:color="getPowerColor(selectedAgv.power)"
:stroke-width="20"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { getMapDetail, getAgvStatus } from '@/api/layout/screen'
import { getMapList } from '@/api/layout/mapManage'
export default {
name: 'AgvScreen',
data() {
return {
currentTime: '',
mapList: [],
selectedMapId: null,
mapDetail: null,
agvList: [],
selectedAgv: null,
mapScale: 1,
canvasOffset: { x: 0, y: 0 },
isDragging: false,
dragStart: { x: 0, y: 0 },
autoRefresh: true,
refreshTimer: null
}
},
computed: {
staticDevices() {
return this.mapDetail && this.mapDetail.device ? this.mapDetail.device : []
},
agvStats() {
const stats = { total: 0, normal: 0, rest: 0, error: 0 }
this.agvList.forEach(agv => {
stats.total++
if (agv.status === 1) stats.rest++
else if (agv.status === 2) stats.normal++
else if (agv.status === 3) stats.error++
})
return stats
},
containerStyle() {
return {
transform: `translate(${this.canvasOffset.x}px, ${this.canvasOffset.y}px)`,
cursor: this.isDragging ? 'grabbing' : 'grab'
}
},
mapStyle() {
if (!this.mapDetail) return {}
return {
width: this.mapDetail.width + 'px',
height: this.mapDetail.height + 'px',
transform: `scale(${this.mapScale})`,
transformOrigin: 'top left'
}
},
originStyle() {
if (!this.mapDetail) return {}
return {
left: this.mapDetail.x * this.mapScale + 'px',
top: this.mapDetail.y * this.mapScale + 'px'
}
}
},
mounted() {
this.updateTime()
setInterval(this.updateTime, 1000)
this.loadMapList()
this.startAutoRefresh()
document.addEventListener('mousemove', this.handleMouseMove)
document.addEventListener('mouseup', this.handleMouseUp)
},
beforeDestroy() {
this.stopAutoRefresh()
document.removeEventListener('mousemove', this.handleMouseMove)
document.removeEventListener('mouseup', this.handleMouseUp)
},
methods: {
updateTime() {
const now = new Date()
this.currentTime = now.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
},
loadMapList() {
getMapList({ page: 1, size: 100 }).then(res => {
this.mapList = res.records || []
if (this.mapList.length > 0 && !this.selectedMapId) {
this.selectedMapId = this.mapList[0].id
this.loadMapDetail()
}
})
},
handleMapChange() {
this.loadMapDetail()
},
loadMapDetail() {
if (!this.selectedMapId) return
getMapDetail(this.selectedMapId).then(res => {
this.mapDetail = res
this.mapScale = 1
this.canvasOffset = { x: 0, y: 0 }
})
},
loadAgvStatus() {
getAgvStatus().then(res => {
this.agvList = res || []
})
},
startAutoRefresh() {
this.loadAgvStatus()
this.refreshTimer = setInterval(() => {
if (this.autoRefresh) {
this.loadAgvStatus()
}
}, 3000)
},
stopAutoRefresh() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer)
this.refreshTimer = null
}
},
toggleAutoRefresh() {
this.autoRefresh = !this.autoRefresh
if (this.autoRefresh) {
this.loadAgvStatus()
}
},
getMapUrl(mapId) {
return `${process.env.NODE_ENV === 'production' ? window.g.prod.VUE_APP_BASE_API : ''}/api/localStorage/download?storageId=${mapId}`
},
getDeviceIconUrl(device) {
return `${process.env.NODE_ENV === 'production' ? window.g.prod.VUE_APP_BASE_API : ''}/api/localStorage/download?storageId=${device.fileId}`
},
getAgvIcon(agv) {
if (agv.fileId) {
return `${process.env.NODE_ENV === 'production' ? window.g.prod.VUE_APP_BASE_API : ''}/api/localStorage/download?storageId=${agv.fileId}`
}
return ''
},
getDeviceStyle(device) {
return {
left: device.x * this.mapScale + 'px',
top: device.y * this.mapScale + 'px',
transform: 'translate(-50%, -50%)'
}
},
getDeviceImageStyle(device) {
const scale = device.size / 100
return {
transform: `rotate(${device.angle}deg) scale(${scale})`
}
},
getAgvStyle(agv) {
return {
left: agv.x * this.mapScale + 'px',
top: agv.y * this.mapScale + 'px',
transform: 'translate(-50%, -50%)'
}
},
getAgvImageStyle(agv) {
return {
transform: `rotate(${agv.angle}deg)`
}
},
handleAgvClick(agv) {
this.selectedAgv = agv
},
isRequestAction(action) {
if (!action) return false
const requestActions = ['请求取货', '请求放货']
return requestActions.some(req => action.includes(req))
},
getStatusTagType(status) {
if (status === 1) return 'info'
if (status === 2) return 'success'
if (status === 3) return 'danger'
return 'warning'
},
getPowerColor(power) {
if (power > 50) return '#67c23a'
if (power > 20) return '#e6a23c'
return '#f56c6c'
},
getPowerClass(power) {
if (power > 50) return 'power-high'
if (power > 20) return 'power-medium'
return 'power-low'
},
zoomIn() {
this.mapScale = Math.min(3, this.mapScale + 0.1)
},
zoomOut() {
this.mapScale = Math.max(0.3, this.mapScale - 0.1)
},
resetZoom() {
this.mapScale = 1
this.canvasOffset = { x: 0, y: 0 }
},
handleCanvasMouseDown(event) {
if (event.target.closest('.agv-vehicle')) return
this.isDragging = true
this.dragStart = {
x: event.clientX - this.canvasOffset.x,
y: event.clientY - this.canvasOffset.y
}
},
handleMouseMove(event) {
if (this.isDragging) {
this.canvasOffset = {
x: event.clientX - this.dragStart.x,
y: event.clientY - this.dragStart.y
}
}
},
handleMouseUp() {
this.isDragging = false
}
}
}
</script>
<style scoped>
.screen-container {
width: 100vw;
height: 100vh;
background: #0a0e27;
display: flex;
flex-direction: column;
overflow: hidden;
}
.screen-header {
height: 60px;
background: linear-gradient(90deg, #1a1f3a 0%, #2d3561 100%);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
color: #fff;
}
.header-left i {
font-size: 28px;
color: #409eff;
}
.title {
font-size: 24px;
font-weight: bold;
background: linear-gradient(90deg, #409eff, #67c23a);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header-center .time {
font-size: 18px;
color: #fff;
font-family: 'Courier New', monospace;
}
.header-right {
display: flex;
gap: 12px;
}
.screen-body {
flex: 1;
display: flex;
gap: 16px;
padding: 16px;
overflow: hidden;
min-height: 0;
}
.left-panel {
width: 280px;
min-width: 250px;
max-width: 280px;
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
min-height: 0;
}
.stat-card {
background: linear-gradient(135deg, #1e2139 0%, #2a2f4a 100%);
border-radius: 8px;
padding: 16px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.stat-icon {
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #fff;
}
.stat-label {
font-size: 12px;
color: #8b92b2;
margin-top: 4px;
}
.agv-list-panel {
flex: 1;
background: linear-gradient(135deg, #1e2139 0%, #2a2f4a 100%);
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-title {
font-size: 16px;
font-weight: bold;
color: #fff;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid #409eff;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: -16px;
background: linear-gradient(135deg, #1e2139 0%, #2a2f4a 100%);
z-index: 10;
margin: -16px -16px 12px -16px;
padding: 16px 16px 8px 16px;
}
.agv-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.agv-item {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 12px;
cursor: pointer;
transition: all 0.3s;
}
.agv-item:hover {
background: rgba(255, 255, 255, 0.1);
border-color: #409eff;
}
.agv-item.active {
background: rgba(64, 158, 255, 0.2);
border-color: #409eff;
}
.agv-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.agv-id {
font-size: 14px;
font-weight: bold;
color: #fff;
}
.agv-item-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-row {
display: flex;
justify-content: space-between;
font-size: 12px;
}
.info-row .label {
color: #8b92b2;
}
.info-row .value {
color: #fff;
display: flex;
align-items: center;
gap: 8px;
}
.center-panel {
flex: 1;
min-width: 0;
background: linear-gradient(135deg, #1e2139 0%, #2a2f4a 100%);
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
transition: all 0.3s ease;
min-height: 0;
}
.map-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.zoom-info {
color: #fff;
font-size: 14px;
}
.map-canvas {
flex: 1;
position: relative;
overflow: hidden;
background: #0f1219;
border-radius: 4px;
background-image:
linear-gradient(rgba(64, 158, 255, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(64, 158, 255, 0.1) 1px, transparent 1px);
background-size: 20px 20px;
}
.map-container {
position: relative;
width: fit-content;
height: fit-content;
min-width: 100%;
min-height: 100%;
}
.map-image {
display: block;
user-select: none;
pointer-events: none;
}
.static-device {
position: absolute;
opacity: 0.6;
}
.device-image {
width: 50px;
height: 50px;
object-fit: contain;
}
.agv-vehicle {
position: absolute;
cursor: pointer;
transition: all 0.3s;
z-index: 100;
}
.agv-vehicle.active {
z-index: 200;
filter: drop-shadow(0 0 10px #409eff);
}
.agv-vehicle.status-normal {
filter: drop-shadow(0 0 8px #67c23a);
}
.agv-vehicle.status-rest {
filter: drop-shadow(0 0 8px #909399);
}
.agv-vehicle.status-error {
filter: drop-shadow(0 0 8px #f56c6c);
animation: blink 1s infinite;
}
.agv-vehicle.status-request {
filter: drop-shadow(0 0 10px #e6a23c);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes pulse {
0%, 100% {
filter: drop-shadow(0 0 8px #e6a23c);
}
50% {
filter: drop-shadow(0 0 15px #e6a23c);
}
}
.agv-image {
width: 60px;
height: 60px;
object-fit: contain;
}
.agv-label {
position: absolute;
bottom: -30px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
white-space: nowrap;
}
.agv-id-label {
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 2px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: bold;
}
.agv-power-label {
background: rgba(0, 0, 0, 0.8);
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
display: flex;
align-items: center;
gap: 2px;
}
.agv-power-label.power-high {
color: #67c23a;
}
.agv-power-label.power-medium {
color: #e6a23c;
}
.agv-power-label.power-low {
color: #f56c6c;
}
.origin-marker {
position: absolute;
width: 20px;
height: 20px;
color: #f56c6c;
font-size: 24px;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 50;
}
.empty-map {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #8b92b2;
}
.empty-map i {
font-size: 64px;
margin-bottom: 16px;
}
.empty-map p {
font-size: 16px;
margin: 0;
}
.right-panel {
width: 320px;
min-width: 280px;
max-width: 320px;
background: linear-gradient(135deg, #1e2139 0%, #2a2f4a 100%);
border-radius: 8px;
padding: 16px;
overflow-y: auto;
overflow-x: hidden;
flex-shrink: 0;
animation: slideInRight 0.3s ease;
min-height: 0;
max-height: 100%;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.agv-detail {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-section {
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
padding: 12px;
}
.section-title {
font-size: 14px;
font-weight: bold;
color: #409eff;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(64, 158, 255, 0.3);
}
.detail-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
font-size: 13px;
}
.detail-item .label {
color: #8b92b2;
}
.detail-item .value {
color: #fff;
font-weight: 500;
}
.no-selection {
text-align: center;
color: #8b92b2;
padding: 40px 20px;
}
.no-selection i {
font-size: 48px;
margin-bottom: 12px;
}
.no-selection p {
font-size: 14px;
margin: 0;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: rgba(64, 158, 255, 0.5);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(64, 158, 255, 0.7);
}
</style>