| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468 |
- <!-- 终端设备远程测控(三级报警控远程手动控制) -->
- <template>
- <div class="remote-ctl-page">
- <!-- 查询条件:仅设备编码、设备名称 -->
- <el-card shadow="never" class="alarm-filter-card">
- <el-form :model="query" inline class="alarm-filters" @submit.prevent="handleSearch">
- <el-form-item label="设备编码">
- <el-input v-model="query.equipmentCode" placeholder="请输入设备编码" clearable style="width: 220px" @keyup.enter="handleSearch" />
- </el-form-item>
- <el-form-item label="设备名称">
- <el-input v-model="query.equipmentName" placeholder="请输入设备名称" clearable style="width: 220px" @keyup.enter="handleSearch" />
- </el-form-item>
- <el-form-item>
- <el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
- <el-button :icon="Refresh" @click="handleReset">重置</el-button>
- </el-form-item>
- </el-form>
- </el-card>
- <!-- 设备列表 -->
- <section class="alarm-table-panel">
- <div class="alarm-toolbar">
- <h3>终端设备列表</h3>
- <span class="toolbar-right">
- <span class="muted">共 {{ total }} 条</span>
- <el-button type="primary" plain size="small" :loading="loading" @click="loadList">刷新状态</el-button>
- </span>
- </div>
- <el-table v-loading="loading" :data="list" border stripe size="small" :header-cell-style="{ background: '#f5f7fa', color: '#606266' }">
- <el-table-column prop="equipmentCode" label="设备编号" min-width="160" show-overflow-tooltip />
- <el-table-column prop="equipmentName" label="设备名称" min-width="160" show-overflow-tooltip />
- <el-table-column prop="equipmentTypeName" label="设备类型" min-width="150" show-overflow-tooltip />
- <el-table-column label="片区" min-width="90">
- <template #default="{ row }">{{ districtName(row.district) }}</template>
- </el-table-column>
- <el-table-column label="设备状态" min-width="150">
- <template #default="{ row }">
- <span class="tag" :class="isOnline(row) ? 'tag-green' : 'tag-gray'"><i class="dot"></i>{{ isOnline(row) ? '在线' : '离线' }}</span>
- <span v-if="isAlarm(row)" class="tag tag-red"><i class="dot"></i>报警</span>
- </template>
- </el-table-column>
- <el-table-column label="实时数据" min-width="220" show-overflow-tooltip>
- <template #default="{ row }">
- <span :class="{ 'cell-alarm': hasAlarmInLatest(row) }">{{ latestMetricsText(row) }}</span>
- </template>
- </el-table-column>
- <el-table-column label="最后通信" min-width="170">
- <template #default="{ row }">{{ profileMap[row.equipmentId]?.lastCommTime || '—' }}</template>
- </el-table-column>
- <el-table-column label="操作" width="100" fixed="right">
- <template #default="{ row }">
- <el-button type="primary" link size="small" @click="openControl(row)">远程控制</el-button>
- </template>
- </el-table-column>
- <template #empty><el-empty description="没有符合条件的设备" /></template>
- </el-table>
- <pagination v-show="total > 0" v-model:page="query.pageNum" v-model:limit="query.pageSize" :total="total" @pagination="loadList" />
- </section>
- <!-- ============ 远程控制抽屉 ============ -->
- <el-drawer
- v-model="drawerOpen"
- :title="drawerTitle"
- size="720px"
- append-to-body
- direction="rtl"
- @opened="startDrawerTimer"
- @closed="onDrawerClosed"
- >
- <div v-loading="profileLoading">
- <template v-if="currentProfile.base">
- <!-- 基础信息 -->
- <div class="ctl-dev">
- <div class="ctl-dev-grid">
- <div>设备编号:<b>{{ currentProfile.base.equipmentCode }}</b></div>
- <div>设备类型:<b>{{ currentProfile.base.equipmentTypeName || '—' }}</b></div>
- <div>管辖片区:<b>{{ districtName(currentProfile.base.district) }}</b></div>
- <div>安装位置:<b>{{ currentProfile.base.equipmentLocation || '—' }}</b></div>
- <div class="full">最后通信:<b>{{ currentProfile.lastCommTime || '—' }}</b></div>
- </div>
- </div>
- <!-- 下发指令 -->
- <div class="form-item">
- <span>上报间隔</span>
- <div class="row gap10">
- <el-input-number v-model="reportInterval" :min="5" :max="3600" :step="1" controls-position="right" style="width: 160px" />
- <span class="unit">秒</span>
- </div>
- <div class="quick-steps">
- <button
- v-for="s in REPORT_STEPS"
- :key="s"
- type="button"
- class="chip"
- :class="{ active: reportInterval === s }"
- @click="reportInterval = s"
- >{{ s }}s</button>
- </div>
- <div class="form-tip">
- 可设置范围 5 ~ 3600 秒,步进 1 秒;当前上报间隔 {{ currentReportInterval }} 秒。上报间隔越短数据越及时,通信流量与设备功耗越高。
- </div>
- </div>
- <div class="drawer-actions">
- <el-button type="primary" @click="sendCommand">下发指令</el-button>
- <el-button @click="drawerOpen = false">取消</el-button>
- </div>
- <!-- 指令下发记录(本会话留痕,纯前端) -->
- <div v-if="cmdLogs.length" class="form-item">
- <span>指令下发记录</span>
- <el-table :data="cmdLogs" size="small" border>
- <el-table-column prop="time" label="时间" width="170" />
- <el-table-column prop="deviceName" label="设备" min-width="120" show-overflow-tooltip />
- <el-table-column prop="param" label="参数" min-width="120" />
- <el-table-column label="结果" width="90">
- <template #default="{ row }">
- <span class="tag" :class="row.result === '成功' ? 'tag-green' : 'tag-red'"><i class="dot"></i>{{ row.result }}</span>
- </template>
- </el-table-column>
- </el-table>
- </div>
- <!-- 采集记录(最近 50 条,与 realTimeShow 一致) -->
- <div class="form-item">
- <span>采集记录(最近 50 条)</span>
- <div class="hist-wrap">
- <el-table :data="pagedRecordRows" max-height="420" size="small">
- <el-table-column
- v-for="c in recordViewColumns"
- :key="c.key"
- :label="c.label + (c.unit ? '(' + c.unit + ')' : '')"
- :min-width="c.key === 'create_time' ? 168 : (c.key === 'filepath' ? 170 : 104)"
- >
- <template #default="{ row, $index }">
- <span v-if="c.key === 'create_time'">{{ row.createTime || '—' }}</span>
- <span v-else-if="c.key === 'filepath'" class="rec-audio-cell">
- <audio
- v-if="valueOf(row, c.key) && valueOf(row, c.key) !== '—'"
- class="rec-audio"
- controls
- preload="none"
- :src="buildMinioAttachmentUrl(valueOf(row, c.key))"
- @play="pauseOtherAudios"
- ></audio>
- <span v-else>—</span>
- </span>
- <span v-else :class="{ 'cell-alarm': alarmOf(row, c.key) }">{{ valueOf(row, c.key) }}</span>
- </template>
- </el-table-column>
- <template #empty><div class="rec-empty">暂无采集记录</div></template>
- </el-table>
- <el-pagination
- v-if="recordRows.length"
- class="rec-pager"
- layout="prev, pager, next"
- :total="recordRows.length"
- :page-size="recordPageSize"
- small
- v-model:current-page="recordPage"
- />
- </div>
- </div>
- </template>
- <el-empty v-else description="未找到设备档案" />
- </div>
- </el-drawer>
- </div>
- </template>
- <script setup>
- import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
- import { Search, Refresh } from '@element-plus/icons-vue'
- import { ElMessage, ElMessageBox } from 'element-plus'
- import { pagePerceptionEquipment, getEquipmentProfile } from '@/api/pipeNetwork/perception'
- import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
- const loading = ref(false)
- const list = ref([])
- const total = ref(0)
- const query = reactive({
- pageNum: 1,
- pageSize: 10,
- equipmentCode: '',
- equipmentName: ''
- })
- // 设备档案缓存(key=equipmentId),列表实时数据列与抽屉共用,复用 getEquipmentProfile
- const profileMap = reactive({})
- const drawerOpen = ref(false)
- const drawerTitle = ref('')
- const profileLoading = ref(false)
- const currentDevice = ref(null)
- const currentProfile = ref({ base: null, lastCommTime: '', recordView: { columns: [], rows: [] } })
- let drawerTimer = null
- // 下发指令(假操作)相关
- const REPORT_STEPS = [5, 10, 30, 60, 300, 600, 1800, 3600]
- const reportInterval = ref(30)
- const currentReportInterval = ref(30)
- const cmdLogs = ref([])
- // 采集记录分页(每页 10 条)
- const recordPage = ref(1)
- const recordPageSize = 10
- const districtOptions = [
- { value: 0, label: '城南' },
- { value: 1, label: '城北' },
- { value: 2, label: '太常' }
- ]
- function districtName(district) {
- const found = districtOptions.find((d) => d.value === district)
- return found ? found.label : '—'
- }
- function isOnline(d) { return d.onlineStatus === 1 }
- function isAlarm(d) { return d.alarmStatus === 1 }
- // ============ 实时数据列(采集记录最新一条的指标项) ============
- function profileOf(row) {
- return profileMap[row.equipmentId] || null
- }
- function latestRowOf(row) {
- const p = profileOf(row)
- const rows = p?.recordView?.rows || []
- return rows.length ? rows[0] : null
- }
- function columnLabelMap(p) {
- const map = {}
- ;(p?.recordView?.columns || []).forEach((c) => { map[c.key] = c })
- return map
- }
- function latestMetricsText(row) {
- if (!isOnline(row)) return '—' // 离线设备不展示实时数据
- const p = profileOf(row)
- const first = latestRowOf(row)
- if (!first) return '—'
- const colMap = columnLabelMap(p)
- return (first.cells || [])
- .filter((c) => c.key !== 'filepath')
- .map((c) => {
- const label = colMap[c.key]?.label || c.key
- const v = c.value && c.value !== '' ? c.value : (c.display || '—')
- return `${label}:${v}`
- })
- .join(' ')
- }
- function hasAlarmInLatest(row) {
- const first = latestRowOf(row)
- return (first?.cells || []).some((c) => c.alarm)
- }
- // ============ 采集记录渲染(与 realTimeShow 一致) ============
- const recordView = computed(() => currentProfile.value.recordView || { columns: [], rows: [] })
- const recordViewColumns = computed(() => recordView.value.columns || [])
- const recordRows = computed(() => recordView.value.rows || [])
- const pagedRecordRows = computed(() => {
- const start = (recordPage.value - 1) * recordPageSize
- return recordRows.value.slice(start, start + recordPageSize)
- })
- function valueOf(row, key) {
- const cells = row.cells || []
- const c = cells.find((x) => x.key === key)
- if (!c) return '—'
- if (recordView.value.layout === 'long') return c.display
- return c.value && c.value !== '' ? c.value : c.display
- }
- function alarmOf(row, key) {
- const cells = row.cells || []
- const c = cells.find((x) => x.key === key)
- return c ? !!c.alarm : false
- }
- // 采集记录音频:行内原生播放器,一次只播一条
- function pauseOtherAudios(e) {
- document.querySelectorAll('audio.rec-audio').forEach((el) => {
- if (el !== e.target) el.pause()
- })
- }
- // ============ 数据加载 ============
- function loadList() {
- loading.value = true
- return pagePerceptionEquipment({
- pageNum: query.pageNum,
- pageSize: query.pageSize,
- equipmentCode: query.equipmentCode || undefined,
- equipmentName: query.equipmentName || undefined,
- rootTypeId: undefined
- })
- .then((res) => {
- const page = res.data || {}
- list.value = page.records || []
- total.value = page.total || 0
- // 批量拉取档案,填充列表实时数据列;复用 getEquipmentProfile,不新增接口
- return Promise.all(
- list.value.map((d) =>
- getEquipmentProfile(d.equipmentId)
- .then((p) => { profileMap[d.equipmentId] = p.data || null })
- .catch(() => { profileMap[d.equipmentId] = null })
- )
- )
- })
- .finally(() => { loading.value = false })
- }
- function handleSearch() {
- query.pageNum = 1
- loadList()
- }
- function handleReset() {
- query.equipmentCode = ''
- query.equipmentName = ''
- query.pageNum = 1
- loadList()
- }
- // ============ 远程控制抽屉 ============
- function openControl(row) {
- currentDevice.value = row
- drawerTitle.value = '远程手动控制 · ' + row.equipmentName
- drawerOpen.value = true
- recordPage.value = 1
- reportInterval.value = currentReportInterval.value = 30
- profileLoading.value = true
- const cached = profileMap[row.equipmentId]
- if (cached) {
- currentProfile.value = cached
- profileLoading.value = false
- } else {
- getEquipmentProfile(row.equipmentId)
- .then((res) => { currentProfile.value = res.data || { base: null, lastCommTime: '', recordView: { columns: [], rows: [] } } })
- .finally(() => { profileLoading.value = false })
- }
- }
- function startDrawerTimer() {
- stopDrawerTimer()
- drawerTimer = setInterval(() => {
- if (currentDevice.value) {
- getEquipmentProfile(currentDevice.value.equipmentId)
- .then((res) => {
- const p = res.data || null
- if (p) { profileMap[currentDevice.value.equipmentId] = p; currentProfile.value = p }
- })
- }
- }, 3000)
- }
- function stopDrawerTimer() {
- if (drawerTimer) clearInterval(drawerTimer)
- drawerTimer = null
- }
- function onDrawerClosed() {
- stopDrawerTimer()
- document.querySelectorAll('audio.rec-audio').forEach((el) => el.pause())
- }
- // 下发指令:纯前端模拟,不调用后端接口
- function sendCommand() {
- const num = Number(reportInterval.value)
- if (!num || num < 5 || num > 3600) {
- ElMessage.error('上报间隔须为 5 ~ 3600 秒之间的整数')
- return
- }
- const d = currentDevice.value
- const cur = currentReportInterval.value
- if (d.onlineStatus !== 1) {
- ElMessage.error(`指令下发失败:${d.equipmentName} 处于离线状态,指令将被拦截`)
- cmdLogs.value.unshift({ time: nowText(), deviceName: d.equipmentName, param: `上报间隔 ${num} 秒`, result: '失败' })
- return
- }
- ElMessageBox.confirm(
- `将向设备 <b>${d.equipmentName}</b>(${d.equipmentCode})下发指令:上报间隔 ${cur} 秒 → <b>${num} 秒</b>`,
- '确认下发控制指令',
- { confirmButtonText: '确认下发', cancelButtonText: '取消', type: 'warning', dangerouslyUseHTMLString: true }
- ).then(() => {
- const btnText = '下发中...'
- ElMessage.info(btnText)
- setTimeout(() => {
- currentReportInterval.value = num
- if (profileMap[d.equipmentId]) profileMap[d.equipmentId].currentReportInterval = num
- cmdLogs.value.unshift({ time: nowText(), deviceName: d.equipmentName, param: `上报间隔 ${num} 秒`, result: '成功' })
- ElMessage.success(`上报间隔已更新为 ${num} 秒:${d.equipmentName}`)
- }, 600)
- }).catch(() => {})
- }
- function nowText() {
- const dt = new Date()
- const p = (n) => String(n).padStart(2, '0')
- return `${dt.getFullYear()}-${p(dt.getMonth() + 1)}-${p(dt.getDate())} ${p(dt.getHours())}:${p(dt.getMinutes())}:${p(dt.getSeconds())}`
- }
- onMounted(() => { loadList() })
- onBeforeUnmount(() => { stopDrawerTimer() })
- </script>
- <style scoped lang="scss">
- .remote-ctl-page {
- padding: 16px;
- background: #f0f3f8;
- min-height: calc(100vh - 84px);
- }
- .title-sub { font-size: 12px; color: #97a3b7; font-weight: 400; }
- .alarm-filter-card { margin-bottom: 16px; }
- .alarm-filters { display: flex; flex-wrap: wrap; align-items: center; }
- /* 单行查询表单:清掉 el-form-item 默认的 18px 底部间距,保证卡片上下留白一致 */
- .alarm-filter-card :deep(.el-form-item) { margin-bottom: 0; }
- .alarm-table-panel { padding: 15px 20px 20px; margin-bottom: 20px; background: #fff; border: 1px solid var(--el-card-border-color); border-radius: 8px; }
- .alarm-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
- .alarm-toolbar h3 { font-size: 15px; font-weight: 600; margin: 0; display: flex; align-items: center; gap: 8px; }
- .alarm-toolbar h3:before { content: ""; width: 3px; height: 15px; border-radius: 2px; background: #1677ff; }
- .toolbar-right { display: flex; align-items: center; gap: 12px; }
- .muted { color: #909399; font-size: 13px; }
- /* 状态标签(与 realTimeShow 一致) */
- .tag { display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px; border-radius: 20px; font-size: 12px; font-weight: 500; }
- .tag-green { background: #dcfce7; color: #16a34a; }
- .tag-gray { background: #f1f5f9; color: #64748b; }
- .tag-red { background: #fee2e2; color: #ef4444; }
- .tag .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; display: inline-block; }
- .cell-alarm { color: #ef4444; font-weight: 600; }
- /* 抽屉内基础信息 / 表单项(复用 realTimeShow 样式) */
- .ctl-dev { background: #f8fafc; border: 1px solid #e3e9f2; border-radius: 8px; padding: 14px; margin-bottom: 16px; }
- .ctl-dev-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 14px; font-size: 12px; color: #5e6d82; }
- .ctl-dev-grid .full { grid-column: 1 / -1; }
- .ctl-dev-grid b { color: #0a1628; font-weight: 500; }
- .form-item { display: block; margin-bottom: 14px; }
- .form-item > span { display: block; font-size: 13px; color: #5e6d82; margin-bottom: 6px; font-weight: 500; }
- .row { display: flex; align-items: center; }
- .row.gap10 { gap: 10px; }
- .unit { font-size: 13px; color: #5e6d82; }
- .quick-steps { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
- .chip { border: 1px solid #d6e6ff; background: #eef5ff; color: #1677ff; border-radius: 16px; padding: 4px 12px; font-size: 12px; cursor: pointer; transition: 0.15s; }
- .chip:hover { background: #d6e6ff; }
- .chip.active { background: #1677ff; color: #fff; border-color: #1677ff; }
- .form-tip { margin-top: 8px; font-size: 12px; color: #97a3b7; line-height: 1.6; }
- .drawer-actions { display: flex; gap: 10px; margin: 16px 0; }
- .drawer-actions :deep(.el-button) { flex: 1; height: 36px; }
- /* 采集记录表格(与 realTimeShow 一致) */
- .hist-wrap { overflow: auto; border-radius: 8px; background: #fff; }
- .hist-wrap :deep(.el-table) { font-size: 12px; border: 1px solid #e3e9f2; border-radius: 8px; }
- .hist-wrap :deep(.el-table th.el-table__cell) { background: #f7f9fc; color: #5e6d82; font-weight: 600; }
- .hist-wrap :deep(.el-table td.el-table__cell) { padding: 6px 0; border-bottom: 1px solid #eef2f7; }
- .hist-wrap :deep(.el-table .cell) { padding: 0 10px; line-height: 1.5; }
- .hist-wrap :deep(.el-table td.el-table__cell .cell) { white-space: nowrap; }
- .hist-wrap :deep(.el-table__header th.el-table__cell) { border-bottom: 1px solid #e3e9f2; }
- .hist-wrap :deep(.el-table::before),
- .hist-wrap :deep(.el-table::after),
- .hist-wrap :deep(.el-table__inner-wrapper::before),
- .hist-wrap :deep(.el-table__inner-wrapper::after) { display: none; }
- .hist-wrap :deep(.el-table .el-table__row:last-child td.el-table__cell) { border-bottom: none; }
- .rec-empty { color: #9aa7b8; font-size: 12px; padding: 28px 0; }
- .rec-audio-cell { display: flex; align-items: center; overflow: hidden; }
- .rec-audio { width: 150px; max-width: 100%; height: 26px; vertical-align: middle; }
- .rec-pager { margin-top: 10px; justify-content: flex-end; display: flex; }
- :deep(.el-drawer__header) { margin-bottom: 0; padding: 10px 20px; border-bottom: 1px solid #e3e9f2; color: #0a1628; }
- :deep(.el-drawer__body) { padding: 10px 20px 20px; }
- </style>
|