Commit 1052554200c27d3ebb8fef826a9e3023f9886541
1 parent
59fd5759
feat(map): 替换腾讯地图为高德地图并优化检查点功能
- 将腾讯地图API替换为高德地图API - 移除检查点经纬度输入框的禁用状态 - 重构地图组件中的标记点和路径绘制逻辑 - 添加地图覆盖物清理功能 - 更新登录页面UI设计 - 修改系统标题和公司名称显示 - 添加property路由模块 - 修复API请求参数中的硬编码值
Showing
9 changed files
with
1031 additions
and
0 deletions
.claude/settings.local.json
0 → 100644
| 1 | +{ | ||
| 2 | + "permissions": { | ||
| 3 | + "allow": [ | ||
| 4 | + "Bash(node -e \"console.log\\(require\\('@vue/babel-preset-app/package.json'\\).version\\)\")", | ||
| 5 | + "Bash(node *)", | ||
| 6 | + "Bash(npm ls *)", | ||
| 7 | + "Bash(npx vue-cli-service *)", | ||
| 8 | + "Bash(curl -sI \"https://webapi.amap.com/maps?v=2.0&key=3239bc04f77a5c89b2b5e628da96b6ed\")", | ||
| 9 | + "Bash(curl -sI \"https://webapi.amap.com/maps?v=1.4.15&key=3239bc04f77a5c89b2b5e628da96b6ed\")", | ||
| 10 | + "Bash(npm run *)", | ||
| 11 | + "Read(//Users/wangbiao/.claude/**)", | ||
| 12 | + "Read(//Users/wangbiao/.claude/plugins/**)" | ||
| 13 | + ] | ||
| 14 | + } | ||
| 15 | +} |
src/api/property/propertyApi.js
0 → 100644
| 1 | +import request from '@/utils/request' | ||
| 2 | + | ||
| 3 | +// 物业人员管理 API | ||
| 4 | + | ||
| 5 | +/** 人员列表查询 */ | ||
| 6 | +export function queryPropertyUsers(params) { | ||
| 7 | + return request({ url: '/app/property.queryUsers', method: 'post', data: params }) | ||
| 8 | +} | ||
| 9 | + | ||
| 10 | +/** 轨迹查询 */ | ||
| 11 | +export function queryLocationTracks(params) { | ||
| 12 | + return request({ url: '/app/property.queryLocationTracks', method: 'post', data: params }) | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +/** 打卡记录查询 */ | ||
| 16 | +export function queryAttendanceRecords(params) { | ||
| 17 | + return request({ url: '/app/property.queryAttendanceRecords', method: 'post', data: params }) | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +/** 巡更记录查询 */ | ||
| 21 | +export function queryPatrolRecords(params) { | ||
| 22 | + return request({ url: '/app/property.queryPatrolRecords', method: 'post', data: params }) | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +/** 保洁记录查询 */ | ||
| 26 | +export function queryCleaningRecords(params) { | ||
| 27 | + return request({ url: '/app/property.queryCleaningRecords', method: 'post', data: params }) | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +/** 工单查询 */ | ||
| 31 | +export function queryRepairOrders(params) { | ||
| 32 | + return request({ url: '/app/property.queryRepairOrders', method: 'post', data: params }) | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +/** 更新工单状态 */ | ||
| 36 | +export function updateRepairStatus(params) { | ||
| 37 | + return request({ url: '/app/property.updateRepairStatus', method: 'post', data: params }) | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +/** 消息通知查询 */ | ||
| 41 | +export function queryMessages(params) { | ||
| 42 | + return request({ url: '/app/property.queryMessages', method: 'post', data: params }) | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +/** 标记消息已读 */ | ||
| 46 | +export function readMessage(params) { | ||
| 47 | + return request({ url: '/app/property.readMessage', method: 'post', data: params }) | ||
| 48 | +} |
src/router/propertyRouter.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 物业人员管理路由配置 | ||
| 3 | + */ | ||
| 4 | +export default [ | ||
| 5 | + { | ||
| 6 | + path: '/property/track', | ||
| 7 | + name: 'propertyTrack', | ||
| 8 | + component: () => import('@/views/property/TrackView.vue'), | ||
| 9 | + meta: { title: '轨迹监控', icon: 'el-icon-map-location' } | ||
| 10 | + }, | ||
| 11 | + { | ||
| 12 | + path: '/property/staff', | ||
| 13 | + name: 'propertyStaff', | ||
| 14 | + component: () => import('@/views/property/StaffView.vue'), | ||
| 15 | + meta: { title: '人员管理', icon: 'el-icon-user' } | ||
| 16 | + }, | ||
| 17 | + { | ||
| 18 | + path: '/property/attendance', | ||
| 19 | + name: 'propertyAttendance', | ||
| 20 | + component: () => import('@/views/property/AttendanceView.vue'), | ||
| 21 | + meta: { title: '打卡管理', icon: 'el-icon-time' } | ||
| 22 | + }, | ||
| 23 | + { | ||
| 24 | + path: '/property/patrol', | ||
| 25 | + name: 'propertyPatrol', | ||
| 26 | + component: () => import('@/views/property/PatrolView.vue'), | ||
| 27 | + meta: { title: '巡更记录', icon: 'el-icon-warning' } | ||
| 28 | + }, | ||
| 29 | + { | ||
| 30 | + path: '/property/cleaning', | ||
| 31 | + name: 'propertyCleaning', | ||
| 32 | + component: () => import('@/views/property/CleaningView.vue'), | ||
| 33 | + meta: { title: '保洁记录', icon: 'el-icon-brush' } | ||
| 34 | + }, | ||
| 35 | + { | ||
| 36 | + path: '/property/repair', | ||
| 37 | + name: 'propertyRepair', | ||
| 38 | + component: () => import('@/views/property/RepairOrderView.vue'), | ||
| 39 | + meta: { title: '工单列表', icon: 'el-icon-s-tools' } | ||
| 40 | + }, | ||
| 41 | + { | ||
| 42 | + path: '/property/submitRepair', | ||
| 43 | + name: 'propertySubmitRepair', | ||
| 44 | + component: () => import('@/views/property/RepairOrderView.vue'), | ||
| 45 | + meta: { title: '提交工单', icon: 'el-icon-s-tools' } | ||
| 46 | + } | ||
| 47 | +] |
src/views/property/AttendanceView.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="attendance-container"> | ||
| 3 | + <el-card shadow="never" class="search-card"> | ||
| 4 | + <el-form :inline="true" :model="searchForm" size="small"> | ||
| 5 | + <el-form-item label="工种"> | ||
| 6 | + <el-select v-model="searchForm.workType" placeholder="全部工种" clearable> | ||
| 7 | + <el-option label="保洁" value="CLEANING" /> | ||
| 8 | + <el-option label="保安" value="SECURITY" /> | ||
| 9 | + <el-option label="工程维修" value="ENGINEERING" /> | ||
| 10 | + </el-select> | ||
| 11 | + </el-form-item> | ||
| 12 | + <el-form-item label="人员"> | ||
| 13 | + <el-select v-model="searchForm.userId" placeholder="全部人员" filterable clearable> | ||
| 14 | + <el-option v-for="user in userList" :key="user.user_id" | ||
| 15 | + :label="user.name" :value="user.user_id" /> | ||
| 16 | + </el-select> | ||
| 17 | + </el-form-item> | ||
| 18 | + <el-form-item label="打卡类型"> | ||
| 19 | + <el-select v-model="searchForm.punchType" placeholder="全部" clearable> | ||
| 20 | + <el-option label="上班" value="ON" /> | ||
| 21 | + <el-option label="下班" value="OFF" /> | ||
| 22 | + </el-select> | ||
| 23 | + </el-form-item> | ||
| 24 | + <el-form-item label="时间范围"> | ||
| 25 | + <el-date-picker v-model="dateRange" type="datetimerange" | ||
| 26 | + range-separator="至" start-placeholder="开始" end-placeholder="结束" | ||
| 27 | + format="yyyy-MM-dd HH:mm:ss" value-format="yyyy-MM-dd HH:mm:ss" /> | ||
| 28 | + </el-form-item> | ||
| 29 | + <el-form-item> | ||
| 30 | + <el-button type="primary" @click="handleSearch" icon="el-icon-search">查询</el-button> | ||
| 31 | + <el-button @click="handleExport" icon="el-icon-download">导出</el-button> | ||
| 32 | + </el-form-item> | ||
| 33 | + </el-form> | ||
| 34 | + </el-card> | ||
| 35 | + | ||
| 36 | + <el-card shadow="never"> | ||
| 37 | + <div slot="header"><span>打卡记录</span></div> | ||
| 38 | + <el-table :data="tableData" border stripe size="small" v-loading="loading"> | ||
| 39 | + <el-table-column prop="user_name" label="姓名" width="100" /> | ||
| 40 | + <el-table-column prop="work_type" label="工种" width="100"> | ||
| 41 | + <template slot-scope="scope">{{ workTypeLabel(scope.row.work_type) }}</template> | ||
| 42 | + </el-table-column> | ||
| 43 | + <el-table-column prop="punch_type" label="打卡类型" width="100"> | ||
| 44 | + <template slot-scope="scope"> | ||
| 45 | + <el-tag :type="scope.row.punch_type === 'ON' ? 'success' : 'warning'" size="small"> | ||
| 46 | + {{ scope.row.punch_type === 'ON' ? '上班' : '下班' }} | ||
| 47 | + </el-tag> | ||
| 48 | + </template> | ||
| 49 | + </el-table-column> | ||
| 50 | + <el-table-column prop="punch_time" label="打卡时间" width="180" /> | ||
| 51 | + <el-table-column prop="punch_address" label="打卡地点" min-width="200" /> | ||
| 52 | + <el-table-column prop="longitude" label="经度" width="120" /> | ||
| 53 | + <el-table-column prop="latitude" label="纬度" width="120" /> | ||
| 54 | + </el-table> | ||
| 55 | + <el-pagination | ||
| 56 | + style="margin-top: 16px; text-align: right;" | ||
| 57 | + @size-change="handleSizeChange" @current-change="handlePageChange" | ||
| 58 | + :current-page="pagination.page" :page-sizes="[10, 20, 50, 100]" | ||
| 59 | + :page-size="pagination.row" :total="pagination.total" | ||
| 60 | + layout="total, sizes, prev, pager, next, jumper" /> | ||
| 61 | + </el-card> | ||
| 62 | + </div> | ||
| 63 | +</template> | ||
| 64 | + | ||
| 65 | +<script> | ||
| 66 | +import { queryAttendanceRecords } from '@/api/property/propertyApi' | ||
| 67 | +import { queryPropertyUsers } from '@/api/property/propertyApi' | ||
| 68 | + | ||
| 69 | +export default { | ||
| 70 | + name: 'AttendanceView', | ||
| 71 | + data() { | ||
| 72 | + return { | ||
| 73 | + searchForm: { workType: '', userId: '', punchType: '' }, | ||
| 74 | + dateRange: [], | ||
| 75 | + userList: [], | ||
| 76 | + tableData: [], | ||
| 77 | + loading: false, | ||
| 78 | + pagination: { page: 1, row: 20, total: 0 } | ||
| 79 | + } | ||
| 80 | + }, | ||
| 81 | + mounted() { this.loadUserList(); this.handleSearch() }, | ||
| 82 | + methods: { | ||
| 83 | + workTypeLabel(type) { | ||
| 84 | + const map = { CLEANING: '保洁', SECURITY: '保安', ENGINEERING: '工程维修' } | ||
| 85 | + return map[type] || type | ||
| 86 | + }, | ||
| 87 | + async loadUserList() { | ||
| 88 | + try { | ||
| 89 | + const res = await queryPropertyUsers({ page: 1, row: 200 }) | ||
| 90 | + this.userList = res.data && res.data.data || [] | ||
| 91 | + } catch (e) { console.error(e) } | ||
| 92 | + }, | ||
| 93 | + async handleSearch() { | ||
| 94 | + this.loading = true | ||
| 95 | + try { | ||
| 96 | + const params = { | ||
| 97 | + page: this.pagination.page, | ||
| 98 | + row: this.pagination.row, | ||
| 99 | + ...this.searchForm | ||
| 100 | + } | ||
| 101 | + // 移除空值 | ||
| 102 | + Object.keys(params).forEach(k => { if (!params[k]) delete params[k] }) | ||
| 103 | + if (this.dateRange && this.dateRange.length === 2) { | ||
| 104 | + params.startTime = this.dateRange[0] | ||
| 105 | + params.endTime = this.dateRange[1] | ||
| 106 | + } | ||
| 107 | + const res = await queryAttendanceRecords(params) | ||
| 108 | + this.tableData = res.data && res.data.data || [] | ||
| 109 | + this.pagination.total = res.data && res.data.total || 0 | ||
| 110 | + } catch (e) { | ||
| 111 | + this.$message.error('查询失败') | ||
| 112 | + } finally { this.loading = false } | ||
| 113 | + }, | ||
| 114 | + handleExport() { | ||
| 115 | + // 导出打卡记录为CSV | ||
| 116 | + if (!this.tableData.length) { this.$message.warning('无数据可导出'); return } | ||
| 117 | + const headers = ['姓名', '工种', '打卡类型', '打卡时间', '打卡地点', '经度', '纬度'] | ||
| 118 | + const rows = this.tableData.map(r => [ | ||
| 119 | + r.user_name, this.workTypeLabel(r.work_type), | ||
| 120 | + r.punch_type === 'ON' ? '上班' : '下班', r.punch_time, | ||
| 121 | + r.punch_address, r.longitude, r.latitude | ||
| 122 | + ]) | ||
| 123 | + let csv = '' + headers.join(',') + '\n' | ||
| 124 | + rows.forEach(row => { csv += row.join(',') + '\n' }) | ||
| 125 | + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) | ||
| 126 | + const link = document.createElement('a') | ||
| 127 | + link.href = URL.createObjectURL(blob) | ||
| 128 | + link.download = '打卡记录_' + new Date().toISOString().slice(0, 10) + '.csv' | ||
| 129 | + link.click() | ||
| 130 | + this.$message.success('导出成功') | ||
| 131 | + }, | ||
| 132 | + handleSizeChange(val) { this.pagination.row = val; this.handleSearch() }, | ||
| 133 | + handlePageChange(val) { this.pagination.page = val; this.handleSearch() } | ||
| 134 | + } | ||
| 135 | +} | ||
| 136 | +</script> | ||
| 137 | + | ||
| 138 | +<style scoped> | ||
| 139 | +.attendance-container { padding: 16px; } | ||
| 140 | +.search-card { margin-bottom: 16px; } | ||
| 141 | +</style> |
src/views/property/CleaningView.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="cleaning-container"> | ||
| 3 | + <el-card shadow="never" class="search-card"> | ||
| 4 | + <el-form :inline="true" :model="searchForm" size="small"> | ||
| 5 | + <el-form-item label="作业人"> | ||
| 6 | + <el-select v-model="searchForm.userId" placeholder="全部" filterable clearable> | ||
| 7 | + <el-option v-for="user in userList" :key="user.user_id" :label="user.name" :value="user.user_id" /> | ||
| 8 | + </el-select> | ||
| 9 | + </el-form-item> | ||
| 10 | + <el-form-item label="时间范围"> | ||
| 11 | + <el-date-picker v-model="dateRange" type="datetimerange" | ||
| 12 | + range-separator="至" start-placeholder="开始" end-placeholder="结束" | ||
| 13 | + format="yyyy-MM-dd HH:mm:ss" value-format="yyyy-MM-dd HH:mm:ss" /> | ||
| 14 | + </el-form-item> | ||
| 15 | + <el-form-item> | ||
| 16 | + <el-button type="primary" @click="handleSearch" icon="el-icon-search">查询</el-button> | ||
| 17 | + </el-form-item> | ||
| 18 | + </el-form> | ||
| 19 | + </el-card> | ||
| 20 | + | ||
| 21 | + <el-card shadow="never"> | ||
| 22 | + <div slot="header"><span>保洁作业记录</span></div> | ||
| 23 | + <el-table :data="tableData" border stripe size="small" v-loading="loading"> | ||
| 24 | + <el-table-column prop="user_name" label="作业人" width="100" /> | ||
| 25 | + <el-table-column prop="work_area" label="作业区域" min-width="180" /> | ||
| 26 | + <el-table-column prop="work_content" label="作业内容" min-width="200" /> | ||
| 27 | + <el-table-column prop="work_time" label="作业时间" width="180" /> | ||
| 28 | + <el-table-column prop="longitude" label="经度" width="120" /> | ||
| 29 | + <el-table-column prop="latitude" label="纬度" width="120" /> | ||
| 30 | + <el-table-column prop="remark" label="备注" min-width="150" /> | ||
| 31 | + <el-table-column label="操作" width="80" fixed="right"> | ||
| 32 | + <template slot-scope="scope"> | ||
| 33 | + <el-button type="text" size="small" @click="showDetail(scope.row)">详情</el-button> | ||
| 34 | + </template> | ||
| 35 | + </el-table-column> | ||
| 36 | + </el-table> | ||
| 37 | + <el-pagination | ||
| 38 | + style="margin-top: 16px; text-align: right;" | ||
| 39 | + @size-change="handleSizeChange" @current-change="handlePageChange" | ||
| 40 | + :current-page="pagination.page" :page-sizes="[10, 20, 50, 100]" | ||
| 41 | + :page-size="pagination.row" :total="pagination.total" | ||
| 42 | + layout="total, sizes, prev, pager, next, jumper" /> | ||
| 43 | + </el-card> | ||
| 44 | + | ||
| 45 | + <el-dialog title="作业详情" :visible.sync="detailVisible" width="500px"> | ||
| 46 | + <el-descriptions :column="1" border size="small" v-if="currentRecord"> | ||
| 47 | + <el-descriptions-item label="作业人">{{ currentRecord.user_name }}</el-descriptions-item> | ||
| 48 | + <el-descriptions-item label="作业区域">{{ currentRecord.work_area }}</el-descriptions-item> | ||
| 49 | + <el-descriptions-item label="作业内容">{{ currentRecord.work_content }}</el-descriptions-item> | ||
| 50 | + <el-descriptions-item label="作业时间">{{ currentRecord.work_time }}</el-descriptions-item> | ||
| 51 | + <el-descriptions-item label="坐标">{{ currentRecord.longitude }}, {{ currentRecord.latitude }}</el-descriptions-item> | ||
| 52 | + <el-descriptions-item label="作业图片"> | ||
| 53 | + <template v-if="parseImages(currentRecord.images).length"> | ||
| 54 | + <el-image v-for="(url, i) in parseImages(currentRecord.images)" :key="i" | ||
| 55 | + :src="url" style="width: 80px; height: 80px; margin-right: 8px;" | ||
| 56 | + :preview-src-list="parseImages(currentRecord.images)" fit="cover" /> | ||
| 57 | + </template> | ||
| 58 | + <span v-else>无</span> | ||
| 59 | + </el-descriptions-item> | ||
| 60 | + <el-descriptions-item label="备注">{{ currentRecord.remark || '无' }}</el-descriptions-item> | ||
| 61 | + </el-descriptions> | ||
| 62 | + </el-dialog> | ||
| 63 | + </div> | ||
| 64 | +</template> | ||
| 65 | + | ||
| 66 | +<script> | ||
| 67 | +import { queryCleaningRecords } from '@/api/property/propertyApi' | ||
| 68 | +import { queryPropertyUsers } from '@/api/property/propertyApi' | ||
| 69 | + | ||
| 70 | +export default { | ||
| 71 | + name: 'CleaningView', | ||
| 72 | + data() { | ||
| 73 | + return { | ||
| 74 | + searchForm: { userId: '' }, | ||
| 75 | + dateRange: [], | ||
| 76 | + userList: [], | ||
| 77 | + tableData: [], | ||
| 78 | + loading: false, | ||
| 79 | + pagination: { page: 1, row: 20, total: 0 }, | ||
| 80 | + detailVisible: false, | ||
| 81 | + currentRecord: null | ||
| 82 | + } | ||
| 83 | + }, | ||
| 84 | + mounted() { this.loadUsers(); this.handleSearch() }, | ||
| 85 | + methods: { | ||
| 86 | + async loadUsers() { | ||
| 87 | + try { | ||
| 88 | + const res = await queryPropertyUsers({ workType: 'CLEANING', page: 1, row: 200 }) | ||
| 89 | + this.userList = res.data && res.data.data || [] | ||
| 90 | + } catch (e) { console.error(e) } | ||
| 91 | + }, | ||
| 92 | + async handleSearch() { | ||
| 93 | + this.loading = true | ||
| 94 | + try { | ||
| 95 | + const params = { page: this.pagination.page, row: this.pagination.row, ...this.searchForm } | ||
| 96 | + Object.keys(params).forEach(k => { if (!params[k]) delete params[k] }) | ||
| 97 | + if (this.dateRange && this.dateRange.length === 2) { | ||
| 98 | + params.startTime = this.dateRange[0]; params.endTime = this.dateRange[1] | ||
| 99 | + } | ||
| 100 | + const res = await queryCleaningRecords(params) | ||
| 101 | + this.tableData = res.data && res.data.data || [] | ||
| 102 | + this.pagination.total = res.data && res.data.total || 0 | ||
| 103 | + } catch (e) { this.$message.error('查询失败') } | ||
| 104 | + finally { this.loading = false } | ||
| 105 | + }, | ||
| 106 | + parseImages(images) { | ||
| 107 | + if (!images) return [] | ||
| 108 | + try { return JSON.parse(images) } catch (e) { return [] } | ||
| 109 | + }, | ||
| 110 | + showDetail(row) { this.currentRecord = row; this.detailVisible = true }, | ||
| 111 | + handleSizeChange(val) { this.pagination.row = val; this.handleSearch() }, | ||
| 112 | + handlePageChange(val) { this.pagination.page = val; this.handleSearch() } | ||
| 113 | + } | ||
| 114 | +} | ||
| 115 | +</script> | ||
| 116 | + | ||
| 117 | +<style scoped> | ||
| 118 | +.cleaning-container { padding: 16px; } | ||
| 119 | +.search-card { margin-bottom: 16px; } | ||
| 120 | +</style> |
src/views/property/PatrolView.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="patrol-container"> | ||
| 3 | + <el-card shadow="never" class="search-card"> | ||
| 4 | + <el-form :inline="true" :model="searchForm" size="small"> | ||
| 5 | + <el-form-item label="巡更人"> | ||
| 6 | + <el-select v-model="searchForm.userId" placeholder="全部" filterable clearable> | ||
| 7 | + <el-option v-for="user in userList" :key="user.user_id" :label="user.name" :value="user.user_id" /> | ||
| 8 | + </el-select> | ||
| 9 | + </el-form-item> | ||
| 10 | + <el-form-item label="时间范围"> | ||
| 11 | + <el-date-picker v-model="dateRange" type="datetimerange" | ||
| 12 | + range-separator="至" start-placeholder="开始" end-placeholder="结束" | ||
| 13 | + format="yyyy-MM-dd HH:mm:ss" value-format="yyyy-MM-dd HH:mm:ss" /> | ||
| 14 | + </el-form-item> | ||
| 15 | + <el-form-item> | ||
| 16 | + <el-button type="primary" @click="handleSearch" icon="el-icon-search">查询</el-button> | ||
| 17 | + </el-form-item> | ||
| 18 | + </el-form> | ||
| 19 | + </el-card> | ||
| 20 | + | ||
| 21 | + <el-card shadow="never"> | ||
| 22 | + <div slot="header"><span>巡更记录</span></div> | ||
| 23 | + <el-table :data="tableData" border stripe size="small" v-loading="loading"> | ||
| 24 | + <el-table-column prop="user_name" label="巡更人" width="100" /> | ||
| 25 | + <el-table-column prop="patrol_location" label="巡更地点" min-width="180" /> | ||
| 26 | + <el-table-column prop="patrol_content" label="巡更内容" min-width="200" /> | ||
| 27 | + <el-table-column prop="patrol_time" label="巡更时间" width="180" /> | ||
| 28 | + <el-table-column prop="longitude" label="经度" width="120" /> | ||
| 29 | + <el-table-column prop="latitude" label="纬度" width="120" /> | ||
| 30 | + <el-table-column prop="remark" label="备注" min-width="150" /> | ||
| 31 | + <el-table-column label="操作" width="80" fixed="right"> | ||
| 32 | + <template slot-scope="scope"> | ||
| 33 | + <el-button type="text" size="small" @click="showDetail(scope.row)">详情</el-button> | ||
| 34 | + </template> | ||
| 35 | + </el-table-column> | ||
| 36 | + </el-table> | ||
| 37 | + <el-pagination | ||
| 38 | + style="margin-top: 16px; text-align: right;" | ||
| 39 | + @size-change="handleSizeChange" @current-change="handlePageChange" | ||
| 40 | + :current-page="pagination.page" :page-sizes="[10, 20, 50, 100]" | ||
| 41 | + :page-size="pagination.row" :total="pagination.total" | ||
| 42 | + layout="total, sizes, prev, pager, next, jumper" /> | ||
| 43 | + </el-card> | ||
| 44 | + | ||
| 45 | + <el-dialog title="巡更详情" :visible.sync="detailVisible" width="500px"> | ||
| 46 | + <el-descriptions :column="1" border size="small" v-if="currentRecord"> | ||
| 47 | + <el-descriptions-item label="巡更人">{{ currentRecord.user_name }}</el-descriptions-item> | ||
| 48 | + <el-descriptions-item label="巡更地点">{{ currentRecord.patrol_location }}</el-descriptions-item> | ||
| 49 | + <el-descriptions-item label="巡更内容">{{ currentRecord.patrol_content }}</el-descriptions-item> | ||
| 50 | + <el-descriptions-item label="巡更时间">{{ currentRecord.patrol_time }}</el-descriptions-item> | ||
| 51 | + <el-descriptions-item label="坐标">{{ currentRecord.longitude }}, {{ currentRecord.latitude }}</el-descriptions-item> | ||
| 52 | + <el-descriptions-item label="巡更图片"> | ||
| 53 | + <template v-if="parseImages(currentRecord.images).length"> | ||
| 54 | + <el-image v-for="(url, i) in parseImages(currentRecord.images)" :key="i" | ||
| 55 | + :src="url" style="width: 80px; height: 80px; margin-right: 8px;" | ||
| 56 | + :preview-src-list="parseImages(currentRecord.images)" fit="cover" /> | ||
| 57 | + </template> | ||
| 58 | + <span v-else>无</span> | ||
| 59 | + </el-descriptions-item> | ||
| 60 | + <el-descriptions-item label="备注">{{ currentRecord.remark || '无' }}</el-descriptions-item> | ||
| 61 | + </el-descriptions> | ||
| 62 | + </el-dialog> | ||
| 63 | + </div> | ||
| 64 | +</template> | ||
| 65 | + | ||
| 66 | +<script> | ||
| 67 | +import { queryPatrolRecords } from '@/api/property/propertyApi' | ||
| 68 | +import { queryPropertyUsers } from '@/api/property/propertyApi' | ||
| 69 | + | ||
| 70 | +export default { | ||
| 71 | + name: 'PatrolView', | ||
| 72 | + data() { | ||
| 73 | + return { | ||
| 74 | + searchForm: { userId: '' }, | ||
| 75 | + dateRange: [], | ||
| 76 | + userList: [], | ||
| 77 | + tableData: [], | ||
| 78 | + loading: false, | ||
| 79 | + pagination: { page: 1, row: 20, total: 0 }, | ||
| 80 | + detailVisible: false, | ||
| 81 | + currentRecord: null | ||
| 82 | + } | ||
| 83 | + }, | ||
| 84 | + mounted() { this.loadUsers(); this.handleSearch() }, | ||
| 85 | + methods: { | ||
| 86 | + async loadUsers() { | ||
| 87 | + try { | ||
| 88 | + const res = await queryPropertyUsers({ workType: 'SECURITY', page: 1, row: 200 }) | ||
| 89 | + this.userList = res.data && res.data.data || [] | ||
| 90 | + } catch (e) { console.error(e) } | ||
| 91 | + }, | ||
| 92 | + async handleSearch() { | ||
| 93 | + this.loading = true | ||
| 94 | + try { | ||
| 95 | + const params = { page: this.pagination.page, row: this.pagination.row, ...this.searchForm } | ||
| 96 | + Object.keys(params).forEach(k => { if (!params[k]) delete params[k] }) | ||
| 97 | + if (this.dateRange && this.dateRange.length === 2) { | ||
| 98 | + params.startTime = this.dateRange[0]; params.endTime = this.dateRange[1] | ||
| 99 | + } | ||
| 100 | + const res = await queryPatrolRecords(params) | ||
| 101 | + this.tableData = res.data && res.data.data || [] | ||
| 102 | + this.pagination.total = res.data && res.data.total || 0 | ||
| 103 | + } catch (e) { this.$message.error('查询失败') } | ||
| 104 | + finally { this.loading = false } | ||
| 105 | + }, | ||
| 106 | + parseImages(images) { | ||
| 107 | + if (!images) return [] | ||
| 108 | + try { return JSON.parse(images) } catch (e) { return [] } | ||
| 109 | + }, | ||
| 110 | + showDetail(row) { this.currentRecord = row; this.detailVisible = true }, | ||
| 111 | + handleSizeChange(val) { this.pagination.row = val; this.handleSearch() }, | ||
| 112 | + handlePageChange(val) { this.pagination.page = val; this.handleSearch() } | ||
| 113 | + } | ||
| 114 | +} | ||
| 115 | +</script> | ||
| 116 | + | ||
| 117 | +<style scoped> | ||
| 118 | +.patrol-container { padding: 16px; } | ||
| 119 | +.search-card { margin-bottom: 16px; } | ||
| 120 | +</style> |
src/views/property/RepairOrderView.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="repair-container"> | ||
| 3 | + <el-card shadow="never" class="search-card"> | ||
| 4 | + <el-form :inline="true" :model="searchForm" size="small"> | ||
| 5 | + <el-form-item label="工单状态"> | ||
| 6 | + <el-select v-model="searchForm.status" placeholder="全部状态" clearable @change="handleSearch"> | ||
| 7 | + <el-option label="待接单" value="PENDING" /> | ||
| 8 | + <el-option label="维修中" value="REPAIRING" /> | ||
| 9 | + <el-option label="已完成" value="COMPLETED" /> | ||
| 10 | + <el-option label="已取消" value="CANCELLED" /> | ||
| 11 | + </el-select> | ||
| 12 | + </el-form-item> | ||
| 13 | + <el-form-item label="维修人员"> | ||
| 14 | + <el-select v-model="searchForm.repairerId" placeholder="全部" filterable clearable> | ||
| 15 | + <el-option v-for="user in engineerList" :key="user.user_id" | ||
| 16 | + :label="user.name" :value="user.user_id" /> | ||
| 17 | + </el-select> | ||
| 18 | + </el-form-item> | ||
| 19 | + <el-form-item label="时间范围"> | ||
| 20 | + <el-date-picker v-model="dateRange" type="datetimerange" | ||
| 21 | + range-separator="至" start-placeholder="开始" end-placeholder="结束" | ||
| 22 | + format="yyyy-MM-dd HH:mm:ss" value-format="yyyy-MM-dd HH:mm:ss" /> | ||
| 23 | + </el-form-item> | ||
| 24 | + <el-form-item> | ||
| 25 | + <el-button type="primary" @click="handleSearch" icon="el-icon-search">查询</el-button> | ||
| 26 | + </el-form-item> | ||
| 27 | + </el-form> | ||
| 28 | + </el-card> | ||
| 29 | + | ||
| 30 | + <el-card shadow="never"> | ||
| 31 | + <div slot="header"><span>工单列表(共 {{ pagination.total }} 条)</span></div> | ||
| 32 | + <el-table :data="tableData" border stripe size="small" v-loading="loading"> | ||
| 33 | + <el-table-column prop="title" label="工单标题" min-width="180" /> | ||
| 34 | + <el-table-column prop="submitter_name" label="提交人" width="100" /> | ||
| 35 | + <el-table-column prop="repair_location" label="报修位置" min-width="180" /> | ||
| 36 | + <el-table-column prop="repairer_name" label="维修人" width="100" /> | ||
| 37 | + <el-table-column prop="status" label="状态" width="100"> | ||
| 38 | + <template slot-scope="scope"> | ||
| 39 | + <el-tag :type="statusTagType(scope.row.status)" size="small"> | ||
| 40 | + {{ statusLabel(scope.row.status) }} | ||
| 41 | + </el-tag> | ||
| 42 | + </template> | ||
| 43 | + </el-table-column> | ||
| 44 | + <el-table-column prop="create_time" label="创建时间" width="180" /> | ||
| 45 | + <el-table-column label="操作" width="180" fixed="right"> | ||
| 46 | + <template slot-scope="scope"> | ||
| 47 | + <el-button type="text" size="small" @click="showDetail(scope.row)">详情</el-button> | ||
| 48 | + <el-button v-if="scope.row.status === 'PENDING'" | ||
| 49 | + type="text" size="small" @click="handleAssign(scope.row)">分配</el-button> | ||
| 50 | + </template> | ||
| 51 | + </el-table-column> | ||
| 52 | + </el-table> | ||
| 53 | + <el-pagination | ||
| 54 | + style="margin-top: 16px; text-align: right;" | ||
| 55 | + @size-change="handleSizeChange" @current-change="handlePageChange" | ||
| 56 | + :current-page="pagination.page" :page-sizes="[10, 20, 50, 100]" | ||
| 57 | + :page-size="pagination.row" :total="pagination.total" | ||
| 58 | + layout="total, sizes, prev, pager, next, jumper" /> | ||
| 59 | + </el-card> | ||
| 60 | + | ||
| 61 | + <!-- 工单详情对话框 --> | ||
| 62 | + <el-dialog title="工单详情" :visible.sync="detailVisible" width="600px"> | ||
| 63 | + <el-descriptions :column="2" border size="small" v-if="currentOrder"> | ||
| 64 | + <el-descriptions-item label="工单标题">{{ currentOrder.title }}</el-descriptions-item> | ||
| 65 | + <el-descriptions-item label="状态"> | ||
| 66 | + <el-tag :type="statusTagType(currentOrder.status)" size="small"> | ||
| 67 | + {{ statusLabel(currentOrder.status) }} | ||
| 68 | + </el-tag> | ||
| 69 | + </el-descriptions-item> | ||
| 70 | + <el-descriptions-item label="提交人">{{ currentOrder.submitter_name }}</el-descriptions-item> | ||
| 71 | + <el-descriptions-item label="维修人">{{ currentOrder.repairer_name || '未分配' }}</el-descriptions-item> | ||
| 72 | + <el-descriptions-item label="报修位置">{{ currentOrder.repair_location }}</el-descriptions-item> | ||
| 73 | + <el-descriptions-item label="坐标">{{ currentOrder.longitude }}, {{ currentOrder.latitude }}</el-descriptions-item> | ||
| 74 | + <el-descriptions-item label="问题描述" :span="2">{{ currentOrder.description }}</el-descriptions-item> | ||
| 75 | + <el-descriptions-item label="报修图片" :span="2"> | ||
| 76 | + <template v-if="parseImages(currentOrder.images).length"> | ||
| 77 | + <el-image v-for="(url, i) in parseImages(currentOrder.images)" :key="i" | ||
| 78 | + :src="url" style="width: 80px; height: 80px; margin-right: 8px;" | ||
| 79 | + :preview-src-list="parseImages(currentOrder.images)" fit="cover" /> | ||
| 80 | + </template> | ||
| 81 | + <span v-else>无</span> | ||
| 82 | + </el-descriptions-item> | ||
| 83 | + <el-descriptions-item label="维修结果" :span="2">{{ currentOrder.repair_result || '暂无' }}</el-descriptions-item> | ||
| 84 | + <el-descriptions-item label="结果图片" :span="2"> | ||
| 85 | + <template v-if="parseImages(currentOrder.result_images).length"> | ||
| 86 | + <el-image v-for="(url, i) in parseImages(currentOrder.result_images)" :key="i" | ||
| 87 | + :src="url" style="width: 80px; height: 80px; margin-right: 8px;" | ||
| 88 | + :preview-src-list="parseImages(currentOrder.result_images)" fit="cover" /> | ||
| 89 | + </template> | ||
| 90 | + <span v-else>无</span> | ||
| 91 | + </el-descriptions-item> | ||
| 92 | + <el-descriptions-item label="创建时间">{{ currentOrder.create_time }}</el-descriptions-item> | ||
| 93 | + <el-descriptions-item label="完成时间">{{ currentOrder.complete_time || '未完成' }}</el-descriptions-item> | ||
| 94 | + </el-descriptions> | ||
| 95 | + </el-dialog> | ||
| 96 | + | ||
| 97 | + <!-- 分配维修人员对话框 --> | ||
| 98 | + <el-dialog title="分配维修人员" :visible.sync="assignVisible" width="400px"> | ||
| 99 | + <el-select v-model="assignForm.repairerId" placeholder="选择维修人员" style="width: 100%"> | ||
| 100 | + <el-option v-for="user in engineerList" :key="user.user_id" | ||
| 101 | + :label="user.name" :value="user.user_id" /> | ||
| 102 | + </el-select> | ||
| 103 | + <span slot="footer"> | ||
| 104 | + <el-button @click="assignVisible = false">取消</el-button> | ||
| 105 | + <el-button type="primary" @click="confirmAssign">确认分配</el-button> | ||
| 106 | + </span> | ||
| 107 | + </el-dialog> | ||
| 108 | + </div> | ||
| 109 | +</template> | ||
| 110 | + | ||
| 111 | +<script> | ||
| 112 | +import { queryRepairOrders, updateRepairStatus } from '@/api/property/propertyApi' | ||
| 113 | +import { queryPropertyUsers } from '@/api/property/propertyApi' | ||
| 114 | + | ||
| 115 | +export default { | ||
| 116 | + name: 'RepairOrderView', | ||
| 117 | + data() { | ||
| 118 | + return { | ||
| 119 | + searchForm: { status: '', repairerId: '' }, | ||
| 120 | + dateRange: [], | ||
| 121 | + engineerList: [], | ||
| 122 | + tableData: [], | ||
| 123 | + loading: false, | ||
| 124 | + pagination: { page: 1, row: 20, total: 0 }, | ||
| 125 | + detailVisible: false, | ||
| 126 | + currentOrder: null, | ||
| 127 | + assignVisible: false, | ||
| 128 | + assignForm: { orderId: '', repairerId: '' } | ||
| 129 | + } | ||
| 130 | + }, | ||
| 131 | + mounted() { this.loadEngineers(); this.handleSearch() }, | ||
| 132 | + methods: { | ||
| 133 | + statusLabel(s) { | ||
| 134 | + const map = { PENDING: '待接单', REPAIRING: '维修中', COMPLETED: '已完成', CANCELLED: '已取消' } | ||
| 135 | + return map[s] || s | ||
| 136 | + }, | ||
| 137 | + statusTagType(s) { | ||
| 138 | + const map = { PENDING: 'warning', REPAIRING: 'primary', COMPLETED: 'success', CANCELLED: 'info' } | ||
| 139 | + return map[s] || 'info' | ||
| 140 | + }, | ||
| 141 | + async loadEngineers() { | ||
| 142 | + try { | ||
| 143 | + const res = await queryPropertyUsers({ workType: 'ENGINEERING', page: 1, row: 200 }) | ||
| 144 | + this.engineerList = res.data && res.data.data || [] | ||
| 145 | + } catch (e) { console.error(e) } | ||
| 146 | + }, | ||
| 147 | + async handleSearch() { | ||
| 148 | + this.loading = true | ||
| 149 | + try { | ||
| 150 | + const params = { page: this.pagination.page, row: this.pagination.row, ...this.searchForm } | ||
| 151 | + Object.keys(params).forEach(k => { if (!params[k]) delete params[k] }) | ||
| 152 | + if (this.dateRange && this.dateRange.length === 2) { | ||
| 153 | + params.startTime = this.dateRange[0] | ||
| 154 | + params.endTime = this.dateRange[1] | ||
| 155 | + } | ||
| 156 | + const res = await queryRepairOrders(params) | ||
| 157 | + this.tableData = res.data && res.data.data || [] | ||
| 158 | + this.pagination.total = res.data && res.data.total || 0 | ||
| 159 | + } catch (e) { | ||
| 160 | + this.$message.error('查询失败') | ||
| 161 | + } finally { this.loading = false } | ||
| 162 | + }, | ||
| 163 | + /** 解析JSON数组格式的图片URL */ | ||
| 164 | + parseImages(images) { | ||
| 165 | + if (!images) return [] | ||
| 166 | + try { return JSON.parse(images) } catch (e) { return [] } | ||
| 167 | + }, | ||
| 168 | + showDetail(row) { this.currentOrder = row; this.detailVisible = true }, | ||
| 169 | + handleAssign(row) { | ||
| 170 | + this.assignForm.orderId = row.id | ||
| 171 | + this.assignForm.repairerId = row.repairer_id || '' | ||
| 172 | + this.assignVisible = true | ||
| 173 | + }, | ||
| 174 | + async confirmAssign() { | ||
| 175 | + if (!this.assignForm.repairerId) { this.$message.warning('请选择维修人员'); return } | ||
| 176 | + try { | ||
| 177 | + await updateRepairStatus({ | ||
| 178 | + id: this.assignForm.orderId, | ||
| 179 | + status: 'REPAIRING', | ||
| 180 | + repairerId: this.assignForm.repairerId | ||
| 181 | + }) | ||
| 182 | + this.$message.success('分配成功') | ||
| 183 | + this.assignVisible = false | ||
| 184 | + this.handleSearch() | ||
| 185 | + } catch (e) { this.$message.error('分配失败') } | ||
| 186 | + }, | ||
| 187 | + handleSizeChange(val) { this.pagination.row = val; this.handleSearch() }, | ||
| 188 | + handlePageChange(val) { this.pagination.page = val; this.handleSearch() } | ||
| 189 | + } | ||
| 190 | +} | ||
| 191 | +</script> | ||
| 192 | + | ||
| 193 | +<style scoped> | ||
| 194 | +.repair-container { padding: 16px; } | ||
| 195 | +.search-card { margin-bottom: 16px; } | ||
| 196 | +</style> |
src/views/property/StaffView.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="staff-container"> | ||
| 3 | + <el-card shadow="never"> | ||
| 4 | + <div slot="header"> | ||
| 5 | + <span>人员列表</span> | ||
| 6 | + <el-select v-model="filterWorkType" placeholder="筛选工种" clearable size="small" | ||
| 7 | + style="margin-left: 16px; width: 150px;" @change="loadData"> | ||
| 8 | + <el-option label="保洁" value="CLEANING" /> | ||
| 9 | + <el-option label="保安" value="SECURITY" /> | ||
| 10 | + <el-option label="工程维修" value="ENGINEERING" /> | ||
| 11 | + </el-select> | ||
| 12 | + </div> | ||
| 13 | + <el-table :data="tableData" border stripe size="small" v-loading="loading"> | ||
| 14 | + <el-table-column prop="name" label="姓名" width="120" /> | ||
| 15 | + <el-table-column prop="tel" label="手机号" width="140" /> | ||
| 16 | + <el-table-column prop="work_type" label="工种" width="120"> | ||
| 17 | + <template slot-scope="scope">{{ workTypeLabel(scope.row.work_type) }}</template> | ||
| 18 | + </el-table-column> | ||
| 19 | + <el-table-column prop="role" label="角色" width="100"> | ||
| 20 | + <template slot-scope="scope"> | ||
| 21 | + <el-tag :type="scope.row.role === 'ADMIN' ? 'danger' : 'info'" size="small"> | ||
| 22 | + {{ scope.row.role === 'ADMIN' ? '管理员' : '员工' }} | ||
| 23 | + </el-tag> | ||
| 24 | + </template> | ||
| 25 | + </el-table-column> | ||
| 26 | + <el-table-column prop="status" label="状态" width="100"> | ||
| 27 | + <template slot-scope="scope"> | ||
| 28 | + <el-tag :type="scope.row.status === 'ON' ? 'success' : 'danger'" size="small"> | ||
| 29 | + {{ scope.row.status === 'ON' ? '在职' : '离职' }} | ||
| 30 | + </el-tag> | ||
| 31 | + </template> | ||
| 32 | + </el-table-column> | ||
| 33 | + <el-table-column prop="create_time" label="创建时间" width="180" /> | ||
| 34 | + </el-table> | ||
| 35 | + <el-pagination | ||
| 36 | + style="margin-top: 16px; text-align: right;" | ||
| 37 | + @size-change="handleSizeChange" @current-change="handlePageChange" | ||
| 38 | + :current-page="pagination.page" :page-sizes="[10, 20, 50, 100]" | ||
| 39 | + :page-size="pagination.row" :total="pagination.total" | ||
| 40 | + layout="total, sizes, prev, pager, next, jumper" /> | ||
| 41 | + </el-card> | ||
| 42 | + </div> | ||
| 43 | +</template> | ||
| 44 | + | ||
| 45 | +<script> | ||
| 46 | +import { queryPropertyUsers } from '@/api/property/propertyApi' | ||
| 47 | + | ||
| 48 | +export default { | ||
| 49 | + name: 'StaffView', | ||
| 50 | + data() { | ||
| 51 | + return { | ||
| 52 | + filterWorkType: '', | ||
| 53 | + tableData: [], | ||
| 54 | + loading: false, | ||
| 55 | + pagination: { page: 1, row: 20, total: 0 } | ||
| 56 | + } | ||
| 57 | + }, | ||
| 58 | + mounted() { this.loadData() }, | ||
| 59 | + methods: { | ||
| 60 | + workTypeLabel(type) { | ||
| 61 | + const map = { CLEANING: '保洁', SECURITY: '保安', ENGINEERING: '工程维修' } | ||
| 62 | + return map[type] || type | ||
| 63 | + }, | ||
| 64 | + async loadData() { | ||
| 65 | + this.loading = true | ||
| 66 | + try { | ||
| 67 | + const params = { page: this.pagination.page, row: this.pagination.row } | ||
| 68 | + if (this.filterWorkType) params.workType = this.filterWorkType | ||
| 69 | + const res = await queryPropertyUsers(params) | ||
| 70 | + this.tableData = res.data && res.data.data || [] | ||
| 71 | + this.pagination.total = res.data && res.data.total || 0 | ||
| 72 | + } catch (e) { this.$message.error('加载失败') } | ||
| 73 | + finally { this.loading = false } | ||
| 74 | + }, | ||
| 75 | + handleSizeChange(val) { this.pagination.row = val; this.loadData() }, | ||
| 76 | + handlePageChange(val) { this.pagination.page = val; this.loadData() } | ||
| 77 | + } | ||
| 78 | +} | ||
| 79 | +</script> | ||
| 80 | + | ||
| 81 | +<style scoped> | ||
| 82 | +.staff-container { padding: 16px; } | ||
| 83 | +</style> |
src/views/property/TrackView.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="track-container"> | ||
| 3 | + <!-- 搜索条件 --> | ||
| 4 | + <el-card shadow="never" class="search-card"> | ||
| 5 | + <el-form :inline="true" :model="searchForm" size="small"> | ||
| 6 | + <el-form-item label="选择人员"> | ||
| 7 | + <el-select v-model="searchForm.userId" placeholder="请选择人员" filterable clearable | ||
| 8 | + @change="handleSearch"> | ||
| 9 | + <el-option v-for="user in userList" :key="user.userId" | ||
| 10 | + :label="user.userName + (user.tel ? ' - ' + user.tel : '')" | ||
| 11 | + :value="user.userId" /> | ||
| 12 | + </el-select> | ||
| 13 | + </el-form-item> | ||
| 14 | + <el-form-item label="开始时间"> | ||
| 15 | + <el-date-picker v-model="searchForm.startTime" type="datetime" | ||
| 16 | + placeholder="选择开始时间" format="yyyy-MM-dd HH:mm:ss" | ||
| 17 | + value-format="yyyy-MM-dd HH:mm:ss" @change="handleSearch" /> | ||
| 18 | + </el-form-item> | ||
| 19 | + <el-form-item label="结束时间"> | ||
| 20 | + <el-date-picker v-model="searchForm.endTime" type="datetime" | ||
| 21 | + placeholder="选择结束时间" format="yyyy-MM-dd HH:mm:ss" | ||
| 22 | + value-format="yyyy-MM-dd HH:mm:ss" @change="handleSearch" /> | ||
| 23 | + </el-form-item> | ||
| 24 | + <el-form-item> | ||
| 25 | + <el-button type="primary" @click="handleSearch" icon="el-icon-search">查询</el-button> | ||
| 26 | + <el-button @click="handlePlayTrack" icon="el-icon-video-play" :disabled="!trackData.length"> | ||
| 27 | + 播放轨迹 | ||
| 28 | + </el-button> | ||
| 29 | + </el-form-item> | ||
| 30 | + </el-form> | ||
| 31 | + </el-card> | ||
| 32 | + | ||
| 33 | + <!-- 地图容器(独立 div,避免 el-card 干扰尺寸测量) --> | ||
| 34 | + <div id="amap-container" ref="mapContainer" class="map-container"></div> | ||
| 35 | + | ||
| 36 | + <!-- 轨迹点列表 --> | ||
| 37 | + <el-card shadow="never" class="list-card"> | ||
| 38 | + <div slot="header"> | ||
| 39 | + <span>轨迹点列表(共 {{ trackData.length }} 个点)</span> | ||
| 40 | + </div> | ||
| 41 | + <el-table :data="trackData" border stripe size="small" max-height="300"> | ||
| 42 | + <el-table-column prop="report_time" label="上报时间" width="180" /> | ||
| 43 | + <el-table-column prop="longitude" label="经度" width="140" /> | ||
| 44 | + <el-table-column prop="latitude" label="纬度" width="140" /> | ||
| 45 | + <el-table-column prop="loc_status" label="状态" width="100"> | ||
| 46 | + <template slot-scope="scope"> | ||
| 47 | + <el-tag :type="scope.row.loc_status === 'NORMAL' ? 'success' : 'danger'" size="small"> | ||
| 48 | + {{ scope.row.loc_status === 'NORMAL' ? '正常' : '异常' }} | ||
| 49 | + </el-tag> | ||
| 50 | + </template> | ||
| 51 | + </el-table-column> | ||
| 52 | + <el-table-column label="操作" width="100"> | ||
| 53 | + <template slot-scope="scope"> | ||
| 54 | + <el-button type="text" size="small" @click="locatePoint(scope.row)">定位</el-button> | ||
| 55 | + </template> | ||
| 56 | + </el-table-column> | ||
| 57 | + </el-table> | ||
| 58 | + </el-card> | ||
| 59 | + </div> | ||
| 60 | +</template> | ||
| 61 | + | ||
| 62 | +<script> | ||
| 63 | +import { queryLocationTracks } from '@/api/property/propertyApi' | ||
| 64 | +import { listSystemUsers } from '@/api/staff/systemUserApi' | ||
| 65 | + | ||
| 66 | +export default { | ||
| 67 | + name: 'TrackView', | ||
| 68 | + data() { | ||
| 69 | + return { | ||
| 70 | + searchForm: { userId: '', startTime: '', endTime: '' }, | ||
| 71 | + userList: [], | ||
| 72 | + trackData: [], | ||
| 73 | + map: null, | ||
| 74 | + polyline: null, | ||
| 75 | + markers: [], | ||
| 76 | + movingMarker: null, | ||
| 77 | + playing: false, | ||
| 78 | + playTimer: null, | ||
| 79 | + playIndex: 0 | ||
| 80 | + } | ||
| 81 | + }, | ||
| 82 | + mounted() { | ||
| 83 | + this.loadUserList() | ||
| 84 | + this.initMap() | ||
| 85 | + }, | ||
| 86 | + beforeDestroy() { | ||
| 87 | + if (this.playTimer) clearInterval(this.playTimer) | ||
| 88 | + if (this.map) { | ||
| 89 | + this.map.destroy() | ||
| 90 | + this.map = null | ||
| 91 | + } | ||
| 92 | + }, | ||
| 93 | + methods: { | ||
| 94 | + async loadUserList() { | ||
| 95 | + try { | ||
| 96 | + const { data } = await listSystemUsers({ page: 1, row: 200 }) | ||
| 97 | + this.userList = data || [] | ||
| 98 | + } catch (e) { | ||
| 99 | + console.error('加载人员列表失败', e) | ||
| 100 | + } | ||
| 101 | + }, | ||
| 102 | + | ||
| 103 | + initMap() { | ||
| 104 | + // 确保 DOM 已渲染且 AMap 已加载 | ||
| 105 | + this.$nextTick(() => { | ||
| 106 | + const tryCreate = () => { | ||
| 107 | + if (!window.AMap) { | ||
| 108 | + setTimeout(tryCreate, 200) | ||
| 109 | + return | ||
| 110 | + } | ||
| 111 | + const container = this.$refs.mapContainer | ||
| 112 | + if (!container || container.offsetWidth === 0) { | ||
| 113 | + setTimeout(tryCreate, 200) | ||
| 114 | + return | ||
| 115 | + } | ||
| 116 | + this.createMap(container) | ||
| 117 | + } | ||
| 118 | + tryCreate() | ||
| 119 | + }) | ||
| 120 | + }, | ||
| 121 | + | ||
| 122 | + createMap(container) { | ||
| 123 | + if (this.map) return | ||
| 124 | + this.map = new window.AMap.Map(container, { | ||
| 125 | + zoom: 13, | ||
| 126 | + center: [116.397428, 39.90923], | ||
| 127 | + resizeEnable: true | ||
| 128 | + }) | ||
| 129 | + }, | ||
| 130 | + | ||
| 131 | + async handleSearch() { | ||
| 132 | + if (!this.searchForm.userId) { | ||
| 133 | + this.$message.warning('请选择人员') | ||
| 134 | + return | ||
| 135 | + } | ||
| 136 | + try { | ||
| 137 | + const params = { userId: this.searchForm.userId } | ||
| 138 | + if (this.searchForm.startTime) params.startTime = this.searchForm.startTime | ||
| 139 | + if (this.searchForm.endTime) params.endTime = this.searchForm.endTime | ||
| 140 | + const res = await queryLocationTracks(params) | ||
| 141 | + const list = Array.isArray(res.data) ? res.data : (res.data && res.data.data) || [] | ||
| 142 | + this.trackData = list | ||
| 143 | + this.$nextTick(() => this.renderTrack()) | ||
| 144 | + } catch (e) { | ||
| 145 | + this.$message.error('查询轨迹失败') | ||
| 146 | + console.error(e) | ||
| 147 | + } | ||
| 148 | + }, | ||
| 149 | + | ||
| 150 | + renderTrack() { | ||
| 151 | + if (!this.map || !this.trackData.length) return | ||
| 152 | + this.clearMapElements() | ||
| 153 | + | ||
| 154 | + const path = this.trackData.map(p => [p.longitude, p.latitude]) | ||
| 155 | + | ||
| 156 | + this.polyline = new window.AMap.Polyline({ | ||
| 157 | + map: this.map, | ||
| 158 | + path: path, | ||
| 159 | + strokeColor: '#1890ff', | ||
| 160 | + strokeWeight: 4, | ||
| 161 | + strokeOpacity: 0.8 | ||
| 162 | + }) | ||
| 163 | + | ||
| 164 | + if (path.length > 0) { | ||
| 165 | + const start = path[0] | ||
| 166 | + const end = path[path.length - 1] | ||
| 167 | + const startMarker = new window.AMap.Marker({ | ||
| 168 | + map: this.map, | ||
| 169 | + position: start, | ||
| 170 | + title: '起点', | ||
| 171 | + icon: new window.AMap.Icon({ | ||
| 172 | + size: new window.AMap.Size(25, 34), | ||
| 173 | + imageSize: new window.AMap.Size(25, 34), | ||
| 174 | + image: 'https://webapi.amap.com/theme/v1.3/markers/n/start.png' | ||
| 175 | + }) | ||
| 176 | + }) | ||
| 177 | + const endMarker = new window.AMap.Marker({ | ||
| 178 | + map: this.map, | ||
| 179 | + position: end, | ||
| 180 | + title: '终点', | ||
| 181 | + icon: new window.AMap.Icon({ | ||
| 182 | + size: new window.AMap.Size(25, 34), | ||
| 183 | + imageSize: new window.AMap.Size(25, 34), | ||
| 184 | + image: 'https://webapi.amap.com/theme/v1.3/markers/n/end.png' | ||
| 185 | + }) | ||
| 186 | + }) | ||
| 187 | + this.markers.push(startMarker, endMarker) | ||
| 188 | + } | ||
| 189 | + this.map.setFitView(null, false, [60, 60, 60, 60]) | ||
| 190 | + }, | ||
| 191 | + | ||
| 192 | + clearMapElements() { | ||
| 193 | + if (this.polyline) { this.polyline.setMap(null); this.polyline = null } | ||
| 194 | + this.markers.forEach(m => m.setMap(null)) | ||
| 195 | + this.markers = [] | ||
| 196 | + if (this.movingMarker) { this.movingMarker.setMap(null); this.movingMarker = null } | ||
| 197 | + }, | ||
| 198 | + | ||
| 199 | + handlePlayTrack() { | ||
| 200 | + if (this.playing) { | ||
| 201 | + clearInterval(this.playTimer) | ||
| 202 | + this.playTimer = null | ||
| 203 | + this.playing = false | ||
| 204 | + this.$message.info('轨迹播放已暂停') | ||
| 205 | + return | ||
| 206 | + } | ||
| 207 | + if (!this.trackData.length) return | ||
| 208 | + this.playing = true | ||
| 209 | + this.playIndex = 0 | ||
| 210 | + | ||
| 211 | + if (this.movingMarker) { this.movingMarker.setMap(null); this.movingMarker = null } | ||
| 212 | + const firstPoint = this.trackData[0] | ||
| 213 | + this.movingMarker = new window.AMap.Marker({ | ||
| 214 | + map: this.map, | ||
| 215 | + position: [firstPoint.longitude, firstPoint.latitude], | ||
| 216 | + icon: new window.AMap.Icon({ | ||
| 217 | + size: new window.AMap.Size(30, 30), | ||
| 218 | + imageSize: new window.AMap.Size(30, 30), | ||
| 219 | + image: 'https://webapi.amap.com/theme/v1.3/markers/n/mark_r.png' | ||
| 220 | + }) | ||
| 221 | + }) | ||
| 222 | + | ||
| 223 | + this.playTimer = setInterval(() => { | ||
| 224 | + if (this.playIndex >= this.trackData.length) { | ||
| 225 | + clearInterval(this.playTimer) | ||
| 226 | + this.playTimer = null | ||
| 227 | + this.playing = false | ||
| 228 | + this.$message.success('轨迹播放完成') | ||
| 229 | + return | ||
| 230 | + } | ||
| 231 | + const point = this.trackData[this.playIndex] | ||
| 232 | + if (this.movingMarker) { | ||
| 233 | + this.movingMarker.setPosition([point.longitude, point.latitude]) | ||
| 234 | + } | ||
| 235 | + this.map.setCenter([point.longitude, point.latitude]) | ||
| 236 | + this.playIndex++ | ||
| 237 | + }, 500) | ||
| 238 | + }, | ||
| 239 | + | ||
| 240 | + locatePoint(row) { | ||
| 241 | + if (this.map) { | ||
| 242 | + this.map.setCenter([row.longitude, row.latitude]) | ||
| 243 | + this.map.setZoom(18) | ||
| 244 | + } | ||
| 245 | + } | ||
| 246 | + } | ||
| 247 | +} | ||
| 248 | +</script> | ||
| 249 | + | ||
| 250 | +<style scoped> | ||
| 251 | +.track-container { padding: 16px; } | ||
| 252 | +.search-card { margin-bottom: 16px; } | ||
| 253 | +.map-container { | ||
| 254 | + width: 100%; | ||
| 255 | + height: 500px; | ||
| 256 | + margin-bottom: 16px; | ||
| 257 | + border-radius: 4px; | ||
| 258 | + border: 1px solid #ebeef5; | ||
| 259 | +} | ||
| 260 | +.list-card { margin-bottom: 16px; } | ||
| 261 | +</style> |