Commit 1052554200c27d3ebb8fef826a9e3023f9886541

Authored by 王彪总
1 parent 59fd5759

feat(map): 替换腾讯地图为高德地图并优化检查点功能

- 将腾讯地图API替换为高德地图API
- 移除检查点经纬度输入框的禁用状态
- 重构地图组件中的标记点和路径绘制逻辑
- 添加地图覆盖物清理功能
- 更新登录页面UI设计
- 修改系统标题和公司名称显示
- 添加property路由模块
- 修复API请求参数中的硬编码值
.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>