remoteControl.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. <!-- 终端设备远程测控(三级报警控远程手动控制) -->
  2. <template>
  3. <div class="remote-ctl-page">
  4. <!-- 查询条件:仅设备编码、设备名称 -->
  5. <el-card shadow="never" class="alarm-filter-card">
  6. <el-form :model="query" inline class="alarm-filters" @submit.prevent="handleSearch">
  7. <el-form-item label="设备编码">
  8. <el-input v-model="query.equipmentCode" placeholder="请输入设备编码" clearable style="width: 220px" @keyup.enter="handleSearch" />
  9. </el-form-item>
  10. <el-form-item label="设备名称">
  11. <el-input v-model="query.equipmentName" placeholder="请输入设备名称" clearable style="width: 220px" @keyup.enter="handleSearch" />
  12. </el-form-item>
  13. <el-form-item>
  14. <el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
  15. <el-button :icon="Refresh" @click="handleReset">重置</el-button>
  16. </el-form-item>
  17. </el-form>
  18. </el-card>
  19. <!-- 设备列表 -->
  20. <section class="alarm-table-panel">
  21. <div class="alarm-toolbar">
  22. <h3>终端设备列表</h3>
  23. <span class="toolbar-right">
  24. <span class="muted">共 {{ total }} 条</span>
  25. <el-button type="primary" plain size="small" :loading="loading" @click="loadList">刷新状态</el-button>
  26. </span>
  27. </div>
  28. <el-table v-loading="loading" :data="list" border stripe size="small" :header-cell-style="{ background: '#f5f7fa', color: '#606266' }">
  29. <el-table-column prop="equipmentCode" label="设备编号" min-width="160" show-overflow-tooltip />
  30. <el-table-column prop="equipmentName" label="设备名称" min-width="160" show-overflow-tooltip />
  31. <el-table-column prop="equipmentTypeName" label="设备类型" min-width="150" show-overflow-tooltip />
  32. <el-table-column label="片区" min-width="90">
  33. <template #default="{ row }">{{ districtName(row.district) }}</template>
  34. </el-table-column>
  35. <el-table-column label="设备状态" min-width="150">
  36. <template #default="{ row }">
  37. <span class="tag" :class="isOnline(row) ? 'tag-green' : 'tag-gray'"><i class="dot"></i>{{ isOnline(row) ? '在线' : '离线' }}</span>
  38. <span v-if="isAlarm(row)" class="tag tag-red"><i class="dot"></i>报警</span>
  39. </template>
  40. </el-table-column>
  41. <el-table-column label="实时数据" min-width="220" show-overflow-tooltip>
  42. <template #default="{ row }">
  43. <span :class="{ 'cell-alarm': hasAlarmInLatest(row) }">{{ latestMetricsText(row) }}</span>
  44. </template>
  45. </el-table-column>
  46. <el-table-column label="最后通信" min-width="170">
  47. <template #default="{ row }">{{ profileMap[row.equipmentId]?.lastCommTime || '—' }}</template>
  48. </el-table-column>
  49. <el-table-column label="操作" width="100" fixed="right">
  50. <template #default="{ row }">
  51. <el-button type="primary" link size="small" @click="openControl(row)">远程控制</el-button>
  52. </template>
  53. </el-table-column>
  54. <template #empty><el-empty description="没有符合条件的设备" /></template>
  55. </el-table>
  56. <pagination v-show="total > 0" v-model:page="query.pageNum" v-model:limit="query.pageSize" :total="total" @pagination="loadList" />
  57. </section>
  58. <!-- ============ 远程控制抽屉 ============ -->
  59. <el-drawer
  60. v-model="drawerOpen"
  61. :title="drawerTitle"
  62. size="720px"
  63. append-to-body
  64. direction="rtl"
  65. @opened="startDrawerTimer"
  66. @closed="onDrawerClosed"
  67. >
  68. <div v-loading="profileLoading">
  69. <template v-if="currentProfile.base">
  70. <!-- 基础信息 -->
  71. <div class="ctl-dev">
  72. <div class="ctl-dev-grid">
  73. <div>设备编号:<b>{{ currentProfile.base.equipmentCode }}</b></div>
  74. <div>设备类型:<b>{{ currentProfile.base.equipmentTypeName || '—' }}</b></div>
  75. <div>管辖片区:<b>{{ districtName(currentProfile.base.district) }}</b></div>
  76. <div>安装位置:<b>{{ currentProfile.base.equipmentLocation || '—' }}</b></div>
  77. <div class="full">最后通信:<b>{{ currentProfile.lastCommTime || '—' }}</b></div>
  78. </div>
  79. </div>
  80. <!-- 下发指令 -->
  81. <div class="form-item">
  82. <span>上报间隔</span>
  83. <div class="row gap10">
  84. <el-input-number v-model="reportInterval" :min="5" :max="3600" :step="1" controls-position="right" style="width: 160px" />
  85. <span class="unit">秒</span>
  86. </div>
  87. <div class="quick-steps">
  88. <button
  89. v-for="s in REPORT_STEPS"
  90. :key="s"
  91. type="button"
  92. class="chip"
  93. :class="{ active: reportInterval === s }"
  94. @click="reportInterval = s"
  95. >{{ s }}s</button>
  96. </div>
  97. <div class="form-tip">
  98. 可设置范围 5 ~ 3600 秒,步进 1 秒;当前上报间隔 {{ currentReportInterval }} 秒。上报间隔越短数据越及时,通信流量与设备功耗越高。
  99. </div>
  100. </div>
  101. <div class="drawer-actions">
  102. <el-button type="primary" @click="sendCommand">下发指令</el-button>
  103. <el-button @click="drawerOpen = false">取消</el-button>
  104. </div>
  105. <!-- 指令下发记录(本会话留痕,纯前端) -->
  106. <div v-if="cmdLogs.length" class="form-item">
  107. <span>指令下发记录</span>
  108. <el-table :data="cmdLogs" size="small" border>
  109. <el-table-column prop="time" label="时间" width="170" />
  110. <el-table-column prop="deviceName" label="设备" min-width="120" show-overflow-tooltip />
  111. <el-table-column prop="param" label="参数" min-width="120" />
  112. <el-table-column label="结果" width="90">
  113. <template #default="{ row }">
  114. <span class="tag" :class="row.result === '成功' ? 'tag-green' : 'tag-red'"><i class="dot"></i>{{ row.result }}</span>
  115. </template>
  116. </el-table-column>
  117. </el-table>
  118. </div>
  119. <!-- 采集记录(最近 50 条,与 realTimeShow 一致) -->
  120. <div class="form-item">
  121. <span>采集记录(最近 50 条)</span>
  122. <div class="hist-wrap">
  123. <el-table :data="pagedRecordRows" max-height="420" size="small">
  124. <el-table-column
  125. v-for="c in recordViewColumns"
  126. :key="c.key"
  127. :label="c.label + (c.unit ? '(' + c.unit + ')' : '')"
  128. :min-width="c.key === 'create_time' ? 168 : (c.key === 'filepath' ? 170 : 104)"
  129. >
  130. <template #default="{ row, $index }">
  131. <span v-if="c.key === 'create_time'">{{ row.createTime || '—' }}</span>
  132. <span v-else-if="c.key === 'filepath'" class="rec-audio-cell">
  133. <audio
  134. v-if="valueOf(row, c.key) && valueOf(row, c.key) !== '—'"
  135. class="rec-audio"
  136. controls
  137. preload="none"
  138. :src="buildMinioAttachmentUrl(valueOf(row, c.key))"
  139. @play="pauseOtherAudios"
  140. ></audio>
  141. <span v-else>—</span>
  142. </span>
  143. <span v-else :class="{ 'cell-alarm': alarmOf(row, c.key) }">{{ valueOf(row, c.key) }}</span>
  144. </template>
  145. </el-table-column>
  146. <template #empty><div class="rec-empty">暂无采集记录</div></template>
  147. </el-table>
  148. <el-pagination
  149. v-if="recordRows.length"
  150. class="rec-pager"
  151. layout="prev, pager, next"
  152. :total="recordRows.length"
  153. :page-size="recordPageSize"
  154. small
  155. v-model:current-page="recordPage"
  156. />
  157. </div>
  158. </div>
  159. </template>
  160. <el-empty v-else description="未找到设备档案" />
  161. </div>
  162. </el-drawer>
  163. </div>
  164. </template>
  165. <script setup>
  166. import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
  167. import { Search, Refresh } from '@element-plus/icons-vue'
  168. import { ElMessage, ElMessageBox } from 'element-plus'
  169. import { pagePerceptionEquipment, getEquipmentProfile } from '@/api/pipeNetwork/perception'
  170. import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
  171. const loading = ref(false)
  172. const list = ref([])
  173. const total = ref(0)
  174. const query = reactive({
  175. pageNum: 1,
  176. pageSize: 10,
  177. equipmentCode: '',
  178. equipmentName: ''
  179. })
  180. // 设备档案缓存(key=equipmentId),列表实时数据列与抽屉共用,复用 getEquipmentProfile
  181. const profileMap = reactive({})
  182. const drawerOpen = ref(false)
  183. const drawerTitle = ref('')
  184. const profileLoading = ref(false)
  185. const currentDevice = ref(null)
  186. const currentProfile = ref({ base: null, lastCommTime: '', recordView: { columns: [], rows: [] } })
  187. let drawerTimer = null
  188. // 下发指令(假操作)相关
  189. const REPORT_STEPS = [5, 10, 30, 60, 300, 600, 1800, 3600]
  190. const reportInterval = ref(30)
  191. const currentReportInterval = ref(30)
  192. const cmdLogs = ref([])
  193. // 采集记录分页(每页 10 条)
  194. const recordPage = ref(1)
  195. const recordPageSize = 10
  196. const districtOptions = [
  197. { value: 0, label: '城南' },
  198. { value: 1, label: '城北' },
  199. { value: 2, label: '太常' }
  200. ]
  201. function districtName(district) {
  202. const found = districtOptions.find((d) => d.value === district)
  203. return found ? found.label : '—'
  204. }
  205. function isOnline(d) { return d.onlineStatus === 1 }
  206. function isAlarm(d) { return d.alarmStatus === 1 }
  207. // ============ 实时数据列(采集记录最新一条的指标项) ============
  208. function profileOf(row) {
  209. return profileMap[row.equipmentId] || null
  210. }
  211. function latestRowOf(row) {
  212. const p = profileOf(row)
  213. const rows = p?.recordView?.rows || []
  214. return rows.length ? rows[0] : null
  215. }
  216. function columnLabelMap(p) {
  217. const map = {}
  218. ;(p?.recordView?.columns || []).forEach((c) => { map[c.key] = c })
  219. return map
  220. }
  221. function latestMetricsText(row) {
  222. if (!isOnline(row)) return '—' // 离线设备不展示实时数据
  223. const p = profileOf(row)
  224. const first = latestRowOf(row)
  225. if (!first) return '—'
  226. const colMap = columnLabelMap(p)
  227. return (first.cells || [])
  228. .filter((c) => c.key !== 'filepath')
  229. .map((c) => {
  230. const label = colMap[c.key]?.label || c.key
  231. const v = c.value && c.value !== '' ? c.value : (c.display || '—')
  232. return `${label}:${v}`
  233. })
  234. .join(' ')
  235. }
  236. function hasAlarmInLatest(row) {
  237. const first = latestRowOf(row)
  238. return (first?.cells || []).some((c) => c.alarm)
  239. }
  240. // ============ 采集记录渲染(与 realTimeShow 一致) ============
  241. const recordView = computed(() => currentProfile.value.recordView || { columns: [], rows: [] })
  242. const recordViewColumns = computed(() => recordView.value.columns || [])
  243. const recordRows = computed(() => recordView.value.rows || [])
  244. const pagedRecordRows = computed(() => {
  245. const start = (recordPage.value - 1) * recordPageSize
  246. return recordRows.value.slice(start, start + recordPageSize)
  247. })
  248. function valueOf(row, key) {
  249. const cells = row.cells || []
  250. const c = cells.find((x) => x.key === key)
  251. if (!c) return '—'
  252. if (recordView.value.layout === 'long') return c.display
  253. return c.value && c.value !== '' ? c.value : c.display
  254. }
  255. function alarmOf(row, key) {
  256. const cells = row.cells || []
  257. const c = cells.find((x) => x.key === key)
  258. return c ? !!c.alarm : false
  259. }
  260. // 采集记录音频:行内原生播放器,一次只播一条
  261. function pauseOtherAudios(e) {
  262. document.querySelectorAll('audio.rec-audio').forEach((el) => {
  263. if (el !== e.target) el.pause()
  264. })
  265. }
  266. // ============ 数据加载 ============
  267. function loadList() {
  268. loading.value = true
  269. return pagePerceptionEquipment({
  270. pageNum: query.pageNum,
  271. pageSize: query.pageSize,
  272. equipmentCode: query.equipmentCode || undefined,
  273. equipmentName: query.equipmentName || undefined,
  274. rootTypeId: undefined
  275. })
  276. .then((res) => {
  277. const page = res.data || {}
  278. list.value = page.records || []
  279. total.value = page.total || 0
  280. // 批量拉取档案,填充列表实时数据列;复用 getEquipmentProfile,不新增接口
  281. return Promise.all(
  282. list.value.map((d) =>
  283. getEquipmentProfile(d.equipmentId)
  284. .then((p) => { profileMap[d.equipmentId] = p.data || null })
  285. .catch(() => { profileMap[d.equipmentId] = null })
  286. )
  287. )
  288. })
  289. .finally(() => { loading.value = false })
  290. }
  291. function handleSearch() {
  292. query.pageNum = 1
  293. loadList()
  294. }
  295. function handleReset() {
  296. query.equipmentCode = ''
  297. query.equipmentName = ''
  298. query.pageNum = 1
  299. loadList()
  300. }
  301. // ============ 远程控制抽屉 ============
  302. function openControl(row) {
  303. currentDevice.value = row
  304. drawerTitle.value = '远程手动控制 · ' + row.equipmentName
  305. drawerOpen.value = true
  306. recordPage.value = 1
  307. reportInterval.value = currentReportInterval.value = 30
  308. profileLoading.value = true
  309. const cached = profileMap[row.equipmentId]
  310. if (cached) {
  311. currentProfile.value = cached
  312. profileLoading.value = false
  313. } else {
  314. getEquipmentProfile(row.equipmentId)
  315. .then((res) => { currentProfile.value = res.data || { base: null, lastCommTime: '', recordView: { columns: [], rows: [] } } })
  316. .finally(() => { profileLoading.value = false })
  317. }
  318. }
  319. function startDrawerTimer() {
  320. stopDrawerTimer()
  321. drawerTimer = setInterval(() => {
  322. if (currentDevice.value) {
  323. getEquipmentProfile(currentDevice.value.equipmentId)
  324. .then((res) => {
  325. const p = res.data || null
  326. if (p) { profileMap[currentDevice.value.equipmentId] = p; currentProfile.value = p }
  327. })
  328. }
  329. }, 3000)
  330. }
  331. function stopDrawerTimer() {
  332. if (drawerTimer) clearInterval(drawerTimer)
  333. drawerTimer = null
  334. }
  335. function onDrawerClosed() {
  336. stopDrawerTimer()
  337. document.querySelectorAll('audio.rec-audio').forEach((el) => el.pause())
  338. }
  339. // 下发指令:纯前端模拟,不调用后端接口
  340. function sendCommand() {
  341. const num = Number(reportInterval.value)
  342. if (!num || num < 5 || num > 3600) {
  343. ElMessage.error('上报间隔须为 5 ~ 3600 秒之间的整数')
  344. return
  345. }
  346. const d = currentDevice.value
  347. const cur = currentReportInterval.value
  348. if (d.onlineStatus !== 1) {
  349. ElMessage.error(`指令下发失败:${d.equipmentName} 处于离线状态,指令将被拦截`)
  350. cmdLogs.value.unshift({ time: nowText(), deviceName: d.equipmentName, param: `上报间隔 ${num} 秒`, result: '失败' })
  351. return
  352. }
  353. ElMessageBox.confirm(
  354. `将向设备 <b>${d.equipmentName}</b>(${d.equipmentCode})下发指令:上报间隔 ${cur} 秒 → <b>${num} 秒</b>`,
  355. '确认下发控制指令',
  356. { confirmButtonText: '确认下发', cancelButtonText: '取消', type: 'warning', dangerouslyUseHTMLString: true }
  357. ).then(() => {
  358. const btnText = '下发中...'
  359. ElMessage.info(btnText)
  360. setTimeout(() => {
  361. currentReportInterval.value = num
  362. if (profileMap[d.equipmentId]) profileMap[d.equipmentId].currentReportInterval = num
  363. cmdLogs.value.unshift({ time: nowText(), deviceName: d.equipmentName, param: `上报间隔 ${num} 秒`, result: '成功' })
  364. ElMessage.success(`上报间隔已更新为 ${num} 秒:${d.equipmentName}`)
  365. }, 600)
  366. }).catch(() => {})
  367. }
  368. function nowText() {
  369. const dt = new Date()
  370. const p = (n) => String(n).padStart(2, '0')
  371. return `${dt.getFullYear()}-${p(dt.getMonth() + 1)}-${p(dt.getDate())} ${p(dt.getHours())}:${p(dt.getMinutes())}:${p(dt.getSeconds())}`
  372. }
  373. onMounted(() => { loadList() })
  374. onBeforeUnmount(() => { stopDrawerTimer() })
  375. </script>
  376. <style scoped lang="scss">
  377. .remote-ctl-page {
  378. padding: 16px;
  379. background: #f0f3f8;
  380. min-height: calc(100vh - 84px);
  381. }
  382. .title-sub { font-size: 12px; color: #97a3b7; font-weight: 400; }
  383. .alarm-filter-card { margin-bottom: 16px; }
  384. .alarm-filters { display: flex; flex-wrap: wrap; align-items: center; }
  385. /* 单行查询表单:清掉 el-form-item 默认的 18px 底部间距,保证卡片上下留白一致 */
  386. .alarm-filter-card :deep(.el-form-item) { margin-bottom: 0; }
  387. .alarm-table-panel { padding: 15px 20px 20px; margin-bottom: 20px; background: #fff; border: 1px solid var(--el-card-border-color); border-radius: 8px; }
  388. .alarm-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
  389. .alarm-toolbar h3 { font-size: 15px; font-weight: 600; margin: 0; display: flex; align-items: center; gap: 8px; }
  390. .alarm-toolbar h3:before { content: ""; width: 3px; height: 15px; border-radius: 2px; background: #1677ff; }
  391. .toolbar-right { display: flex; align-items: center; gap: 12px; }
  392. .muted { color: #909399; font-size: 13px; }
  393. /* 状态标签(与 realTimeShow 一致) */
  394. .tag { display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px; border-radius: 20px; font-size: 12px; font-weight: 500; }
  395. .tag-green { background: #dcfce7; color: #16a34a; }
  396. .tag-gray { background: #f1f5f9; color: #64748b; }
  397. .tag-red { background: #fee2e2; color: #ef4444; }
  398. .tag .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; display: inline-block; }
  399. .cell-alarm { color: #ef4444; font-weight: 600; }
  400. /* 抽屉内基础信息 / 表单项(复用 realTimeShow 样式) */
  401. .ctl-dev { background: #f8fafc; border: 1px solid #e3e9f2; border-radius: 8px; padding: 14px; margin-bottom: 16px; }
  402. .ctl-dev-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 14px; font-size: 12px; color: #5e6d82; }
  403. .ctl-dev-grid .full { grid-column: 1 / -1; }
  404. .ctl-dev-grid b { color: #0a1628; font-weight: 500; }
  405. .form-item { display: block; margin-bottom: 14px; }
  406. .form-item > span { display: block; font-size: 13px; color: #5e6d82; margin-bottom: 6px; font-weight: 500; }
  407. .row { display: flex; align-items: center; }
  408. .row.gap10 { gap: 10px; }
  409. .unit { font-size: 13px; color: #5e6d82; }
  410. .quick-steps { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
  411. .chip { border: 1px solid #d6e6ff; background: #eef5ff; color: #1677ff; border-radius: 16px; padding: 4px 12px; font-size: 12px; cursor: pointer; transition: 0.15s; }
  412. .chip:hover { background: #d6e6ff; }
  413. .chip.active { background: #1677ff; color: #fff; border-color: #1677ff; }
  414. .form-tip { margin-top: 8px; font-size: 12px; color: #97a3b7; line-height: 1.6; }
  415. .drawer-actions { display: flex; gap: 10px; margin: 16px 0; }
  416. .drawer-actions :deep(.el-button) { flex: 1; height: 36px; }
  417. /* 采集记录表格(与 realTimeShow 一致) */
  418. .hist-wrap { overflow: auto; border-radius: 8px; background: #fff; }
  419. .hist-wrap :deep(.el-table) { font-size: 12px; border: 1px solid #e3e9f2; border-radius: 8px; }
  420. .hist-wrap :deep(.el-table th.el-table__cell) { background: #f7f9fc; color: #5e6d82; font-weight: 600; }
  421. .hist-wrap :deep(.el-table td.el-table__cell) { padding: 6px 0; border-bottom: 1px solid #eef2f7; }
  422. .hist-wrap :deep(.el-table .cell) { padding: 0 10px; line-height: 1.5; }
  423. .hist-wrap :deep(.el-table td.el-table__cell .cell) { white-space: nowrap; }
  424. .hist-wrap :deep(.el-table__header th.el-table__cell) { border-bottom: 1px solid #e3e9f2; }
  425. .hist-wrap :deep(.el-table::before),
  426. .hist-wrap :deep(.el-table::after),
  427. .hist-wrap :deep(.el-table__inner-wrapper::before),
  428. .hist-wrap :deep(.el-table__inner-wrapper::after) { display: none; }
  429. .hist-wrap :deep(.el-table .el-table__row:last-child td.el-table__cell) { border-bottom: none; }
  430. .rec-empty { color: #9aa7b8; font-size: 12px; padding: 28px 0; }
  431. .rec-audio-cell { display: flex; align-items: center; overflow: hidden; }
  432. .rec-audio { width: 150px; max-width: 100%; height: 26px; vertical-align: middle; }
  433. .rec-pager { margin-top: 10px; justify-content: flex-end; display: flex; }
  434. :deep(.el-drawer__header) { margin-bottom: 0; padding: 10px 20px; border-bottom: 1px solid #e3e9f2; color: #0a1628; }
  435. :deep(.el-drawer__body) { padding: 10px 20px 20px; }
  436. </style>