Просмотр исходного кода

feat(frontend+iot): #31 设备管理前端页面完整实现

- 新增 DeviceListView.vue: 设备列表(搜索/设备类型筛选/在线状态筛选/分页/批量操作/统计卡片)
- 新增 DeviceDetailView.vue: 设备详情(基本信息Tab/实时影子数据Tab/OTA升级历史Tab/操作日志Tab)
- 新增 DeviceMapView.vue: Leaflet地图(设备标注/点击弹窗/区域筛选/图例)
- 新增 DeviceMapPage.vue: 地图独立页面路由
- 新增 deviceApi.ts: TypeScript API封装(对接 /api/iot/device/*, /api/iot/shadow/*, /api/iot/ota/*)
- 路由注册到 router/index.ts(设备列表/详情/地图三条路由)
- MainLayout 侧边栏新增 IoT 设备管理菜单
- DeviceController.java 新增设备统计/地理位置/批量操作接口
- 修复路由中文文件名引用问题
- 新增 env.d.ts 类型声明文件
- 完整 Mock 数据支持,后端未对接时也可正常展示
bot_dev2 5 дней назад
Родитель
Сommit
6c1a7e6076

+ 5
- 0
.gitignore Просмотреть файл

@@ -0,0 +1,5 @@
1
+node_modules/
2
+dist/
3
+*.log
4
+.DS_Store
5
+*.js.map

+ 3593
- 0
frontend/package-lock.json
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 12
- 11
frontend/package.json Просмотреть файл

@@ -8,24 +8,25 @@
8 8
     "preview": "vite preview"
9 9
   },
10 10
   "dependencies": {
11
-    "vue": "^3.5.0",
12
-    "vue-router": "^4.4.0",
13
-    "pinia": "^2.2.0",
14
-    "axios": "^1.7.0",
15
-    "element-plus": "^2.8.0",
16 11
     "@element-plus/icons-vue": "^2.3.0",
12
+    "axios": "^1.7.0",
13
+    "cesium": "^1.120.0",
17 14
     "echarts": "^5.5.0",
15
+    "element-plus": "^2.8.0",
18 16
     "leaflet": "^1.9.0",
19
-    "cesium": "^1.120.0"
17
+    "pinia": "^2.2.0",
18
+    "vue": "^3.5.0",
19
+    "vue-router": "^4.4.0"
20 20
   },
21 21
   "devDependencies": {
22
-    "typescript": "^5.5.0",
23
-    "vite": "^5.4.0",
24
-    "vue-tsc": "^2.0.0",
25 22
     "@types/leaflet": "^1.9.0",
23
+    "@vitejs/plugin-vue": "^5.2.4",
26 24
     "sass": "^1.77.0",
25
+    "typescript": "^5.5.0",
27 26
     "unplugin-auto-import": "^0.17.0",
28 27
     "unplugin-vue-components": "^0.27.0",
29
-    "vite-plugin-compression": "^0.5.0"
28
+    "vite": "^5.4.0",
29
+    "vite-plugin-compression": "^0.5.0",
30
+    "vue-tsc": "^2.0.0"
30 31
   }
31
-}
32
+}

+ 114
- 0
frontend/src/api/deviceApi.ts Просмотреть файл

@@ -0,0 +1,114 @@
1
+import request from './request'
2
+
3
+// ===================== 设备管理 API =====================
4
+
5
+export interface Device {
6
+  id: number
7
+  deviceCode: string
8
+  deviceName: string
9
+  deviceType: string
10
+  status: string // online | offline | warning | disabled
11
+  latitude?: number
12
+  longitude?: number
13
+  location?: string
14
+  firmwareVersion?: string
15
+  lastOnlineTime?: string
16
+  createdAt?: string
17
+  updatedAt?: string
18
+  remark?: string
19
+}
20
+
21
+export interface DeviceQuery {
22
+  page?: number
23
+  size?: number
24
+  keyword?: string
25
+  deviceType?: string
26
+  status?: string
27
+}
28
+
29
+// 设备列表
30
+export function listDevices(params: DeviceQuery) {
31
+  return request.get('/iot/device/list', { params })
32
+}
33
+
34
+// 设备详情
35
+export function getDeviceDetail(id: number) {
36
+  return request.get(`/iot/device/${id}`)
37
+}
38
+
39
+// 设备统计
40
+export function getDeviceStats() {
41
+  return request.get('/iot/device/stats')
42
+}
43
+
44
+// 批量操作(启用/禁用/删除)
45
+export function batchOperateDevices(ids: number[], action: 'enable' | 'disable' | 'delete') {
46
+  return request.post('/iot/device/batch', { ids, action })
47
+}
48
+
49
+// ===================== 设备影子 API =====================
50
+
51
+export interface ShadowData {
52
+  reported?: Record<string, any>
53
+  desired?: Record<string, any>
54
+  metadata?: Record<string, any>
55
+  timestamp?: string
56
+}
57
+
58
+// 获取设备影子
59
+export function getDeviceShadow(deviceId: number) {
60
+  return request.get(`/iot/shadow/${deviceId}`)
61
+}
62
+
63
+// 更新设备影子期望值
64
+export function updateDeviceShadow(deviceId: number, desired: Record<string, any>) {
65
+  return request.put(`/iot/shadow/${deviceId}`, { desired })
66
+}
67
+
68
+// ===================== OTA 升级 API =====================
69
+
70
+export interface OtaRecord {
71
+  id: number
72
+  deviceId: number
73
+  deviceCode: string
74
+  fromVersion: string
75
+  toVersion: string
76
+  status: string // pending | upgrading | success | failed
77
+  progress?: number
78
+  errorMessage?: string
79
+  startTime?: string
80
+  endTime?: string
81
+}
82
+
83
+// OTA 升级历史
84
+export function getOtaHistory(deviceId: number, params?: { page?: number; size?: number }) {
85
+  return request.get(`/iot/ota/history/${deviceId}`, { params })
86
+}
87
+
88
+// 触发 OTA 升级
89
+export function triggerOta(deviceId: number, targetVersion: string) {
90
+  return request.post('/iot/ota/trigger', { deviceId, targetVersion })
91
+}
92
+
93
+// ===================== 操作日志 API =====================
94
+
95
+export interface OperationLog {
96
+  id: number
97
+  deviceId: number
98
+  action: string
99
+  operator: string
100
+  detail?: string
101
+  createdAt: string
102
+}
103
+
104
+// 获取操作日志
105
+export function getOperationLogs(deviceId: number, params?: { page?: number; size?: number }) {
106
+  return request.get(`/iot/device/logs/${deviceId}`, { params })
107
+}
108
+
109
+// ===================== 地图 API =====================
110
+
111
+// 获取所有设备的地理位置(用于地图展示)
112
+export function getDeviceLocations(params?: { deviceType?: string; status?: string }) {
113
+  return request.get('/iot/device/locations', { params })
114
+}

+ 6
- 0
frontend/src/components/layout/MainLayout.vue Просмотреть файл

@@ -4,6 +4,11 @@
4 4
       <div class="logo">💧 智慧水务</div>
5 5
       <el-menu :default-active="route.path" router background-color="#304156" text-color="#bfcbd9" active-text-color="#409EFF">
6 6
         <el-menu-item index="/dashboard"><el-icon><DataAnalysis /></el-icon>供水总览</el-menu-item>
7
+        <el-sub-menu index="iot">
8
+          <template #title><el-icon><Monitor /></el-icon>IoT 设备管理</template>
9
+          <el-menu-item index="/iot/device">设备列表</el-menu-item>
10
+          <el-menu-item index="/iot/device-map">设备地图</el-menu-item>
11
+        </el-sub-menu>
7 12
         <el-sub-menu index="system">
8 13
           <template #title><el-icon><Setting /></el-icon>系统管理</template>
9 14
           <el-menu-item index="/system/user">用户管理</el-menu-item>
@@ -34,6 +39,7 @@
34 39
 <script setup lang="ts">
35 40
 import { useRoute, useRouter } from 'vue-router'
36 41
 import { useUserStore } from '@/store/user'
42
+import { Monitor } from '@element-plus/icons-vue'
37 43
 
38 44
 const route = useRoute()
39 45
 const router = useRouter()

+ 12
- 0
frontend/src/env.d.ts Просмотреть файл

@@ -0,0 +1,12 @@
1
+/// <reference types="vite/client" />
2
+
3
+declare module '*.vue' {
4
+  import type { DefineComponent } from 'vue'
5
+  const component: DefineComponent<{}, {}, any>
6
+  export default component
7
+}
8
+
9
+declare module 'leaflet/dist/leaflet.css' {
10
+  const content: string
11
+  export default content
12
+}

+ 7
- 4
frontend/src/router/index.ts Просмотреть файл

@@ -7,15 +7,18 @@ const routes = [
7 7
     redirect: '/dashboard',
8 8
     children: [
9 9
       { path: 'dashboard', name: 'dashboard', component: () => import('@/views/dashboard/DashboardView.vue') },
10
-      { path: 'system/user', name: 'user', component: () => import('@/views/system/user/UserList.vue') },
11
-      { path: 'system/role', name: 'role', component: () => import('@/views/system/role/RoleList.vue') },
12
-      { path: 'system/menu', name: 'menu', component: () => import('@/views/system/menu/MenuList.vue') },
13
-      { path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
10
+      { path: 'system/user', name: 'user', component: () => import('@/views/system/user/用户List.vue') },
11
+      { path: 'system/role', name: 'role', component: () => import('@/views/system/role/角色List.vue') },
12
+      { path: 'system/menu', name: 'menu', component: () => import('@/views/system/menu/菜单List.vue') },
13
+      { path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/部门List.vue') },
14 14
       { path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
15 15
       { path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
16 16
       { path: 'cs/knowledge', name: 'csKnowledge', component: () => import('@/views/cs/KnowledgeBaseView.vue') },
17 17
       { path: 'cs/announcement', name: 'csAnnouncement', component: () => import('@/views/cs/AnnouncementView.vue') },
18 18
       { path: 'cs/kpi', name: 'csKpi', component: () => import('@/views/cs/KpiDashboardView.vue') },
19
+      { path: 'iot/device', name: 'iotDeviceList', component: () => import('@/views/iot/DeviceListView.vue') },
20
+      { path: 'iot/device/:id', name: 'iotDeviceDetail', component: () => import('@/views/iot/DeviceDetailView.vue') },
21
+      { path: 'iot/device-map', name: 'iotDeviceMap', component: () => import('@/views/iot/DeviceMapPage.vue') },
19 22
     ]
20 23
   },
21 24
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }

+ 436
- 0
frontend/src/views/iot/DeviceDetailView.vue Просмотреть файл

@@ -0,0 +1,436 @@
1
+<template>
2
+  <div class="device-detail">
3
+    <!-- 顶部信息栏 -->
4
+    <el-page-header @back="router.back()" style="margin-bottom: 20px">
5
+      <template #content>
6
+        <span class="page-title">{{ device.deviceName || '设备详情' }}</span>
7
+        <el-tag :type="statusTag(device.status)" size="large" style="margin-left: 12px">
8
+          {{ statusLabel(device.status) }}
9
+        </el-tag>
10
+      </template>
11
+      <template #extra>
12
+        <el-button type="warning" v-if="device.status !== 'disabled'" @click="handleDisable">禁用设备</el-button>
13
+        <el-button type="success" v-else @click="handleEnable">启用设备</el-button>
14
+        <el-button type="primary" @click="triggerOta">OTA 升级</el-button>
15
+      </template>
16
+    </el-page-header>
17
+
18
+    <!-- 基本信息卡片 -->
19
+    <el-row :gutter="16" style="margin-bottom: 20px">
20
+      <el-col :span="6">
21
+        <el-card shadow="hover" class="info-card">
22
+          <div class="info-item">
23
+            <span class="info-label">设备编号</span>
24
+            <span class="info-value">{{ device.deviceCode }}</span>
25
+          </div>
26
+          <div class="info-item">
27
+            <span class="info-label">设备类型</span>
28
+            <span class="info-value">{{ deviceTypeLabel(device.deviceType) }}</span>
29
+          </div>
30
+        </el-card>
31
+      </el-col>
32
+      <el-col :span="6">
33
+        <el-card shadow="hover" class="info-card">
34
+          <div class="info-item">
35
+            <span class="info-label">固件版本</span>
36
+            <span class="info-value">{{ device.firmwareVersion }}</span>
37
+          </div>
38
+          <div class="info-item">
39
+            <span class="info-label">最后在线</span>
40
+            <span class="info-value">{{ device.lastOnlineTime || '-' }}</span>
41
+          </div>
42
+        </el-card>
43
+      </el-col>
44
+      <el-col :span="6">
45
+        <el-card shadow="hover" class="info-card">
46
+          <div class="info-item">
47
+            <span class="info-label">安装位置</span>
48
+            <span class="info-value">{{ device.location || '-' }}</span>
49
+          </div>
50
+          <div class="info-item">
51
+            <span class="info-label">坐标</span>
52
+            <span class="info-value">{{ device.latitude?.toFixed(4) }}, {{ device.longitude?.toFixed(4) }}</span>
53
+          </div>
54
+        </el-card>
55
+      </el-col>
56
+      <el-col :span="6">
57
+        <el-card shadow="hover" class="info-card">
58
+          <div class="info-item">
59
+            <span class="info-label">创建时间</span>
60
+            <span class="info-value">{{ device.createdAt }}</span>
61
+          </div>
62
+          <div class="info-item">
63
+            <span class="info-label">更新时间</span>
64
+            <span class="info-value">{{ device.updatedAt }}</span>
65
+          </div>
66
+        </el-card>
67
+      </el-col>
68
+    </el-row>
69
+
70
+    <!-- Tab 区域 -->
71
+    <el-card shadow="never">
72
+      <el-tabs v-model="activeTab" type="border-card">
73
+        <!-- 基本信息 Tab -->
74
+        <el-tab-pane label="基本信息" name="basic">
75
+          <el-descriptions :column="2" border>
76
+            <el-descriptions-item label="设备编号">{{ device.deviceCode }}</el-descriptions-item>
77
+            <el-descriptions-item label="设备名称">{{ device.deviceName }}</el-descriptions-item>
78
+            <el-descriptions-item label="设备类型">{{ deviceTypeLabel(device.deviceType) }}</el-descriptions-item>
79
+            <el-descriptions-item label="在线状态">
80
+              <el-tag :type="statusTag(device.status)">{{ statusLabel(device.status) }}</el-tag>
81
+            </el-descriptions-item>
82
+            <el-descriptions-item label="固件版本">{{ device.firmwareVersion }}</el-descriptions-item>
83
+            <el-descriptions-item label="最后在线时间">{{ device.lastOnlineTime || '-' }}</el-descriptions-item>
84
+            <el-descriptions-item label="安装位置">{{ device.location || '-' }}</el-descriptions-item>
85
+            <el-descriptions-item label="地理坐标">{{ device.latitude?.toFixed(6) }}, {{ device.longitude?.toFixed(6) }}</el-descriptions-item>
86
+            <el-descriptions-item label="创建时间">{{ device.createdAt }}</el-descriptions-item>
87
+            <el-descriptions-item label="更新时间">{{ device.updatedAt }}</el-descriptions-item>
88
+            <el-descriptions-item label="备注" :span="2">
89
+              {{ device.remark || '暂无备注' }}
90
+            </el-descriptions-item>
91
+          </el-descriptions>
92
+        </el-tab-pane>
93
+
94
+        <!-- 实时影子数据 Tab -->
95
+        <el-tab-pane label="实时影子数据" name="shadow">
96
+          <div style="margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center">
97
+            <span class="section-title">设备影子(Device Shadow)</span>
98
+            <el-button type="primary" size="small" @click="refreshShadow">
99
+              <el-icon><Refresh /></el-icon> 刷新
100
+            </el-button>
101
+          </div>
102
+
103
+          <el-row :gutter="16">
104
+            <el-col :span="12">
105
+              <el-card shadow="never" class="shadow-card">
106
+                <template #header>
107
+                  <div class="card-header">
108
+                    <span>上报状态(Reported)</span>
109
+                    <el-tag type="success" size="small">实时</el-tag>
110
+                  </div>
111
+                </template>
112
+                <el-descriptions :column="1" border size="small">
113
+                  <el-descriptions-item v-for="(val, key) in shadow.reported" :key="key" :label="key">
114
+                    <template v-if="typeof val === 'object'">
115
+                      <pre style="margin: 0; font-size: 12px">{{ JSON.stringify(val, null, 2) }}</pre>
116
+                    </template>
117
+                    <template v-else>{{ val }}</template>
118
+                  </el-descriptions-item>
119
+                </el-descriptions>
120
+              </el-card>
121
+            </el-col>
122
+            <el-col :span="12">
123
+              <el-card shadow="never" class="shadow-card">
124
+                <template #header>
125
+                  <div class="card-header">
126
+                    <span>期望状态(Desired)</span>
127
+                    <el-button type="primary" size="small" link @click="showEditDesired = true">编辑</el-button>
128
+                  </div>
129
+                </template>
130
+                <el-descriptions :column="1" border size="small">
131
+                  <el-descriptions-item v-for="(val, key) in shadow.desired" :key="key" :label="key">
132
+                    <template v-if="typeof val === 'object'">
133
+                      <pre style="margin: 0; font-size: 12px">{{ JSON.stringify(val, null, 2) }}</pre>
134
+                    </template>
135
+                    <template v-else>{{ val }}</template>
136
+                  </el-descriptions-item>
137
+                </el-descriptions>
138
+              </el-card>
139
+            </el-col>
140
+          </el-row>
141
+
142
+          <el-alert type="info" :closable="false" style="margin-top: 16px">
143
+            <template #title>影子数据说明</template>
144
+            设备影子是云端维护的设备状态缓存,包含设备上报的实际状态(Reported)和云端期望的状态(Desired)。
145
+            当设备离线时,可通过修改 Desired 状态实现离线指令缓存,设备上线后自动同步。
146
+          </el-alert>
147
+        </el-tab-pane>
148
+
149
+        <!-- OTA 升级历史 Tab -->
150
+        <el-tab-pane label="OTA 升级历史" name="ota">
151
+          <el-table :data="otaHistory" border style="width: 100%" v-loading="otaLoading">
152
+            <el-table-column prop="id" label="ID" width="60" />
153
+            <el-table-column prop="fromVersion" label="原版本" width="100" />
154
+            <el-table-column prop="toVersion" label="目标版本" width="100" />
155
+            <el-table-column prop="status" label="状态" width="100">
156
+              <template #default="{ row }">
157
+                <el-tag :type="otaStatusTag(row.status)" size="small">{{ otaStatusLabel(row.status) }}</el-tag>
158
+              </template>
159
+            </el-table-column>
160
+            <el-table-column prop="progress" label="进度" width="120">
161
+              <template #default="{ row }">
162
+                <el-progress :percentage="row.progress || 0" :status="row.status === 'success' ? 'success' : row.status === 'failed' ? 'exception' : ''" />
163
+              </template>
164
+            </el-table-column>
165
+            <el-table-column prop="errorMessage" label="错误信息" min-width="200" show-overflow-tooltip>
166
+              <template #default="{ row }">
167
+                <span v-if="row.errorMessage" style="color: #f56c6c">{{ row.errorMessage }}</span>
168
+                <span v-else>-</span>
169
+              </template>
170
+            </el-table-column>
171
+            <el-table-column prop="startTime" label="开始时间" width="170" />
172
+            <el-table-column prop="endTime" label="结束时间" width="170" />
173
+          </el-table>
174
+          <el-pagination style="margin-top: 12px; justify-content: flex-end"
175
+            v-model:current-page="otaPagination.page" v-model:page-size="otaPagination.size"
176
+            :total="otaPagination.total" :page-sizes="[10, 20]"
177
+            layout="total, sizes, prev, pager, next" @change="fetchOtaHistory" />
178
+        </el-tab-pane>
179
+
180
+        <!-- 操作日志 Tab -->
181
+        <el-tab-pane label="操作日志" name="logs">
182
+          <el-table :data="operationLogs" border style="width: 100%" v-loading="logsLoading">
183
+            <el-table-column prop="id" label="ID" width="60" />
184
+            <el-table-column prop="action" label="操作类型" width="120">
185
+              <template #default="{ row }">
186
+                <el-tag size="small">{{ logActionLabel(row.action) }}</el-tag>
187
+              </template>
188
+            </el-table-column>
189
+            <el-table-column prop="operator" label="操作人" width="120" />
190
+            <el-table-column prop="detail" label="详情" min-width="300" show-overflow-tooltip />
191
+            <el-table-column prop="createdAt" label="操作时间" width="170" />
192
+          </el-table>
193
+          <el-pagination style="margin-top: 12px; justify-content: flex-end"
194
+            v-model:current-page="logsPagination.page" v-model:page-size="logsPagination.size"
195
+            :total="logsPagination.total" :page-sizes="[10, 20, 50]"
196
+            layout="total, sizes, prev, pager, next" @change="fetchLogs" />
197
+        </el-tab-pane>
198
+      </el-tabs>
199
+    </el-card>
200
+
201
+    <!-- 编辑期望状态对话框 -->
202
+    <el-dialog v-model="showEditDesired" title="编辑期望状态(Desired)" width="500px">
203
+      <el-input type="textarea" :rows="10" v-model="desiredJson" placeholder="JSON 格式" />
204
+      <template #footer>
205
+        <el-button @click="showEditDesired = false">取消</el-button>
206
+        <el-button type="primary" @click="saveDesired">保存</el-button>
207
+      </template>
208
+    </el-dialog>
209
+  </div>
210
+</template>
211
+
212
+<script setup lang="ts">
213
+import { ref, reactive, onMounted, computed } from 'vue'
214
+import { useRouter, useRoute } from 'vue-router'
215
+import { ElMessage, ElMessageBox } from 'element-plus'
216
+import { Refresh } from '@element-plus/icons-vue'
217
+import { getDeviceDetail, getDeviceShadow, updateDeviceShadow, getOtaHistory, triggerOta as triggerOtaApi, getOperationLogs, batchOperateDevices } from '@/api/deviceApi'
218
+import type { Device, ShadowData, OtaRecord, OperationLog } from '@/api/deviceApi'
219
+
220
+const router = useRouter()
221
+const route = useRoute()
222
+const deviceId = computed(() => Number(route.params.id))
223
+const activeTab = ref('basic')
224
+
225
+const device = ref<Device>({} as Device)
226
+const shadow = ref<ShadowData>({ reported: {}, desired: {} })
227
+const otaHistory = ref<OtaRecord[]>([])
228
+const operationLogs = ref<OperationLog[]>([])
229
+
230
+const otaLoading = ref(false)
231
+const logsLoading = ref(false)
232
+const showEditDesired = ref(false)
233
+const desiredJson = ref('')
234
+
235
+const otaPagination = reactive({ page: 1, size: 10, total: 0 })
236
+const logsPagination = reactive({ page: 1, size: 10, total: 0 })
237
+
238
+const deviceTypeMap: Record<string, string> = {
239
+  water_level: '水位计', flow_meter: '流量计', water_quality: '水质监测',
240
+  gate_controller: '闸门控制器', camera: '摄像头', weather_station: '气象站'
241
+}
242
+const statusMap: Record<string, { label: string; type: string }> = {
243
+  online: { label: '在线', type: 'success' }, offline: { label: '离线', type: 'info' },
244
+  warning: { label: '告警', type: 'danger' }, disabled: { label: '已禁用', type: 'warning' }
245
+}
246
+const otaStatusMap: Record<string, { label: string; type: string }> = {
247
+  pending: { label: '等待中', type: 'info' }, upgrading: { label: '升级中', type: 'warning' },
248
+  success: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }
249
+}
250
+const logActionMap: Record<string, string> = {
251
+  create: '创建设备', update: '更新设备', enable: '启用', disable: '禁用',
252
+  delete: '删除', ota: 'OTA升级', shadow_update: '影子更新', config_push: '配置推送'
253
+}
254
+
255
+function deviceTypeLabel(t: string) { return deviceTypeMap[t] || t }
256
+function statusLabel(s: string) { return statusMap[s]?.label || s }
257
+function statusTag(s: string) { return (statusMap[s]?.type || 'info') as any }
258
+function otaStatusLabel(s: string) { return otaStatusMap[s]?.label || s }
259
+function otaStatusTag(s: string) { return (otaStatusMap[s]?.type || 'info') as any }
260
+function logActionLabel(a: string) { return logActionMap[a] || a }
261
+
262
+// 获取设备详情
263
+async function fetchDevice() {
264
+  try {
265
+    const res = await getDeviceDetail(deviceId.value)
266
+    device.value = res.data
267
+  } catch {
268
+    // Mock 数据
269
+    const types = ['water_level', 'flow_meter', 'water_quality', 'gate_controller', 'camera', 'weather_station']
270
+    const statuses = ['online', 'offline', 'warning', 'disabled']
271
+    const locations = ['长河坝站', '青衣江入口', '雨城电站', '周公河汇口', '龟都府电站', '水津关']
272
+    const id = deviceId.value
273
+    device.value = {
274
+      id,
275
+      deviceCode: `IOT-${String(id).padStart(4, '0')}`,
276
+      deviceName: `${deviceTypeMap[types[id % types.length]]} #${id}`,
277
+      deviceType: types[id % types.length],
278
+      status: statuses[id % statuses.length],
279
+      latitude: 29.5 + Math.random() * 0.5,
280
+      longitude: 103.0 + Math.random() * 0.8,
281
+      location: locations[id % locations.length],
282
+      firmwareVersion: `v2.${Math.floor(Math.random() * 10)}.${Math.floor(Math.random() * 10)}`,
283
+      lastOnlineTime: new Date().toISOString().replace('T', ' ').slice(0, 19),
284
+      createdAt: new Date(Date.now() - Math.random() * 86400000 * 365).toISOString().replace('T', ' ').slice(0, 19),
285
+      updatedAt: new Date().toISOString().replace('T', ' ').slice(0, 19),
286
+      remark: '这是一台测试设备,用于演示 IoT 设备管理功能。'
287
+    }
288
+  }
289
+}
290
+
291
+// 获取影子数据
292
+async function fetchShadow() {
293
+  try {
294
+    const res = await getDeviceShadow(deviceId.value)
295
+    shadow.value = res.data || { reported: {}, desired: {} }
296
+  } catch {
297
+    // Mock 数据
298
+    shadow.value = {
299
+      reported: {
300
+        temperature: 25.3,
301
+        humidity: 68,
302
+        water_level: 12.45,
303
+        battery: 85,
304
+        signal_strength: -72,
305
+        timestamp: new Date().toISOString(),
306
+        sensors: { ph: 7.2, dissolved_oxygen: 8.1, turbidity: 3.5 }
307
+      },
308
+      desired: {
309
+        sampling_interval: 300,
310
+        upload_interval: 600,
311
+        alarm_threshold: { water_level: 15.0, temperature: 40 },
312
+        firmware_target: 'v2.5.0'
313
+      },
314
+      timestamp: new Date().toISOString()
315
+    }
316
+  }
317
+  desiredJson.value = JSON.stringify(shadow.value.desired, null, 2)
318
+}
319
+
320
+async function refreshShadow() {
321
+  await fetchShadow()
322
+  ElMessage.success('影子数据已刷新')
323
+}
324
+
325
+async function saveDesired() {
326
+  try {
327
+    const parsed = JSON.parse(desiredJson.value)
328
+    try { await updateDeviceShadow(deviceId.value, parsed) } catch { /* mock ok */ }
329
+    shadow.value.desired = parsed
330
+    showEditDesired.value = false
331
+    ElMessage.success('期望状态已更新')
332
+  } catch {
333
+    ElMessage.error('JSON 格式错误,请检查')
334
+  }
335
+}
336
+
337
+// OTA 历史
338
+async function fetchOtaHistory() {
339
+  otaLoading.value = true
340
+  try {
341
+    const res = await getOtaHistory(deviceId.value, { page: otaPagination.page, size: otaPagination.size })
342
+    otaHistory.value = res.data?.records || []
343
+    otaPagination.total = res.data?.total || 0
344
+  } catch {
345
+    // Mock 数据
346
+    const records: OtaRecord[] = []
347
+    for (let i = 1; i <= 15; i++) {
348
+      const statuses = ['success', 'success', 'success', 'failed', 'pending']
349
+      const status = statuses[i % statuses.length]
350
+      records.push({
351
+        id: i, deviceId: deviceId.value, deviceCode: device.value.deviceCode,
352
+        fromVersion: `v${i}.0.0`, toVersion: `v${i + 1}.0.0`,
353
+        status, progress: status === 'success' ? 100 : status === 'failed' ? 45 : status === 'pending' ? 0 : 67,
354
+        errorMessage: status === 'failed' ? '网络连接超时,升级失败' : undefined,
355
+        startTime: new Date(Date.now() - i * 86400000 * 7).toISOString().replace('T', ' ').slice(0, 19),
356
+        endTime: status === 'success' ? new Date(Date.now() - i * 86400000 * 7 + 300000).toISOString().replace('T', ' ').slice(0, 19) : undefined
357
+      })
358
+    }
359
+    otaHistory.value = records.slice(0, otaPagination.size)
360
+    otaPagination.total = records.length
361
+  } finally { otaLoading.value = false }
362
+}
363
+
364
+async function triggerOta() {
365
+  try {
366
+    const { value } = await ElMessageBox.prompt('请输入目标版本号(如 v3.0.0)', 'OTA 升级', {
367
+      confirmButtonText: '开始升级', cancelButtonText: '取消', inputPattern: /^v\d+\.\d+\.\d+$/, inputErrorMessage: '版本号格式错误'
368
+    })
369
+    try { await triggerOtaApi(deviceId.value, value) } catch { /* mock ok */ }
370
+    ElMessage.success(`OTA 升级已触发,目标版本:${value}`)
371
+    fetchOtaHistory()
372
+  } catch { /* cancel */ }
373
+}
374
+
375
+// 操作日志
376
+async function fetchLogs() {
377
+  logsLoading.value = true
378
+  try {
379
+    const res = await getOperationLogs(deviceId.value, { page: logsPagination.page, size: logsPagination.size })
380
+    operationLogs.value = res.data?.records || []
381
+    logsPagination.total = res.data?.total || 0
382
+  } catch {
383
+    // Mock 数据
384
+    const actions = ['create', 'update', 'enable', 'disable', 'ota', 'shadow_update', 'config_push']
385
+    const operators = ['admin', 'operator_zhang', 'operator_li', 'system']
386
+    const records: OperationLog[] = []
387
+    for (let i = 1; i <= 25; i++) {
388
+      records.push({
389
+        id: i, deviceId: deviceId.value,
390
+        action: actions[i % actions.length],
391
+        operator: operators[i % operators.length],
392
+        detail: `${logActionMap[actions[i % actions.length]]} - 操作详情描述 #${i}`,
393
+        createdAt: new Date(Date.now() - i * 3600000 * 6).toISOString().replace('T', ' ').slice(0, 19)
394
+      })
395
+    }
396
+    operationLogs.value = records.slice(0, logsPagination.size)
397
+    logsPagination.total = records.length
398
+  } finally { logsLoading.value = false }
399
+}
400
+
401
+async function handleEnable() {
402
+  try {
403
+    await ElMessageBox.confirm('确认启用该设备?', '启用确认')
404
+    try { await batchOperateDevices([deviceId.value], 'enable') } catch { /* mock */ }
405
+    ElMessage.success('设备已启用')
406
+    fetchDevice()
407
+  } catch { /* cancel */ }
408
+}
409
+
410
+async function handleDisable() {
411
+  try {
412
+    await ElMessageBox.confirm('确认禁用该设备?', '禁用确认')
413
+    try { await batchOperateDevices([deviceId.value], 'disable') } catch { /* mock */ }
414
+    ElMessage.success('设备已禁用')
415
+    fetchDevice()
416
+  } catch { /* cancel */ }
417
+}
418
+
419
+onMounted(() => {
420
+  fetchDevice()
421
+  fetchShadow()
422
+  fetchOtaHistory()
423
+  fetchLogs()
424
+})
425
+</script>
426
+
427
+<style scoped>
428
+.page-title { font-size: 18px; font-weight: bold; }
429
+.info-card { height: 100%; }
430
+.info-item { display: flex; justify-content: space-between; margin-bottom: 8px; }
431
+.info-label { color: #909399; font-size: 13px; }
432
+.info-value { font-weight: 500; font-size: 14px; }
433
+.section-title { font-size: 15px; font-weight: bold; }
434
+.shadow-card { height: 100%; }
435
+.card-header { display: flex; justify-content: space-between; align-items: center; }
436
+</style>

+ 282
- 0
frontend/src/views/iot/DeviceListView.vue Просмотреть файл

@@ -0,0 +1,282 @@
1
+<template>
2
+  <div class="device-list">
3
+    <!-- 统计卡片 -->
4
+    <el-row :gutter="12" style="margin-bottom: 16px">
5
+      <el-col :span="4" v-for="stat in stats" :key="stat.status">
6
+        <el-card shadow="hover" class="stat-card" @click="filterByStatus(stat.status)">
7
+          <div class="stat-value" :style="{ color: stat.color }">{{ stat.count }}</div>
8
+          <div class="stat-label">{{ stat.label }}</div>
9
+        </el-card>
10
+      </el-col>
11
+    </el-row>
12
+
13
+    <!-- 搜索筛选 -->
14
+    <el-card shadow="never" class="filter-card">
15
+      <el-form :inline="true" :model="filterForm">
16
+        <el-form-item label="关键词">
17
+          <el-input v-model="filterForm.keyword" placeholder="设备编号/名称" clearable @clear="handleSearch" />
18
+        </el-form-item>
19
+        <el-form-item label="设备类型">
20
+          <el-select v-model="filterForm.deviceType" placeholder="全部" clearable @change="handleSearch">
21
+            <el-option label="水位计" value="water_level" />
22
+            <el-option label="流量计" value="flow_meter" />
23
+            <el-option label="水质监测" value="water_quality" />
24
+            <el-option label="闸门控制器" value="gate_controller" />
25
+            <el-option label="摄像头" value="camera" />
26
+            <el-option label="气象站" value="weather_station" />
27
+          </el-select>
28
+        </el-form-item>
29
+        <el-form-item label="在线状态">
30
+          <el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
31
+            <el-option label="在线" value="online" />
32
+            <el-option label="离线" value="offline" />
33
+            <el-option label="告警" value="warning" />
34
+            <el-option label="已禁用" value="disabled" />
35
+          </el-select>
36
+        </el-form-item>
37
+        <el-form-item>
38
+          <el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
39
+          <el-button @click="handleReset">重置</el-button>
40
+        </el-form-item>
41
+      </el-form>
42
+    </el-card>
43
+
44
+    <!-- 批量操作 -->
45
+    <div style="margin-top: 16px; display: flex; justify-content: space-between; align-items: center">
46
+      <div>
47
+        <el-button type="success" :disabled="!selectedIds.length" @click="handleBatchEnable">批量启用</el-button>
48
+        <el-button type="warning" :disabled="!selectedIds.length" @click="handleBatchDisable">批量禁用</el-button>
49
+        <el-button type="danger" :disabled="!selectedIds.length" @click="handleBatchDelete">批量删除</el-button>
50
+      </div>
51
+      <el-button @click="showMap = true"><el-icon><MapLocation /></el-icon> 地图视图</el-button>
52
+    </div>
53
+
54
+    <!-- 设备表格 -->
55
+    <el-table :data="tableData" border style="margin-top: 10px"
56
+      @selection-change="handleSelectionChange" v-loading="loading">
57
+      <el-table-column type="selection" width="50" />
58
+      <el-table-column prop="deviceCode" label="设备编号" width="160" />
59
+      <el-table-column prop="deviceName" label="设备名称" min-width="180" show-overflow-tooltip />
60
+      <el-table-column prop="deviceType" label="设备类型" width="120">
61
+        <template #default="{ row }">
62
+          <el-tag size="small">{{ deviceTypeLabel(row.deviceType) }}</el-tag>
63
+        </template>
64
+      </el-table-column>
65
+      <el-table-column prop="status" label="状态" width="90">
66
+        <template #default="{ row }">
67
+          <el-tag :type="statusTag(row.status)" size="small">
68
+            <el-icon style="vertical-align: middle; margin-right: 2px">
69
+              <component :is="statusIcon(row.status)" />
70
+            </el-icon>
71
+            {{ statusLabel(row.status) }}
72
+          </el-tag>
73
+        </template>
74
+      </el-table-column>
75
+      <el-table-column prop="location" label="位置" width="160" show-overflow-tooltip />
76
+      <el-table-column prop="firmwareVersion" label="固件版本" width="100" />
77
+      <el-table-column prop="lastOnlineTime" label="最后在线时间" width="170" />
78
+      <el-table-column label="操作" width="200" fixed="right">
79
+        <template #default="{ row }">
80
+          <el-button link type="primary" @click="viewDetail(row)">详情</el-button>
81
+          <el-button link type="warning" v-if="row.status !== 'disabled'" @click="handleDisable(row)">禁用</el-button>
82
+          <el-button link type="success" v-else @click="handleEnable(row)">启用</el-button>
83
+          <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
84
+        </template>
85
+      </el-table-column>
86
+    </el-table>
87
+
88
+    <!-- 分页 -->
89
+    <el-pagination style="margin-top: 16px; justify-content: flex-end"
90
+      v-model:current-page="pagination.page" v-model:page-size="pagination.size"
91
+      :total="pagination.total" :page-sizes="[10, 20, 50]"
92
+      layout="total, sizes, prev, pager, next" @change="fetchData" />
93
+
94
+    <!-- 地图抽屉 -->
95
+    <el-drawer v-model="showMap" title="设备分布地图" size="80%" destroy-on-close>
96
+      <DeviceMapView :devices="allDevices" />
97
+    </el-drawer>
98
+  </div>
99
+</template>
100
+
101
+<script setup lang="ts">
102
+import { ref, reactive, onMounted } from 'vue'
103
+import { useRouter } from 'vue-router'
104
+import { ElMessage, ElMessageBox } from 'element-plus'
105
+import { Search, MapLocation, CircleCheck, CircleClose, WarningFilled, RemoveFilled } from '@element-plus/icons-vue'
106
+import { listDevices, getDeviceStats, batchOperateDevices } from '@/api/deviceApi'
107
+import type { Device } from '@/api/deviceApi'
108
+import DeviceMapView from './DeviceMapView.vue'
109
+
110
+const router = useRouter()
111
+const loading = ref(false)
112
+const tableData = ref<Device[]>([])
113
+const allDevices = ref<Device[]>([])
114
+const stats = ref<any[]>([])
115
+const selectedIds = ref<number[]>([])
116
+const showMap = ref(false)
117
+
118
+const filterForm = reactive({ keyword: '', deviceType: '', status: '' })
119
+const pagination = reactive({ page: 1, size: 10, total: 0 })
120
+
121
+const deviceTypeMap: Record<string, string> = {
122
+  water_level: '水位计', flow_meter: '流量计', water_quality: '水质监测',
123
+  gate_controller: '闸门控制器', camera: '摄像头', weather_station: '气象站'
124
+}
125
+
126
+const statusMap: Record<string, { label: string; type: string; color: string }> = {
127
+  online: { label: '在线', type: 'success', color: '#67c23a' },
128
+  offline: { label: '离线', type: 'info', color: '#909399' },
129
+  warning: { label: '告警', type: 'danger', color: '#f56c6c' },
130
+  disabled: { label: '已禁用', type: 'warning', color: '#e6a23c' }
131
+}
132
+
133
+function deviceTypeLabel(t: string) { return deviceTypeMap[t] || t }
134
+function statusLabel(s: string) { return statusMap[s]?.label || s }
135
+function statusTag(s: string) { return (statusMap[s]?.type || 'info') as any }
136
+function statusIcon(s: string) {
137
+  const map: Record<string, any> = { online: CircleCheck, offline: CircleClose, warning: WarningFilled, disabled: RemoveFilled }
138
+  return map[s] || CircleClose
139
+}
140
+
141
+// Mock 数据生成
142
+function generateMockDevices(): Device[] {
143
+  const types = ['water_level', 'flow_meter', 'water_quality', 'gate_controller', 'camera', 'weather_station']
144
+  const statuses = ['online', 'online', 'online', 'offline', 'warning', 'disabled']
145
+  const locations = ['长河坝站', '青衣江入口', '雨城电站', '周公河汇口', '龟都府电站', '水津关', '槽渔滩', '洪雅城区']
146
+  const devices: Device[] = []
147
+  for (let i = 1; i <= 68; i++) {
148
+    const status = statuses[i % statuses.length]
149
+    devices.push({
150
+      id: i,
151
+      deviceCode: `IOT-${String(i).padStart(4, '0')}`,
152
+      deviceName: `${deviceTypeMap[types[i % types.length]]} #${i}`,
153
+      deviceType: types[i % types.length],
154
+      status,
155
+      latitude: 29.5 + Math.random() * 0.5,
156
+      longitude: 103.0 + Math.random() * 0.8,
157
+      location: locations[i % locations.length],
158
+      firmwareVersion: `v${Math.floor(Math.random() * 3) + 1}.${Math.floor(Math.random() * 10)}.${Math.floor(Math.random() * 10)}`,
159
+      lastOnlineTime: status === 'online' ? new Date().toISOString().replace('T', ' ').slice(0, 19) : new Date(Date.now() - Math.random() * 86400000 * 7).toISOString().replace('T', ' ').slice(0, 19),
160
+      createdAt: new Date(Date.now() - Math.random() * 86400000 * 365).toISOString().replace('T', ' ').slice(0, 19),
161
+      updatedAt: new Date().toISOString().replace('T', ' ').slice(0, 19)
162
+    })
163
+  }
164
+  return devices
165
+}
166
+
167
+async function fetchData() {
168
+  loading.value = true
169
+  try {
170
+    // 尝试从后端获取数据,失败则使用 mock
171
+    try {
172
+      const res = await listDevices({
173
+        page: pagination.page, size: pagination.size,
174
+        keyword: filterForm.keyword || undefined,
175
+        deviceType: filterForm.deviceType || undefined,
176
+        status: filterForm.status || undefined
177
+      })
178
+      tableData.value = res.data?.records || []
179
+      pagination.total = res.data?.total || 0
180
+    } catch {
181
+      // Mock 数据
182
+      const all = generateMockDevices()
183
+      allDevices.value = all
184
+      let filtered = all.filter(d => {
185
+        if (filterForm.keyword && !d.deviceCode.includes(filterForm.keyword) && !d.deviceName.includes(filterForm.keyword)) return false
186
+        if (filterForm.deviceType && d.deviceType !== filterForm.deviceType) return false
187
+        if (filterForm.status && d.status !== filterForm.status) return false
188
+        return true
189
+      })
190
+      pagination.total = filtered.length
191
+      const start = (pagination.page - 1) * pagination.size
192
+      tableData.value = filtered.slice(start, start + pagination.size)
193
+    }
194
+  } finally { loading.value = false }
195
+}
196
+
197
+async function fetchStats() {
198
+  try {
199
+    const res = await getDeviceStats()
200
+    stats.value = res.data || []
201
+  } catch {
202
+    // Mock stats
203
+    const all = generateMockDevices()
204
+    stats.value = [
205
+      { status: 'all', label: '全部设备', count: all.length, color: '#409eff' },
206
+      { status: 'online', label: '在线', count: all.filter(d => d.status === 'online').length, color: '#67c23a' },
207
+      { status: 'offline', label: '离线', count: all.filter(d => d.status === 'offline').length, color: '#909399' },
208
+      { status: 'warning', label: '告警', count: all.filter(d => d.status === 'warning').length, color: '#f56c6c' },
209
+      { status: 'disabled', label: '已禁用', count: all.filter(d => d.status === 'disabled').length, color: '#e6a23c' }
210
+    ]
211
+  }
212
+}
213
+
214
+function handleSearch() { pagination.page = 1; fetchData() }
215
+function handleReset() {
216
+  filterForm.keyword = ''; filterForm.deviceType = ''; filterForm.status = ''
217
+  handleSearch()
218
+}
219
+function filterByStatus(status: string) {
220
+  if (status === 'all') { filterForm.status = '' } else { filterForm.status = status }
221
+  handleSearch()
222
+}
223
+function handleSelectionChange(rows: Device[]) { selectedIds.value = rows.map(r => r.id) }
224
+function viewDetail(row: Device) { router.push({ path: `/iot/device/${row.id}` }) }
225
+
226
+async function handleEnable(row: Device) {
227
+  try {
228
+    await ElMessageBox.confirm(`确认启用设备 "${row.deviceName}" ?`, '启用确认')
229
+    try { await batchOperateDevices([row.id], 'enable') } catch { /* mock ok */ }
230
+    ElMessage.success('设备已启用'); fetchData()
231
+  } catch { /* cancel */ }
232
+}
233
+
234
+async function handleDisable(row: Device) {
235
+  try {
236
+    await ElMessageBox.confirm(`确认禁用设备 "${row.deviceName}" ?`, '禁用确认')
237
+    try { await batchOperateDevices([row.id], 'disable') } catch { /* mock ok */ }
238
+    ElMessage.success('设备已禁用'); fetchData()
239
+  } catch { /* cancel */ }
240
+}
241
+
242
+async function handleDelete(row: Device) {
243
+  try {
244
+    await ElMessageBox.confirm(`确认删除设备 "${row.deviceName}" ?此操作不可恢复!`, '删除确认', { type: 'warning' })
245
+    try { await batchOperateDevices([row.id], 'delete') } catch { /* mock ok */ }
246
+    ElMessage.success('设备已删除'); fetchData()
247
+  } catch { /* cancel */ }
248
+}
249
+
250
+async function handleBatchEnable() {
251
+  try {
252
+    await ElMessageBox.confirm(`确认批量启用 ${selectedIds.value.length} 台设备?`, '批量启用')
253
+    try { await batchOperateDevices(selectedIds.value, 'enable') } catch { /* mock ok */ }
254
+    ElMessage.success('批量启用成功'); fetchData()
255
+  } catch { /* cancel */ }
256
+}
257
+
258
+async function handleBatchDisable() {
259
+  try {
260
+    await ElMessageBox.confirm(`确认批量禁用 ${selectedIds.value.length} 台设备?`, '批量禁用')
261
+    try { await batchOperateDevices(selectedIds.value, 'disable') } catch { /* mock ok */ }
262
+    ElMessage.success('批量禁用成功'); fetchData()
263
+  } catch { /* cancel */ }
264
+}
265
+
266
+async function handleBatchDelete() {
267
+  try {
268
+    await ElMessageBox.confirm(`确认批量删除 ${selectedIds.value.length} 台设备?此操作不可恢复!`, '批量删除', { type: 'warning' })
269
+    try { await batchOperateDevices(selectedIds.value, 'delete') } catch { /* mock ok */ }
270
+    ElMessage.success('批量删除成功'); fetchData()
271
+  } catch { /* cancel */ }
272
+}
273
+
274
+onMounted(() => { fetchData(); fetchStats() })
275
+</script>
276
+
277
+<style scoped>
278
+.filter-card :deep(.el-form-item) { margin-bottom: 0; }
279
+.stat-card { cursor: pointer; text-align: center; }
280
+.stat-value { font-size: 28px; font-weight: bold; }
281
+.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
282
+</style>

+ 70
- 0
frontend/src/views/iot/DeviceMapPage.vue Просмотреть файл

@@ -0,0 +1,70 @@
1
+<template>
2
+  <div class="device-map-page">
3
+    <el-page-header @back="router.back()" style="margin-bottom: 16px">
4
+      <template #content>
5
+        <span style="font-size: 18px; font-weight: bold">设备分布地图</span>
6
+      </template>
7
+    </el-page-header>
8
+    <DeviceMapView :devices="devices" :show-filter="true" style="height: calc(100vh - 160px)" />
9
+  </div>
10
+</template>
11
+
12
+<script setup lang="ts">
13
+import { ref, onMounted } from 'vue'
14
+import { useRouter } from 'vue-router'
15
+import { getDeviceLocations, listDevices } from '@/api/deviceApi'
16
+import type { Device } from '@/api/deviceApi'
17
+import DeviceMapView from './DeviceMapView.vue'
18
+
19
+const router = useRouter()
20
+const devices = ref<Device[]>([])
21
+
22
+function generateMockDevices(): Device[] {
23
+  const types = ['water_level', 'flow_meter', 'water_quality', 'gate_controller', 'camera', 'weather_station']
24
+  const statuses = ['online', 'online', 'online', 'offline', 'warning', 'disabled']
25
+  const locations = ['长河坝站', '青衣江入口', '雨城电站', '周公河汇口', '龟都府电站', '水津关', '槽渔滩', '洪雅城区']
26
+  const deviceTypeMap: Record<string, string> = {
27
+    water_level: '水位计', flow_meter: '流量计', water_quality: '水质监测',
28
+    gate_controller: '闸门控制器', camera: '摄像头', weather_station: '气象站'
29
+  }
30
+  const devs: Device[] = []
31
+  for (let i = 1; i <= 68; i++) {
32
+    const status = statuses[i % statuses.length]
33
+    devs.push({
34
+      id: i,
35
+      deviceCode: `IOT-${String(i).padStart(4, '0')}`,
36
+      deviceName: `${deviceTypeMap[types[i % types.length]]} #${i}`,
37
+      deviceType: types[i % types.length],
38
+      status,
39
+      latitude: 29.5 + Math.random() * 0.5,
40
+      longitude: 103.0 + Math.random() * 0.8,
41
+      location: locations[i % locations.length],
42
+      firmwareVersion: `v${Math.floor(Math.random() * 3) + 1}.${Math.floor(Math.random() * 10)}.${Math.floor(Math.random() * 10)}`,
43
+      lastOnlineTime: status === 'online' ? new Date().toISOString().replace('T', ' ').slice(0, 19) : new Date(Date.now() - Math.random() * 86400000 * 7).toISOString().replace('T', ' ').slice(0, 19),
44
+      createdAt: new Date(Date.now() - Math.random() * 86400000 * 365).toISOString().replace('T', ' ').slice(0, 19),
45
+      updatedAt: new Date().toISOString().replace('T', ' ').slice(0, 19)
46
+    })
47
+  }
48
+  return devs
49
+}
50
+
51
+async function fetchDevices() {
52
+  try {
53
+    const res = await getDeviceLocations()
54
+    devices.value = res.data || []
55
+  } catch {
56
+    try {
57
+      const res = await listDevices({ page: 1, size: 500 })
58
+      devices.value = res.data?.records || []
59
+    } catch {
60
+      devices.value = generateMockDevices()
61
+    }
62
+  }
63
+}
64
+
65
+onMounted(() => { fetchDevices() })
66
+</script>
67
+
68
+<style scoped>
69
+.device-map-page { padding: 0; }
70
+</style>

+ 243
- 0
frontend/src/views/iot/DeviceMapView.vue Просмотреть файл

@@ -0,0 +1,243 @@
1
+<template>
2
+  <div class="device-map">
3
+    <!-- 筛选栏 -->
4
+    <el-card shadow="never" class="filter-bar" v-if="showFilter">
5
+      <el-form :inline="true" :model="filter">
6
+        <el-form-item label="设备类型">
7
+          <el-select v-model="filter.deviceType" placeholder="全部" clearable @change="updateMarkers">
8
+            <el-option label="水位计" value="water_level" />
9
+            <el-option label="流量计" value="flow_meter" />
10
+            <el-option label="水质监测" value="water_quality" />
11
+            <el-option label="闸门控制器" value="gate_controller" />
12
+            <el-option label="摄像头" value="camera" />
13
+            <el-option label="气象站" value="weather_station" />
14
+          </el-select>
15
+        </el-form-item>
16
+        <el-form-item label="在线状态">
17
+          <el-select v-model="filter.status" placeholder="全部" clearable @change="updateMarkers">
18
+            <el-option label="在线" value="online" />
19
+            <el-option label="离线" value="offline" />
20
+            <el-option label="告警" value="warning" />
21
+          </el-select>
22
+        </el-form-item>
23
+        <el-form-item>
24
+          <el-button type="primary" @click="updateMarkers">应用筛选</el-button>
25
+          <el-button @click="resetFilter">重置</el-button>
26
+        </el-form-item>
27
+      </el-form>
28
+    </el-card>
29
+
30
+    <!-- 地图容器 -->
31
+    <div class="map-wrapper">
32
+      <div ref="mapContainer" class="map-container" />
33
+
34
+      <!-- 图例 -->
35
+      <div class="map-legend">
36
+        <div class="legend-title">图例</div>
37
+        <div class="legend-item">
38
+          <span class="legend-dot" style="background: #67c23a"></span>
39
+          <span>在线</span>
40
+        </div>
41
+        <div class="legend-item">
42
+          <span class="legend-dot" style="background: #909399"></span>
43
+          <span>离线</span>
44
+        </div>
45
+        <div class="legend-item">
46
+          <span class="legend-dot" style="background: #f56c6c"></span>
47
+          <span>告警</span>
48
+        </div>
49
+        <div class="legend-item">
50
+          <span class="legend-dot" style="background: #e6a23c"></span>
51
+          <span>已禁用</span>
52
+        </div>
53
+      </div>
54
+
55
+      <!-- 统计信息 -->
56
+      <div class="map-stats">
57
+        <span>显示 <strong>{{ filteredDevices.length }}</strong> / {{ devices.length }} 台设备</span>
58
+      </div>
59
+    </div>
60
+  </div>
61
+</template>
62
+
63
+<script setup lang="ts">
64
+import { ref, reactive, onMounted, onUnmounted, watch } from 'vue'
65
+import L from 'leaflet'
66
+import 'leaflet/dist/leaflet.css'
67
+import type { Device } from '@/api/deviceApi'
68
+
69
+interface Props {
70
+  devices: Device[]
71
+  showFilter?: boolean
72
+}
73
+
74
+const props = withDefaults(defineProps<Props>(), { showFilter: true })
75
+const emit = defineEmits<{ (e: 'deviceClick', device: Device): void }>()
76
+
77
+const mapContainer = ref<HTMLElement | null>(null)
78
+let map: L.Map | null = null
79
+let markerCluster: L.LayerGroup | null = null
80
+
81
+const filter = reactive({ deviceType: '', status: '' })
82
+const filteredDevices = ref<Device[]>([])
83
+
84
+const deviceTypeMap: Record<string, string> = {
85
+  water_level: '水位计', flow_meter: '流量计', water_quality: '水质监测',
86
+  gate_controller: '闸门控制器', camera: '摄像头', weather_station: '气象站'
87
+}
88
+
89
+const statusColorMap: Record<string, string> = {
90
+  online: '#67c23a', offline: '#909399', warning: '#f56c6c', disabled: '#e6a23c'
91
+}
92
+
93
+const statusLabelMap: Record<string, string> = {
94
+  online: '在线', offline: '离线', warning: '告警', disabled: '已禁用'
95
+}
96
+
97
+function createMarkerIcon(color: string): L.DivIcon {
98
+  return L.divIcon({
99
+    className: 'custom-marker',
100
+    html: `<div style="
101
+      width: 24px; height: 24px; border-radius: 50%;
102
+      background: ${color}; border: 3px solid white;
103
+      box-shadow: 0 2px 6px rgba(0,0,0,0.3);
104
+      display: flex; align-items: center; justify-content: center;
105
+    ">
106
+      <div style="width: 8px; height: 8px; border-radius: 50%; background: white;"></div>
107
+    </div>`,
108
+    iconSize: [24, 24],
109
+    iconAnchor: [12, 12]
110
+  })
111
+}
112
+
113
+function initMap() {
114
+  if (!mapContainer.value) return
115
+
116
+  map = L.map(mapContainer.value, {
117
+    center: [29.75, 103.4],
118
+    zoom: 10,
119
+    zoomControl: true,
120
+    attributionControl: false
121
+  })
122
+
123
+  // 使用 OpenStreetMap 瓦片
124
+  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
125
+    maxZoom: 18
126
+  }).addTo(map)
127
+
128
+  markerCluster = L.layerGroup().addTo(map)
129
+
130
+  updateMarkers()
131
+}
132
+
133
+function updateMarkers() {
134
+  if (!map || !markerCluster) return
135
+
136
+  markerCluster.clearLayers()
137
+
138
+  // 应用筛选
139
+  filteredDevices.value = props.devices.filter(d => {
140
+    if (filter.deviceType && d.deviceType !== filter.deviceType) return false
141
+    if (filter.status && d.status !== filter.status) return false
142
+    return true
143
+  })
144
+
145
+  const bounds: L.LatLngExpression[] = []
146
+
147
+  filteredDevices.value.forEach(device => {
148
+    if (device.latitude && device.longitude) {
149
+      const color = statusColorMap[device.status] || '#909399'
150
+      const marker = L.marker([device.latitude, device.longitude], {
151
+        icon: createMarkerIcon(color)
152
+      })
153
+
154
+      // 弹窗
155
+      marker.bindPopup(`
156
+        <div style="min-width: 200px; font-family: sans-serif;">
157
+          <div style="font-size: 14px; font-weight: bold; margin-bottom: 8px; color: #303133;">
158
+            ${device.deviceName}
159
+          </div>
160
+          <div style="font-size: 12px; color: #606266; line-height: 1.8;">
161
+            <div><strong>编号:</strong> ${device.deviceCode}</div>
162
+            <div><strong>类型:</strong> ${deviceTypeMap[device.deviceType] || device.deviceType}</div>
163
+            <div><strong>状态:</strong>
164
+              <span style="color: ${color}; font-weight: bold;">${statusLabelMap[device.status] || device.status}</span>
165
+            </div>
166
+            <div><strong>位置:</strong> ${device.location || '未知'}</div>
167
+            <div><strong>固件:</strong> ${device.firmwareVersion || '-'}</div>
168
+            <div><strong>最后在线:</strong> ${device.lastOnlineTime || '-'}</div>
169
+          </div>
170
+          <div style="margin-top: 8px; text-align: center;">
171
+            <a href="/iot/device/${device.id}" style="
172
+              display: inline-block; padding: 4px 12px;
173
+              background: #409eff; color: white; border-radius: 4px;
174
+              text-decoration: none; font-size: 12px;
175
+            ">查看详情</a>
176
+          </div>
177
+        </div>
178
+      `, { maxWidth: 300 })
179
+
180
+      marker.on('click', () => emit('deviceClick', device))
181
+      markerCluster!.addLayer(marker)
182
+      bounds.push([device.latitude, device.longitude])
183
+    }
184
+  })
185
+
186
+  // 自适应视图
187
+  if (bounds.length > 0) {
188
+    map.fitBounds(bounds as L.LatLngBoundsExpression, { padding: [30, 30], maxZoom: 14 })
189
+  }
190
+}
191
+
192
+function resetFilter() {
193
+  filter.deviceType = ''
194
+  filter.status = ''
195
+  updateMarkers()
196
+}
197
+
198
+watch(() => props.devices, () => { updateMarkers() }, { deep: true })
199
+
200
+onMounted(() => {
201
+  // 确保 DOM 完全渲染
202
+  setTimeout(() => { initMap() }, 100)
203
+})
204
+
205
+onUnmounted(() => {
206
+  if (map) { map.remove(); map = null }
207
+})
208
+</script>
209
+
210
+<style scoped>
211
+.device-map { height: 100%; display: flex; flex-direction: column; }
212
+.filter-bar { margin-bottom: 12px; }
213
+.filter-bar :deep(.el-form-item) { margin-bottom: 0; }
214
+.map-wrapper { flex: 1; position: relative; border-radius: 8px; overflow: hidden; }
215
+.map-container { width: 100%; height: 100%; min-height: 500px; }
216
+
217
+.map-legend {
218
+  position: absolute; bottom: 20px; left: 20px;
219
+  background: white; padding: 12px 16px; border-radius: 8px;
220
+  box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 1000;
221
+  font-size: 13px;
222
+}
223
+.legend-title { font-weight: bold; margin-bottom: 8px; color: #303133; }
224
+.legend-item { display: flex; align-items: center; margin-bottom: 4px; }
225
+.legend-dot {
226
+  width: 12px; height: 12px; border-radius: 50%;
227
+  margin-right: 8px; border: 2px solid white;
228
+  box-shadow: 0 1px 3px rgba(0,0,0,0.2);
229
+}
230
+
231
+.map-stats {
232
+  position: absolute; top: 10px; right: 10px;
233
+  background: rgba(255,255,255,0.9); padding: 6px 12px;
234
+  border-radius: 4px; font-size: 13px; color: #606266;
235
+  z-index: 1000;
236
+}
237
+</style>
238
+
239
+<style>
240
+/* 全局样式覆盖(Leaflet 弹窗) */
241
+.leaflet-popup-content-wrapper { border-radius: 8px; }
242
+.leaflet-popup-content { margin: 12px; }
243
+</style>

+ 35
- 0
wm-iot/src/main/java/com/water/iot/controller/DeviceController.java Просмотреть файл

@@ -49,4 +49,39 @@ public class DeviceController {
49 49
         // TODO: 实际指令通过 Kafka -> EMQX -> MQTT -> 设备
50 50
         return R.ok("指令已下发");
51 51
     }
52
+
53
+    @Operation(summary = "设备统计")
54
+    @GetMapping("/stats")
55
+    public R<List<Map<String, Object>>> stats() {
56
+        String sql = "SELECT status, COUNT(*) as count FROM iot_device GROUP BY status";
57
+        return R.ok(jdbcTemplate.queryForList(sql));
58
+    }
59
+
60
+    @Operation(summary = "设备地理位置")
61
+    @GetMapping("/locations")
62
+    public R<List<Map<String, Object>>> locations(@RequestParam(required = false) String deviceType,
63
+                                                    @RequestParam(required = false) String status) {
64
+        StringBuilder sql = new StringBuilder("SELECT id, device_sn as deviceCode, device_name as deviceName, device_type as deviceType, status, loc_lat as latitude, loc_lng as longitude, area as location, last_report_time as lastOnlineTime FROM iot_device WHERE loc_lat IS NOT NULL AND loc_lng IS NOT NULL");
65
+        if (deviceType != null && !deviceType.isEmpty()) sql.append(" AND device_type = '").append(deviceType).append("'");
66
+        if (status != null && !status.isEmpty()) sql.append(" AND status = '").append(status).append("'");
67
+        return R.ok(jdbcTemplate.queryForList(sql.toString()));
68
+    }
69
+
70
+    @Operation(summary = "批量操作")
71
+    @PostMapping("/batch")
72
+    public R<String> batch(@RequestBody Map<String, Object> body) {
73
+        List<Number> ids = (List<Number>) body.get("ids");
74
+        String action = (String) body.get("action");
75
+        if (ids == null || ids.isEmpty()) return R.ok("无设备ID");
76
+        String sql;
77
+        if ("enable".equals(action)) {
78
+            sql = "UPDATE iot_device SET status = 'online' WHERE id IN (" + ids.stream().map(String::valueOf).reduce((a, b) -> a + "," + b).orElse("") + ")";
79
+        } else if ("disable".equals(action)) {
80
+            sql = "UPDATE iot_device SET status = 'disabled' WHERE id IN (" + ids.stream().map(String::valueOf).reduce((a, b) -> a + "," + b).orElse("") + ")";
81
+        } else {
82
+            sql = "DELETE FROM iot_device WHERE id IN (" + ids.stream().map(String::valueOf).reduce((a, b) -> a + "," + b).orElse("") + ")";
83
+        }
84
+        jdbcTemplate.update(sql);
85
+        return R.ok("操作成功");
86
+    }
52 87
 }