add:AGV信息
This commit is contained in:
42
acs2/nladmin-ui/src/api/layout/car.js
Normal file
42
acs2/nladmin-ui/src/api/layout/car.js
Normal 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 }
|
||||
42
acs2/nladmin-ui/src/api/layout/device.js
Normal file
42
acs2/nladmin-ui/src/api/layout/device.js
Normal 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 }
|
||||
42
acs2/nladmin-ui/src/api/layout/map.js
Normal file
42
acs2/nladmin-ui/src/api/layout/map.js
Normal 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 }
|
||||
27
acs2/nladmin-ui/src/api/layout/mapManage.js
Normal file
27
acs2/nladmin-ui/src/api/layout/mapManage.js
Normal 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 }
|
||||
18
acs2/nladmin-ui/src/api/layout/screen.js
Normal file
18
acs2/nladmin-ui/src/api/layout/screen.js
Normal 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 }
|
||||
39
acs2/nladmin-ui/src/api/localStorage.js
Normal file
39
acs2/nladmin-ui/src/api/localStorage.js
Normal 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 }
|
||||
407
acs2/nladmin-ui/src/views/layout/car/index.vue
Normal file
407
acs2/nladmin-ui/src/views/layout/car/index.vue
Normal 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">支持上传jpg、png等图片格式,大小不超过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>
|
||||
536
acs2/nladmin-ui/src/views/layout/device/index.vue
Normal file
536
acs2/nladmin-ui/src/views/layout/device/index.vue
Normal 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">支持上传jpg、png等图片格式,大小不超过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>
|
||||
675
acs2/nladmin-ui/src/views/layout/map/index.vue
Normal file
675
acs2/nladmin-ui/src/views/layout/map/index.vue
Normal 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>
|
||||
423
acs2/nladmin-ui/src/views/layout/mapManage/index.vue
Normal file
423
acs2/nladmin-ui/src/views/layout/mapManage/index.vue
Normal 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>
|
||||
969
acs2/nladmin-ui/src/views/layout/screen/index.vue
Normal file
969
acs2/nladmin-ui/src/views/layout/screen/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user