瀏覽代碼

refactor(video): 重构视频模块接入真实接口并支持静音播放

- startLive 接口新增可选 config 参数支持透传请求配置
- VideoPlayer 组件新增 muted 属性,控制原生及 Jessibuca 播放器音频开关
- 排水 GIS 地图页移除模拟数据,接入 getDrainageVideoMap 真实接口
- GIS 页替换 BMapGL 为 GisMapCanvas 组件,增加权限校验与错误处理
- 视频管理页移除模拟数据,改为表格展示并接入真实设备接口
- 视频弹窗改用 DrainageLiveTile 组件实现实际播放能力
林仔 1 周之前
父節點
當前提交
5117acf152

文件差異過大導致無法顯示
+ 0 - 0
public/jessibuca/decoder.js


文件差異過大導致無法顯示
+ 0 - 0
public/jessibuca/jessibuca.js


+ 8 - 0
src/api/drainageVideo.js

@@ -0,0 +1,8 @@
+import request from '@/utils/request'
+const base = '/api/drainage/video'
+export const listDrainageVideos = params => request({ url: `${base}/resources`, params })
+export const getDrainageVideoTree = () => request({ url: `${base}/tree` })
+export const getDrainageVideoMap = () => request({ url: `${base}/map` })
+export const getDrainageVideoPoints = () => request({ url: `${base}/points` })
+export const associateDrainageVideo = (id, pointId) => request({ url: `${base}/devices/${encodeURIComponent(id)}/waterlogging`, method: 'put', data: { pointId } })
+export const favoriteDrainageVideo = (id, enabled) => request({ url: `${base}/devices/${encodeURIComponent(id)}/favorite`, method: 'put', data: { enabled } })

+ 1 - 1
src/api/video/index.js

@@ -7,7 +7,7 @@ export const removeVideoDevice = id => request({ url: `/api/video/devices/${id}`
 export const discoverVideoDevices = params => request({ url: '/api/video/discovered', params })
 export const syncVideoDevice = id => request({ url: `/api/video/discovered/${id}/sync`, method: 'post' })
 export const syncAllVideoDevices = () => request({ url: '/api/video/discovered/sync', method: 'post', timeout: 35000 })
-export const startLive = id => request({ url: `/api/video/devices/${id}/live`, method: 'post', timeout: 35000 })
+export const startLive = (id, config = {}) => request({ url: `/api/video/devices/${id}/live`, method: 'post', timeout: 35000, ...config })
 export const queryRecords = (id, params) => request({ url: `/api/video/devices/${id}/records`, params, timeout: 95000 })
 export const startPlayback = (id, data) => request({ url: `/api/video/devices/${id}/playback`, method: 'post', data, timeout: 35000 })
 export const ptzVideo = (id, data) => request({ url: `/api/video/devices/${id}/ptz`, method: 'post', data, headers: { repeatSubmit: false } })

+ 4 - 4
src/components/VideoPlayer/index.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="video-player">
-    <video v-if="native" ref="video" :src="url" controls autoplay playsinline @error="emit('error', '视频地址无法播放,请确认格式和网络')" />
+    <video v-if="native" ref="video" :src="url" :muted="muted" controls autoplay playsinline @playing="emit('ready')" @error="emit('error', '视频地址无法播放,请确认格式和网络')" />
     <div v-else ref="container" class="decoder-player" />
     <div v-if="error" class="player-error">{{ error }}</div>
   </div>
@@ -8,7 +8,7 @@
 
 <script setup>
 import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
-const props = defineProps({ url: String, native: Boolean })
+const props = defineProps({ url: String, native: Boolean, muted: Boolean })
 const emit = defineEmits(['error', 'ready'])
 const container = ref(), video = ref(), error = ref('')
 let player, generation = 0
@@ -28,7 +28,7 @@ function dispose() {
   if (player) { player.destroy(); player = null }
   if (video.value) { video.value.pause(); video.value.removeAttribute('src'); video.value.load() }
 }
-watch(() => [props.url, props.native], async () => {
+watch(() => [props.url, props.native, props.muted], async () => {
   dispose(); error.value = ''
   const version = generation
   if (!props.url || props.native) return
@@ -36,7 +36,7 @@ watch(() => [props.url, props.native], async () => {
     await nextTick(); await loadSdk()
     if (version !== generation || !container.value) return
     player = new window.Jessibuca({ container: container.value, decoder: `${import.meta.env.BASE_URL}jessibuca/decoder.js`,
-      isResize: true, useMSE: false, useWCS: false, hasAudio: true, videoBuffer: 0.2,
+      isResize: true, useMSE: false, useWCS: false, hasAudio: !props.muted, videoBuffer: 0.2,
       operateBtns: { fullscreen: true, screenshot: true, play: false, audio: true }, loadingText: '视频加载中' })
     player.on('error', () => { error.value = '视频解码或网络异常,请重试'; emit('error', error.value) })
     player.on('play', () => emit('ready'))

+ 33 - 0
src/utils/drainageVideoModel.js

@@ -0,0 +1,33 @@
+export const videoLayers = [
+  { value: 'GB28181', label: '国标视频', color: '#409eff' },
+  { value: 'MANUAL', label: '地址视频', color: '#67c23a' }
+]
+export const districts = [{ value: 0, label: '城南' }, { value: 1, label: '城北' }, { value: 2, label: '太常' }]
+export function hasVideoCoordinates(row) {
+  if (row.longitude == null || row.latitude == null || row.longitude === '' || row.latitude === '') return false
+  const lng = Number(row.longitude), lat = Number(row.latitude)
+  return Number.isFinite(lng) && Number.isFinite(lat) && lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90 && !(lng === 0 && lat === 0)
+}
+export function videoSlots(rows, ids, mode, page) {
+  const size = [1, 4, 9].includes(mode) ? mode : 4
+  const index = new Map(rows.map(row => [row.videoId, row]))
+  const selected = [...new Set(ids)].map(id => index.get(id)).filter(Boolean)
+  const pages = Math.max(1, Math.ceil(selected.length / size))
+  const current = Math.max(1, Math.min(page, pages))
+  const slice = selected.slice((current - 1) * size, current * size)
+  return { pages, current, total: selected.length, slots: Array.from({ length: size }, (_, i) => slice[i] || null) }
+}
+export function filterGroupVideos(rows, node, query = '') {
+  const ids = node ? new Set(node.videoIds || []) : null
+  const key = query.trim().toLowerCase()
+  return rows.filter(row => (!ids || ids.has(row.videoId)) && (!key || `${row.deviceName} ${row.deviceCode}`.toLowerCase().includes(key)))
+}
+export function videoMapPoints(rows, layers, query = '') {
+  const key = query.trim().toLowerCase()
+  return rows.filter(row => layers.includes(row.sourceType) && (!key || `${row.deviceName} ${row.deviceCode} ${row.pointName || ''}`.toLowerCase().includes(key)))
+    .filter(hasVideoCoordinates).map(row => {
+      const color = videoLayers.find(layer => layer.value === row.sourceType)?.color || '#909399'
+      const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><circle cx="18" cy="18" r="17" fill="${color}" stroke="white" stroke-width="2"/><rect x="7" y="12" width="15" height="12" rx="2" fill="white"/><path d="M23 15l7-4v14l-7-4z" fill="white"/></svg>`
+      return { ...row, id: row.videoId, name: row.deviceName, lng: Number(row.longitude), lat: Number(row.latitude), iconUrl: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` }
+    })
+}

+ 47 - 0
src/utils/videoLiveSession.js

@@ -0,0 +1,47 @@
+/** 迟到的 start 响应也必须关闭,不能泄漏到下一页。 */
+export function createVideoLiveSession(api, update, timers = globalThis) {
+  let generation = 0, session = '', heartbeat, disposed = false
+  const clear = () => { if (heartbeat) timers.clearInterval(heartbeat); heartbeat = undefined }
+  async function release(id) {
+    if (!id) return
+    try { await api.stop(id) } catch { update({ cleanupError: '会话释放失败,服务端将在心跳超时后回收' }) }
+  }
+  async function stop() {
+    ++generation; clear()
+    const id = session; session = ''
+    update({ url: '', loading: false })
+    await release(id)
+  }
+  async function start(id, secure = false) {
+    const stopping = stop()
+    const version = generation
+    await stopping
+    if (disposed || !id || version !== generation) return
+    update({ url: '', loading: true, error: '', cleanupError: '' })
+    try {
+      const result = await api.start(id)
+      const data = result.data || {}
+      if (disposed || version !== generation) { await release(data.sessionId); return }
+      session = data.sessionId
+      const url = data.url || (secure ? data.wss_flv || data.https_flv : data.ws_flv || data.flv)
+      if (!session || !url) throw new Error('媒体服务没有返回有效会话或可播放地址')
+      update({ url, native: Boolean(data.native) && !/\.flv(?:[?#]|$)|^wss?:/.test(url), loading: false })
+      let beating = false
+      heartbeat = timers.setInterval(async () => {
+        if (beating || version !== generation || !session) return
+        beating = true
+        try { await api.heartbeat(session) }
+        catch { if (version === generation) { update({ error: '播放会话断开,请重试' }); await stop() } }
+        finally { beating = false }
+      }, 30000)
+    } catch (error) {
+      if (version === generation && !disposed) {
+        const id = session; session = ''; clear()
+        update({ loading: false, url: '', error: error.message || '实时播放失败,请检查设备和媒体服务' })
+        await release(id)
+      }
+    }
+  }
+  async function dispose() { disposed = true; await stop() }
+  return { start, stop, dispose }
+}

+ 29 - 0
src/views/subSystem/drainage/spjc/DrainageLiveTile.vue

@@ -0,0 +1,29 @@
+<template>
+  <div class="live-tile" :data-playing="state.playing" v-loading="state.loading" element-loading-background="rgba(0,0,0,.6)">
+    <VideoPlayer v-if="state.url && !state.error" :url="state.url" :native="state.native" :muted="true" @ready="state.playing=true" @error="playError" />
+    <div v-else class="tile-message"><span>{{ state.error || (state.loading ? '正在连接视频…' : '未开始播放') }}</span><el-button v-if="state.error && enabled" size="small" type="primary" @click="start">重试</el-button></div>
+    <div v-if="state.cleanupError" class="cleanup-error">{{ state.cleanupError }}</div>
+  </div>
+</template>
+<script setup>
+import { reactive, watch, onBeforeUnmount, onDeactivated, onActivated } from 'vue'
+import VideoPlayer from '@/components/VideoPlayer/index.vue'
+import { startLive, stopVideo, heartbeatVideo } from '@/api/video'
+import { createVideoLiveSession } from '@/utils/videoLiveSession'
+const props = defineProps({ videoId: String, enabled: { type: Boolean, default: true } })
+const state = reactive({ url: '', loading: false, error: '', native: false, cleanupError: '', playing: false })
+let inactive = false
+const live = createVideoLiveSession({ start: id => startLive(id, { headers: { 'X-Silent-Error': true, repeatSubmit: false } }), stop: stopVideo, heartbeat: heartbeatVideo }, values => {
+  if ('url' in values) state.playing=false
+  Object.assign(state, values)
+})
+function start() { if (!inactive && props.enabled) live.start(props.videoId, location.protocol === 'https:') }
+function playError(error) { state.error = error; live.stop() }
+watch(() => [props.videoId, props.enabled], () => { if (props.enabled && props.videoId) start(); else live.stop() }, { immediate: true })
+onDeactivated(() => { inactive = true; live.stop() })
+onActivated(() => { if (inactive) { inactive = false; start() } })
+onBeforeUnmount(() => live.dispose())
+</script>
+<style scoped>
+.live-tile{position:relative;background:#101820;min-height:160px;height:100%;color:#fff}.live-tile :deep(.video-player){height:100%}.tile-message{display:flex;height:100%;min-height:160px;gap:16px;align-items:center;justify-content:center;flex-direction:column;padding:16px;text-align:center}.cleanup-error{position:absolute;bottom:0;padding:4px 8px;font-size:12px;background:#5a4000}
+</style>

+ 33 - 638
src/views/subSystem/drainage/spjc/jcspGIS.vue

@@ -1,643 +1,38 @@
 <template>
-  <div class="jcsp-gis-page">
-    <!-- 左侧浮动面板 -->
-    <div class="side-panel">
-      <div class="panel-header">
-        <span class="panel-title">视频资源分布</span>
-      </div>
-
-      <!-- 图层筛选 -->
-      <div class="panel-section">
-        <div class="section-label">图层筛选</div>
-        <div class="layer-list">
-          <div class="layer-item" v-for="item in layerOptions" :key="item.value">
-            <el-checkbox
-              v-model="layerChecked[item.value]"
-              @change="onLayerChange"
-            >
-              <span class="layer-dot" :style="{ background: item.color }"></span>
-              {{ item.label }}
-            </el-checkbox>
-          </div>
-        </div>
-      </div>
-
-      <!-- 视频统计 -->
-      <div class="panel-section">
-        <div class="section-label">视频统计</div>
-        <div class="stats-row">
-          <div class="stat-card">
-            <div class="stat-value">{{ videoList.length }}</div>
-            <div class="stat-text">总数</div>
-          </div>
-          <div class="stat-card stat-online">
-            <div class="stat-value">{{ onlineCount }}</div>
-            <div class="stat-text">在线</div>
-          </div>
-          <div class="stat-card stat-offline">
-            <div class="stat-value">{{ offlineCount }}</div>
-            <div class="stat-text">离线</div>
-          </div>
-        </div>
-      </div>
-
-      <!-- 搜索框 -->
-      <div class="panel-section">
-        <div class="section-label">快速定位</div>
-        <el-input
-          v-model="searchKey"
-          placeholder="输入视频名称搜索"
-          clearable
-          prefix-icon="Search"
-          @input="onSearch"
-        />
-        <div class="search-results" v-if="searchResults.length > 0">
-          <div
-            class="search-item"
-            v-for="item in searchResults"
-            :key="item.id"
-            @click="locateVideo(item)"
-          >
-            <span class="layer-dot" :style="{ background: getTypeColor(item.type) }"></span>
-            <span class="search-name">{{ item.name }}</span>
-            <span class="search-status" :class="item.status === '在线' ? 'online' : 'offline'">
-              {{ item.status }}
-            </span>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 地图容器 -->
-    <div id="jcspGISMap" class="map-container"></div>
-
-    <!-- 视频弹窗 -->
-    <el-dialog
-      v-model="videoDialogVisible"
-      :title="currentVideo.name"
-      width="480px"
-      :close-on-click-modal="false"
-      class="video-dialog"
-      @closed="onDialogClosed"
-    >
-      <div class="video-player" :class="{ offline: currentVideo.status === '离线' }">
-        <div class="player-placeholder" v-if="currentVideo.status === '离线'">
-          <el-icon :size="48"><VideoCamera /></el-icon>
-          <span>设备离线,无法播放</span>
-        </div>
-        <div class="player-placeholder playing" v-else>
-          <el-icon :size="48" class="play-icon"><VideoCamera /></el-icon>
-          <span>实时监控画面</span>
-          <div class="live-badge">LIVE</div>
-        </div>
-      </div>
-      <div class="video-info">
-        <div class="info-row">
-          <span class="info-label">视频名称</span>
-          <span class="info-value">{{ currentVideo.name }}</span>
-        </div>
-        <div class="info-row">
-          <span class="info-label">设备类型</span>
-          <span class="info-value">
-            <span class="type-tag" :style="{ background: getTypeColor(currentVideo.type), color: '#fff' }">
-              {{ getTypeLabel(currentVideo.type) }}
-            </span>
-          </span>
-        </div>
-        <div class="info-row">
-          <span class="info-label">安装位置</span>
-          <span class="info-value">{{ currentVideo.location }}</span>
-        </div>
-        <div class="info-row">
-          <span class="info-label">在线状态</span>
-          <span class="info-value" :class="currentVideo.status === '在线' ? 'status-online' : 'status-offline'">
-            {{ currentVideo.status }}
-          </span>
-        </div>
-        <div class="info-row">
-          <span class="info-label">关联设备</span>
-          <span class="info-value">{{ currentVideo.device }}</span>
-        </div>
-      </div>
-    </el-dialog>
+  <div class="video-gis" v-loading="loading">
+    <aside>
+      <h3>视频资源分布</h3><el-checkbox-group v-model="layers"><el-checkbox v-for="layer in videoLayers" :key="layer.value" :label="layer.value"><i :style="{background:layer.color}" />{{ layer.label }}</el-checkbox></el-checkbox-group>
+      <el-input v-model="keyword" placeholder="名称/编码/易积水点" clearable /><p>资源 {{ rows.length }} 个 · 当前上图 {{ points.length }} 个</p>
+      <el-alert v-if="missing" :title="`${missing} 个视频坐标缺失或无效,未上图;请在设备台账补齐。`" type="warning" :closable="false" /><el-button @click="load">刷新资源</el-button>
+      <el-alert v-if="error" :title="error" type="error" :closable="false" /><el-empty v-if="!points.length && !loading" description="当前图层暂无可上图视频" :image-size="70" />
+      <div v-for="row in points" :key="row.id" class="resource"><el-button link type="primary" @click="locate(row)">{{ row.deviceName }}</el-button><small>{{ row.districtName }} · {{ row.pointName || '未关联点位' }}</small><el-button v-hasPermi="['video:device:play']" link @click="open(row)">查看视频</el-button></div>
+    </aside>
+    <main>
+      <GisMapCanvas :key="mapKey" ref="map" :points="points" :marker-size="36" :map-style-id="''" @ready="ready" @error="mapError=$event" @point-click="open" @cluster-click="cluster=$event.points || []" />
+      <div v-if="mapError" class="map-error"><span>{{ mapError }}</span><el-button @click="retryMap">重试地图</el-button></div>
+      <div v-if="cluster.length" class="cluster"><el-button link @click="cluster=[]">关闭重叠视频列表</el-button><el-button v-for="row in cluster" :key="row.videoId" link type="primary" @click="open(row)">{{ row.deviceName }}</el-button></div>
+    </main>
+    <el-dialog :model-value="Boolean(viewer)" :title="viewer?.deviceName" width="760px" @update:model-value="viewer=null"><div class="viewer"><DrainageLiveTile v-if="viewer" :key="viewer.videoId" :video-id="viewer.videoId" /></div><p>{{ viewer?.districtName }} · {{ viewer?.location }} · {{ viewer?.pointName || '未关联易积水点' }}</p></el-dialog>
   </div>
 </template>
-
 <script setup name="JcspGIS">
-import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
-import { VideoCamera, Search } from '@element-plus/icons-vue'
-import locationIcon from '@/assets/images/location.png'
-
-// ==================== 图层配置 ====================
-const layerOptions = [
-  { label: '积水点视频', value: 'jsd', color: '#409eff' },
-  { label: '泵站视频', value: 'bz', color: '#67c23a' },
-  { label: '下穿立交视频', value: 'xclj', color: '#e6a23c' },
-  { label: '管网视频', value: 'gw', color: '#9b59b6' }
-]
-
-const layerChecked = reactive({
-  jsd: true,
-  bz: true,
-  xclj: true,
-  gw: true
-})
-
-// ==================== 模拟视频数据(18个点位)====================
-const videoList = ref([
-  { id: 1, name: '沅陵大桥积水点', type: 'jsd', lng: 110.396, lat: 28.468, status: '在线', location: '沅陵大桥南侧', device: 'WL-JSD-001' },
-  { id: 2, name: '太常路积水点', type: 'jsd', lng: 110.382, lat: 28.459, status: '在线', location: '太常路与沅江路交汇', device: 'WL-JSD-002' },
-  { id: 3, name: '沅江路积水点', type: 'jsd', lng: 110.403, lat: 28.447, status: '离线', location: '沅江路中段', device: 'WL-JSD-003' },
-  { id: 4, name: '龙舟路积水点', type: 'jsd', lng: 110.388, lat: 28.436, status: '在线', location: '龙舟路与建设路交汇', device: 'WL-JSD-004' },
-  { id: 5, name: '城东积水点', type: 'jsd', lng: 110.412, lat: 28.461, status: '在线', location: '城东开发区入口', device: 'WL-JSD-005' },
-  { id: 6, name: '一号泵站', type: 'bz', lng: 110.391, lat: 28.465, status: '在线', location: '沅陵大桥西侧', device: 'WL-BZ-001' },
-  { id: 7, name: '二号泵站', type: 'bz', lng: 110.378, lat: 28.451, status: '在线', location: '太常片区排水口', device: 'WL-BZ-002' },
-  { id: 8, name: '三号泵站', type: 'bz', lng: 110.407, lat: 28.455, status: '离线', location: '沅江路东段', device: 'WL-BZ-003' },
-  { id: 9, name: '四号泵站', type: 'bz', lng: 110.395, lat: 28.439, status: '在线', location: '城区排水枢纽', device: 'WL-BZ-004' },
-  { id: 10, name: '五号泵站', type: 'bz', lng: 110.368, lat: 28.448, status: '在线', location: '沅水南岸排水站', device: 'WL-BZ-005' },
-  { id: 11, name: '沅陵大桥下穿', type: 'xclj', lng: 110.398, lat: 28.463, status: '在线', location: '沅陵大桥下穿通道', device: 'WL-XC-001' },
-  { id: 12, name: '太常路下穿', type: 'xclj', lng: 110.384, lat: 28.453, status: '在线', location: '太常路下穿隧道', device: 'WL-XC-002' },
-  { id: 13, name: '建设路下穿', type: 'xclj', lng: 110.393, lat: 28.441, status: '离线', location: '建设路下穿通道', device: 'WL-XC-003' },
-  { id: 14, name: '城东下穿立交', type: 'xclj', lng: 110.415, lat: 28.457, status: '在线', location: '城东立交桥下', device: 'WL-XC-004' },
-  { id: 15, name: '主城区管网A段', type: 'gw', lng: 110.390, lat: 28.460, status: '在线', location: '主城区排水干管', device: 'WL-GW-001' },
-  { id: 16, name: '太常片区管网', type: 'gw', lng: 110.376, lat: 28.446, status: '在线', location: '太常片区管网节点', device: 'WL-GW-002' },
-  { id: 17, name: '沅江路管网', type: 'gw', lng: 110.405, lat: 28.449, status: '离线', location: '沅江路管网检测点', device: 'WL-GW-003' },
-  { id: 18, name: '城东管网B段', type: 'gw', lng: 110.418, lat: 28.465, status: '在线', location: '城东管网检测井', device: 'WL-GW-004' }
-])
-
-// ==================== 统计计算 ====================
-const onlineCount = computed(() => videoList.value.filter(v => v.status === '在线').length)
-const offlineCount = computed(() => videoList.value.filter(v => v.status === '离线').length)
-
-// ==================== 搜索功能 ====================
-const searchKey = ref('')
-const searchResults = ref([])
-
-function onSearch() {
-  if (!searchKey.value.trim()) {
-    searchResults.value = []
-    return
-  }
-  const key = searchKey.value.trim().toLowerCase()
-  searchResults.value = videoList.value.filter(v =>
-    v.name.toLowerCase().includes(key) || v.location.toLowerCase().includes(key)
-  )
-}
-
-function locateVideo(item) {
-  if (mapInstance) {
-    mapInstance.centerAndZoom(new BMapGL.Point(item.lng, item.lat), 17)
-    // 触发对应标记的点击
-    const label = markerMap.get(item.id)
-    if (label) {
-      // 打开视频弹窗
-      openVideoDialog(item)
-    }
-  }
-  searchResults.value = []
-  searchKey.value = ''
-}
-
-// ==================== 工具方法 ====================
-function getTypeColor(type) {
-  const map = { jsd: '#409eff', bz: '#67c23a', xclj: '#e6a23c', gw: '#9b59b6' }
-  return map[type] || '#409eff'
-}
-
-function getTypeLabel(type) {
-  const map = { jsd: '积水点视频', bz: '泵站视频', xclj: '下穿立交视频', gw: '管网视频' }
-  return map[type] || '未知类型'
-}
-
-// ==================== 地图相关 ====================
-let mapInstance = null
-const markerMap = new Map() // id -> label
-
-function initMap() {
-  const container = document.getElementById('jcspGISMap')
-  if (!container || typeof BMapGL === 'undefined') {
-    console.warn('BMapGL 未加载或容器不存在')
-    return
-  }
-
-  try {
-    mapInstance = new BMapGL.Map('jcspGISMap')
-    const centerPoint = new BMapGL.Point(110.393, 28.452)
-    mapInstance.centerAndZoom(centerPoint, 15)
-    mapInstance.enableScrollWheelZoom(true)
-
-    // 添加所有标记
-    videoList.value.forEach(point => {
-      addMapMarker(point)
-    })
-  } catch (e) {
-    console.error('百度地图初始化失败:', e)
-  }
-}
-
-function addMapMarker(point) {
-  const color = getTypeColor(point.type)
-  const typeLabel = getTypeLabel(point.type)
-  const bPoint = new BMapGL.Point(point.lng, point.lat)
-  const statusDot = point.status === '在线'
-    ? '<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:#67c23a;margin-right:3px;vertical-align:middle;"></span>'
-    : '<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:#f56c6c;margin-right:3px;vertical-align:middle;"></span>'
-
-  const html = `<div style="text-align:center;cursor:pointer;">
-    <div style="color:#fff;background:${color};border-radius:4px;padding:2px 8px;font-size:11px;white-space:nowrap;display:inline-block;margin-bottom:2px;box-shadow:0 1px 4px rgba(0,0,0,0.3);">
-      ${statusDot}${point.name}
-    </div>
-    <div><img src="${locationIcon}" style="width:28px;height:28px;display:block;margin:0 auto;"/></div>
-  </div>`
-
-  const label = new BMapGL.Label(html, {
-    position: bPoint,
-    offset: new BMapGL.Size(-30, -50)
-  })
-  label.setStyle({
-    border: 'none',
-    background: 'transparent',
-    padding: '0',
-    zIndex: '10'
-  })
-  mapInstance.addOverlay(label)
-
-  // 存储标记引用
-  markerMap.set(point.id, label)
-
-  // 点击标记打开视频弹窗
-  label.addEventListener('click', function () {
-    openVideoDialog(point)
-  })
-}
-
-// ==================== 图层筛选 ====================
-function onLayerChange() {
-  videoList.value.forEach(point => {
-    const label = markerMap.get(point.id)
-    if (!label) return
-    const visible = layerChecked[point.type]
-    label.show()
-    if (!visible) {
-      label.hide()
-    }
-  })
-}
-
-// ==================== 视频弹窗 ====================
-const videoDialogVisible = ref(false)
-const currentVideo = ref({})
-
-function openVideoDialog(point) {
-  currentVideo.value = { ...point }
-  videoDialogVisible.value = true
-}
-
-function onDialogClosed() {
-  currentVideo.value = {}
-}
-
-// ==================== 生命周期 ====================
-onMounted(() => {
-  nextTick(() => {
-    initMap()
-  })
-})
+import { ref, computed, nextTick, watch, onDeactivated } from 'vue'
+import { ElMessage } from 'element-plus'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import { checkPermi } from '@/utils/permission'
+import { getDrainageVideoMap } from '@/api/drainageVideo'
+import { videoLayers, videoMapPoints, hasVideoCoordinates } from '@/utils/drainageVideoModel'
+import DrainageLiveTile from './DrainageLiveTile.vue'
+const rows=ref([]), layers=ref(videoLayers.map(layer=>layer.value)), keyword=ref(''), loading=ref(false), error=ref(''), viewer=ref(null), map=ref(), mapKey=ref(0), mapError=ref(''), cluster=ref([])
+const points=computed(()=>videoMapPoints(rows.value,layers.value,keyword.value))
+const missing=computed(()=>rows.value.filter(row=>!hasVideoCoordinates(row)).length)
+function locate(row){map.value?.locatePoint(row)}
+function open(row){if(!checkPermi(['video:device:play'])){ElMessage.warning('当前账号无视频播放权限');return}viewer.value=row;cluster.value=[]}
+function ready(){mapError.value='';map.value?.fitBounds()}
+function retryMap(){mapError.value='';mapKey.value++}
+watch([layers,keyword],()=>{cluster.value=[]},{deep:true})
+async function load(){loading.value=true;error.value='';cluster.value=[];viewer.value=null;try{rows.value=(await getDrainageVideoMap()).data || [];await nextTick();map.value?.fitBounds()}catch(e){rows.value=[];error.value=e.message || '视频地图资源加载失败'}finally{loading.value=false}}
+onDeactivated(()=>{viewer.value=null})
+load()
 </script>
-
-<style scoped>
-.jcsp-gis-page {
-  position: relative;
-  width: 100%;
-  height: calc(100vh - 84px);
-  overflow: hidden;
-}
-
-.map-container {
-  width: 100%;
-  height: 100%;
-}
-
-/* ==================== 左侧浮动面板 ==================== */
-.side-panel {
-  position: absolute;
-  top: 16px;
-  left: 16px;
-  width: 280px;
-  background: rgba(20, 30, 48, 0.92);
-  border-radius: 8px;
-  z-index: 100;
-  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-  padding: 0;
-  max-height: calc(100% - 32px);
-  overflow-y: auto;
-}
-
-.side-panel::-webkit-scrollbar {
-  width: 4px;
-}
-.side-panel::-webkit-scrollbar-thumb {
-  background: rgba(255, 255, 255, 0.2);
-  border-radius: 2px;
-}
-
-.panel-header {
-  padding: 16px 18px 12px;
-  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
-}
-
-.panel-title {
-  font-size: 16px;
-  font-weight: 600;
-  color: #fff;
-  letter-spacing: 1px;
-}
-
-.panel-section {
-  padding: 14px 18px;
-  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
-}
-
-.panel-section:last-child {
-  border-bottom: none;
-}
-
-.section-label {
-  font-size: 12px;
-  color: rgba(255, 255, 255, 0.5);
-  margin-bottom: 10px;
-  text-transform: uppercase;
-  letter-spacing: 1px;
-}
-
-/* 图层列表 */
-.layer-list {
-  display: flex;
-  flex-direction: column;
-  gap: 6px;
-}
-
-.layer-item :deep(.el-checkbox) {
-  color: rgba(255, 255, 255, 0.85);
-  height: auto;
-}
-
-.layer-item :deep(.el-checkbox__label) {
-  color: rgba(255, 255, 255, 0.85);
-  font-size: 13px;
-  display: inline-flex;
-  align-items: center;
-  gap: 4px;
-}
-
-.layer-item :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
-  background: #409eff;
-  border-color: #409eff;
-}
-
-.layer-dot {
-  display: inline-block;
-  width: 10px;
-  height: 10px;
-  border-radius: 50%;
-  margin-right: 2px;
-  vertical-align: middle;
-}
-
-/* 统计卡片 */
-.stats-row {
-  display: flex;
-  gap: 10px;
-}
-
-.stat-card {
-  flex: 1;
-  background: rgba(255, 255, 255, 0.08);
-  border-radius: 6px;
-  padding: 10px 8px;
-  text-align: center;
-}
-
-.stat-card.stat-online {
-  background: rgba(103, 194, 58, 0.15);
-}
-
-.stat-card.stat-offline {
-  background: rgba(245, 108, 108, 0.15);
-}
-
-.stat-value {
-  font-size: 22px;
-  font-weight: 700;
-  color: #fff;
-  line-height: 1.2;
-}
-
-.stat-online .stat-value {
-  color: #67c23a;
-}
-
-.stat-offline .stat-value {
-  color: #f56c6c;
-}
-
-.stat-text {
-  font-size: 11px;
-  color: rgba(255, 255, 255, 0.5);
-  margin-top: 4px;
-}
-
-/* 搜索 */
-.side-panel :deep(.el-input__wrapper) {
-  background: rgba(255, 255, 255, 0.08);
-  box-shadow: none;
-  border: 1px solid rgba(255, 255, 255, 0.12);
-}
-
-.side-panel :deep(.el-input__inner) {
-  color: #fff;
-  font-size: 13px;
-}
-
-.side-panel :deep(.el-input__inner::placeholder) {
-  color: rgba(255, 255, 255, 0.35);
-}
-
-.side-panel :deep(.el-input__prefix .el-icon) {
-  color: rgba(255, 255, 255, 0.4);
-}
-
-.search-results {
-  margin-top: 8px;
-  max-height: 200px;
-  overflow-y: auto;
-  border-radius: 4px;
-}
-
-.search-results::-webkit-scrollbar {
-  width: 3px;
-}
-.search-results::-webkit-scrollbar-thumb {
-  background: rgba(255, 255, 255, 0.15);
-  border-radius: 2px;
-}
-
-.search-item {
-  display: flex;
-  align-items: center;
-  padding: 7px 10px;
-  cursor: pointer;
-  border-radius: 4px;
-  transition: background 0.15s;
-}
-
-.search-item:hover {
-  background: rgba(255, 255, 255, 0.1);
-}
-
-.search-name {
-  flex: 1;
-  font-size: 12px;
-  color: rgba(255, 255, 255, 0.85);
-  margin-left: 6px;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.search-status {
-  font-size: 11px;
-  padding: 1px 6px;
-  border-radius: 3px;
-}
-
-.search-status.online {
-  color: #67c23a;
-  background: rgba(103, 194, 58, 0.15);
-}
-
-.search-status.offline {
-  color: #f56c6c;
-  background: rgba(245, 108, 108, 0.15);
-}
-
-/* ==================== 视频弹窗样式 ==================== */
-.video-dialog :deep(.el-dialog) {
-  border-radius: 10px;
-  overflow: hidden;
-}
-
-.video-dialog :deep(.el-dialog__header) {
-  background: #1a1a2e;
-  padding: 14px 20px;
-  margin: 0;
-}
-
-.video-dialog :deep(.el-dialog__title) {
-  color: #fff;
-  font-size: 15px;
-}
-
-.video-dialog :deep(.el-dialog__headerbtn .el-dialog__close) {
-  color: rgba(255, 255, 255, 0.6);
-}
-
-.video-dialog :deep(.el-dialog__body) {
-  padding: 0;
-}
-
-.video-player {
-  width: 100%;
-  height: 300px;
-  background: #000;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  position: relative;
-}
-
-.player-placeholder {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 12px;
-  color: rgba(255, 255, 255, 0.3);
-  font-size: 14px;
-}
-
-.player-placeholder.playing {
-  color: rgba(255, 255, 255, 0.6);
-}
-
-.play-icon {
-  color: #409eff;
-  animation: pulse 2s infinite;
-}
-
-@keyframes pulse {
-  0%, 100% { opacity: 1; }
-  50% { opacity: 0.5; }
-}
-
-.live-badge {
-  position: absolute;
-  top: 12px;
-  right: 12px;
-  background: #f56c6c;
-  color: #fff;
-  font-size: 11px;
-  font-weight: 700;
-  padding: 2px 8px;
-  border-radius: 3px;
-  letter-spacing: 1px;
-  animation: blink 1.5s infinite;
-}
-
-@keyframes blink {
-  0%, 100% { opacity: 1; }
-  50% { opacity: 0.4; }
-}
-
-.video-info {
-  padding: 16px 20px;
-}
-
-.info-row {
-  display: flex;
-  align-items: center;
-  padding: 8px 0;
-  border-bottom: 1px solid #f0f0f0;
-}
-
-.info-row:last-child {
-  border-bottom: none;
-}
-
-.info-label {
-  width: 80px;
-  flex-shrink: 0;
-  font-size: 13px;
-  color: #909399;
-}
-
-.info-value {
-  font-size: 13px;
-  color: #303133;
-}
-
-.type-tag {
-  font-size: 11px;
-  padding: 2px 8px;
-  border-radius: 3px;
-}
-
-.status-online {
-  color: #67c23a;
-  font-weight: 500;
-}
-
-.status-offline {
-  color: #f56c6c;
-  font-weight: 500;
-}
-</style>
+<style scoped>.video-gis{display:flex;height:calc(100vh - 84px);min-height:600px}aside{width:280px;flex-shrink:0;padding:18px;overflow:auto}aside .el-input,aside .el-alert{margin:12px 0}i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px}main{flex:1;position:relative;min-width:0}.resource{padding:12px 0;border-bottom:1px solid #eee}.resource small{display:block;color:#909399;margin:5px 0}.map-error{position:absolute;inset:0;background:#eef2f7ee;display:flex;gap:16px;align-items:center;justify-content:center;flex-direction:column}.cluster{position:absolute;top:16px;right:16px;display:flex;flex-direction:column;max-height:400px;overflow:auto;background:#fff;padding:14px;box-shadow:0 3px 14px #0003}.viewer{height:420px}</style>

+ 42 - 647
src/views/subSystem/drainage/spjc/spfzgl.vue

@@ -1,652 +1,47 @@
 <template>
-  <div class="spfzgl-container">
-    <!-- 左侧分组树 -->
-    <div class="left-panel">
-      <div class="panel-header">
-        <span class="panel-title">视频分组</span>
-        <el-button type="primary" link @click="handleAddGroup">
-          <el-icon><Plus /></el-icon> 新增组
-        </el-button>
-      </div>
-      <el-tree
-        ref="treeRef"
-        :data="treeData"
-        :props="treeProps"
-        node-key="id"
-        highlight-current
-        default-expand-all
-        @node-click="handleNodeClick"
-      >
-        <template #default="{ node, data }">
-          <span class="tree-node">
-            <span class="tree-node-label">{{ node.label }}</span>
-            <span v-if="data.count !== undefined" class="tree-node-count">({{ data.count }})</span>
-            <span v-if="data.custom" class="tree-node-actions">
-              <el-icon class="action-icon" @click.stop="handleDeleteGroup(node, data)"><Delete /></el-icon>
-            </span>
-          </span>
-        </template>
-      </el-tree>
-    </div>
-
-    <!-- 右侧视频列表 -->
-    <div class="right-panel">
-      <!-- 搜索栏 -->
-      <el-form :model="queryParams" :inline="true" class="search-form">
-        <el-form-item label="视频名称">
-          <el-input v-model="queryParams.name" placeholder="请输入视频名称" clearable style="width: 200px" />
-        </el-form-item>
-        <el-form-item label="状态">
-          <el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 130px">
-            <el-option label="在线" value="在线" />
-            <el-option label="离线" value="离线" />
-          </el-select>
-        </el-form-item>
-        <el-form-item>
-          <el-button type="primary" @click="handleQuery">搜索</el-button>
-          <el-button @click="resetQuery">重置</el-button>
-        </el-form-item>
-      </el-form>
-
-      <!-- 操作栏 -->
-      <div class="toolbar">
-        <el-button type="primary" :disabled="selectedIds.length === 0" @click="handleCollectBatch">
-          <el-icon><Star /></el-icon> 收藏到分组
-        </el-button>
-      </div>
-
-      <!-- 视频卡片网格 -->
-      <el-row :gutter="16">
-        <el-col :span="8" v-for="item in pagedList" :key="item.id">
-          <div class="video-card" :class="{ 'is-selected': selectedIds.includes(item.id) }" @click="toggleSelect(item.id)">
-            <div class="video-thumb">
-              <el-icon :size="48" color="#fff"><VideoPlay /></el-icon>
-            </div>
-            <div class="video-info">
-              <div class="video-name">
-                <el-checkbox :model-value="selectedIds.includes(item.id)" @click.stop @change="toggleSelect(item.id)" />
-                <span class="name-text">{{ item.name }}</span>
-              </div>
-              <div class="video-meta">
-                <span :class="['status-tag', item.status === '在线' ? 'online' : 'offline']">{{ item.status }}</span>
-                <span class="meta-text">关联设备:{{ item.device }}</span>
-              </div>
-              <div class="video-meta">
-                <span class="meta-text">安装位置:{{ item.location }}</span>
-              </div>
-              <div class="video-actions">
-                <el-button link type="primary" @click.stop="handleViewVideo(item)">查看视频</el-button>
-                <el-button link :type="item.collected ? 'warning' : 'default'" @click.stop="handleToggleCollect(item)">
-                  <el-icon><Star /></el-icon>
-                  {{ item.collected ? '已收藏' : '收藏' }}
-                </el-button>
-              </div>
-            </div>
-          </div>
-        </el-col>
-      </el-row>
-
-      <!-- 分页 -->
-      <div class="pagination-wrap">
-        <el-pagination
-          v-model:current-page="pagination.current"
-          v-model:page-size="pagination.size"
-          :page-sizes="[9, 18, 27]"
-          :total="filteredList.length"
-          layout="total, sizes, prev, pager, next"
-          background
-        />
-      </div>
-    </div>
-
-    <!-- 新增收藏组弹窗 -->
-    <el-dialog v-model="addGroupVisible" title="新增收藏组" width="420px" :before-close="() => addGroupVisible = false">
-      <el-form ref="addGroupFormRef" :model="addGroupForm" :rules="addGroupRules" label-width="90px">
-        <el-form-item label="组名称" prop="name">
-          <el-input v-model="addGroupForm.name" placeholder="请输入收藏组名称" />
-        </el-form-item>
-      </el-form>
-      <template #footer>
-        <el-button @click="addGroupVisible = false">取 消</el-button>
-        <el-button type="primary" @click="handleAddGroupSubmit">确 定</el-button>
-      </template>
-    </el-dialog>
-
-    <!-- 收藏到分组弹窗 -->
-    <el-dialog v-model="collectDialogVisible" title="收藏到分组" width="420px" :before-close="() => collectDialogVisible = false">
-      <el-form label-width="90px">
-        <el-form-item label="选择分组">
-          <el-select v-model="collectTargetGroup" placeholder="请选择收藏组" style="width: 100%">
-            <el-option v-for="g in customGroups" :key="g.id" :label="g.label" :value="g.id" />
-          </el-select>
-        </el-form-item>
-      </el-form>
-      <template #footer>
-        <el-button @click="collectDialogVisible = false">取 消</el-button>
-        <el-button type="primary" @click="handleCollectSubmit">确 定</el-button>
-      </template>
-    </el-dialog>
-
-    <!-- 查看视频弹窗 -->
-    <el-drawer v-model="videoDialogVisible" title="视频查看" size="680px" :before-close="closeVideo" direction="rtl" class="detail-drawer">
-      <div class="video-player">
-        <div class="video-placeholder">
-          <el-icon :size="56" color="#fff"><VideoPlay /></el-icon>
-          <span class="video-placeholder-text">视频播放区域</span>
-        </div>
-      </div>
-      <div class="video-detail-info">
-        <el-row :gutter="20">
-          <el-col :span="12">
-            <div class="info-item">
-              <span class="info-label">视频名称:</span>
-              <span class="info-value">{{ currentVideo.name || '-' }}</span>
-            </div>
-          </el-col>
-          <el-col :span="12">
-            <div class="info-item">
-              <span class="info-label">视频状态:</span>
-              <span :class="currentVideo.status === '在线' ? 'status-online' : 'status-offline'">{{ currentVideo.status || '-' }}</span>
-            </div>
-          </el-col>
-        </el-row>
-        <el-row :gutter="20">
-          <el-col :span="12">
-            <div class="info-item">
-              <span class="info-label">关联设备:</span>
-              <span class="info-value">{{ currentVideo.device || '-' }}</span>
-            </div>
-          </el-col>
-          <el-col :span="12">
-            <div class="info-item">
-              <span class="info-label">安装位置:</span>
-              <span class="info-value">{{ currentVideo.location || '-' }}</span>
-            </div>
-          </el-col>
-        </el-row>
-      </div>
-    </el-drawer>
+  <div class="group-page" v-loading="loading">
+    <aside><h3>视频分组</h3><el-button link type="primary" @click="selectNode(null)">全部视频({{ rows.length }})</el-button>
+      <el-tree :data="tree" node-key="id" highlight-current default-expand-all @node-click="selectNode"><template #default="{data}">{{ data.label }}({{ data.count }})</template></el-tree>
+    </aside>
+    <main>
+      <el-form :inline="true" @submit.prevent="search"><el-form-item label="视频设备"><el-input v-model="keyword" clearable placeholder="名称/编码" @keyup.enter="search" /></el-form-item><el-form-item><el-button type="primary" @click="search">查询</el-button><el-button @click="refresh">刷新</el-button></el-form-item></el-form>
+      <el-alert v-if="error" :title="error" type="error" :closable="false" />
+      <el-table :data="pageRows" border empty-text="当前分组暂无视频">
+        <el-table-column prop="deviceName" label="视频设备" min-width="170" /><el-table-column prop="deviceCode" label="设备编码" min-width="180" show-overflow-tooltip />
+        <el-table-column prop="districtName" label="所属片区" width="100" /><el-table-column label="易积水点" min-width="140"><template #default="{row}">{{ row.pointName || '未关联' }}</template></el-table-column><el-table-column prop="typeName" label="接入类型" width="100" />
+        <el-table-column label="操作" width="210"><template #default="{row}">
+          <el-button v-hasPermi="['video:device:play']" link type="primary" @click="viewer=row">查看视频</el-button>
+          <el-button v-hasPermi="['drainage:video:favorite']" link :type="row.favorite ? 'warning':'primary'" :loading="pending.has(row.videoId)" @click="favorite(row)">{{ row.favorite ? '取消收藏':'收藏' }}</el-button>
+        </template></el-table-column>
+      </el-table>
+      <pagination v-show="filtered.length" v-model:page="page" v-model:limit="size" :total="filtered.length" />
+    </main>
+    <el-dialog :model-value="Boolean(viewer)" :title="viewer?.deviceName" width="760px" @update:model-value="viewer=null"><div class="viewer"><DrainageLiveTile v-if="viewer" :key="viewer.videoId" :video-id="viewer.videoId" /></div></el-dialog>
   </div>
 </template>
-
 <script setup name="Spfzgl">
-import { ref, computed } from 'vue'
-import { ElMessage, ElMessageBox } from 'element-plus'
-import { Plus, Delete, Star, VideoPlay } from '@element-plus/icons-vue'
-
-// ==================== 视频模拟数据 ====================
-const videoList = ref([
-  { id: 1, name: '沅陵大道积水点摄像头', type: '积水点', status: '在线', device: '液位计 YW-001', location: '沅陵大道与人民路交叉口', collected: true, collectGroup: 'g1' },
-  { id: 2, name: '城北路段积水监测视频', type: '积水点', status: '在线', device: '液位计 YW-002', location: '城北路段低洼处', collected: true, collectGroup: 'g1' },
-  { id: 3, name: '河西片区积水点监控', type: '积水点', status: '离线', device: '液位计 YW-003', location: '河西片区涵洞入口', collected: false, collectGroup: '' },
-  { id: 4, name: '南环路积水监测摄像头', type: '积水点', status: '在线', device: '液位计 YW-004', location: '南环路低洼路段', collected: false, collectGroup: '' },
-  { id: 5, name: '人民路积水点视频', type: '积水点', status: '在线', device: '液位计 YW-005', location: '人民路地下通道入口', collected: false, collectGroup: '' },
-  { id: 6, name: '开发区排水泵站视频', type: '泵站', status: '在线', device: '流量计 LL-001', location: '开发区排水泵站出口', collected: true, collectGroup: 'g2' },
-  { id: 7, name: '城北泵站监控摄像头', type: '泵站', status: '在线', device: '流量计 LL-002', location: '城北泵站进水口', collected: false, collectGroup: '' },
-  { id: 8, name: '河西泵站视频监控', type: '泵站', status: '离线', device: '流量计 LL-003', location: '河西泵站出水口', collected: false, collectGroup: '' },
-  { id: 9, name: '南环泵站监测视频', type: '泵站', status: '在线', device: '流量计 LL-004', location: '南环泵站机房', collected: true, collectGroup: 'g2' },
-  { id: 10, name: '沅陵大道下穿立交监控', type: '下穿立交', status: '在线', device: '液位计 YW-006', location: '沅陵大道下穿段', collected: false, collectGroup: '' },
-  { id: 11, name: '城北下穿立交视频', type: '下穿立交', status: '在线', device: '液位计 YW-007', location: '城北路下穿段', collected: false, collectGroup: '' },
-  { id: 12, name: '河西下穿涵洞监控', type: '下穿立交', status: '离线', device: '液位计 YW-008', location: '河西大道下穿涵洞', collected: false, collectGroup: '' },
-  { id: 13, name: '人民路管网监测视频', type: '管网', status: '在线', device: '流量计 LL-005', location: '人民路主干排水管道', collected: false, collectGroup: '' },
-  { id: 14, name: '城北路段管网监控', type: '管网', status: '在线', device: '流量计 LL-006', location: '城北路段排水管网', collected: false, collectGroup: '' },
-  { id: 15, name: '开发区管网监测摄像头', type: '管网', status: '离线', device: '流量计 LL-007', location: '开发区排水管网出口', collected: false, collectGroup: '' }
-])
-
-// ==================== 分组树 ====================
-const typeCountMap = computed(() => {
-  const map = {}
-  videoList.value.forEach(v => {
-    map[v.type] = (map[v.type] || 0) + 1
-  })
-  return map
-})
-
-const customGroups = ref([
-  { id: 'g1', label: '常用监测组', custom: true, count: 2 },
-  { id: 'g2', label: '泵站重点组', custom: true, count: 2 }
-])
-
-let groupSeq = 3
-
-const treeData = computed(() => {
-  return [
-    { id: 'all', label: '全部视频', count: videoList.value.length },
-    {
-      id: 'type',
-      label: '按类型分组',
-      children: [
-        { id: 'type-积水点', label: '积水点视频', count: typeCountMap.value['积水点'] || 0 },
-        { id: 'type-泵站', label: '泵站视频', count: typeCountMap.value['泵站'] || 0 },
-        { id: 'type-下穿立交', label: '下穿立交视频', count: typeCountMap.value['下穿立交'] || 0 },
-        { id: 'type-管网', label: '管网视频', count: typeCountMap.value['管网'] || 0 }
-      ]
-    },
-    {
-      id: 'fav',
-      label: '我的收藏',
-      children: customGroups.value
-    }
-  ]
-})
-
-const treeProps = { children: 'children', label: 'label' }
-const treeRef = ref(null)
-const currentNodeId = ref('all')
-
-function handleNodeClick(data) {
-  currentNodeId.value = data.id
-  pagination.value.current = 1
-}
-
-// ==================== 搜索 ====================
-const queryParams = ref({ name: '', status: '' })
-
-function handleQuery() {
-  pagination.value.current = 1
-}
-
-function resetQuery() {
-  queryParams.value = { name: '', status: '' }
-  pagination.value.current = 1
-}
-
-// ==================== 过滤 + 分页 ====================
-const filteredList = computed(() => {
-  return videoList.value.filter(item => {
-    const nameMatch = !queryParams.value.name || item.name.includes(queryParams.value.name)
-    const statusMatch = !queryParams.value.status || item.status === queryParams.value.status
-    let groupMatch = true
-    if (currentNodeId.value === 'all') {
-      groupMatch = true
-    } else if (currentNodeId.value.startsWith('type-')) {
-      const typeName = currentNodeId.value.replace('type-', '')
-      groupMatch = item.type === typeName
-    } else if (currentNodeId.value === 'fav' || currentNodeId.value === 'type') {
-      groupMatch = true
-    } else {
-      // 自定义收藏组
-      groupMatch = item.collected && item.collectGroup === currentNodeId.value
-    }
-    return nameMatch && statusMatch && groupMatch
-  })
-})
-
-const pagination = ref({ current: 1, size: 9 })
-
-const pagedList = computed(() => {
-  const start = (pagination.value.current - 1) * pagination.value.size
-  return filteredList.value.slice(start, start + pagination.value.size)
-})
-
-// ==================== 多选 ====================
-const selectedIds = ref([])
-
-function toggleSelect(id) {
-  const idx = selectedIds.value.indexOf(id)
-  if (idx > -1) {
-    selectedIds.value.splice(idx, 1)
-  } else {
-    selectedIds.value.push(id)
-  }
-}
-
-// ==================== 新增收藏组 ====================
-const addGroupVisible = ref(false)
-const addGroupFormRef = ref(null)
-const addGroupForm = ref({ name: '' })
-const addGroupRules = { name: [{ required: true, message: '请输入组名称', trigger: 'blur' }] }
-
-function handleAddGroup() {
-  addGroupForm.value = { name: '' }
-  addGroupVisible.value = true
-}
-
-function handleAddGroupSubmit() {
-  addGroupFormRef.value.validate(valid => {
-    if (!valid) return
-    const newId = 'g' + groupSeq++
-    customGroups.value.push({ id: newId, label: addGroupForm.value.name, custom: true, count: 0 })
-    ElMessage.success('新增收藏组成功')
-    addGroupVisible.value = false
-  })
-}
-
-// ==================== 删除收藏组 ====================
-function handleDeleteGroup(node, data) {
-  ElMessageBox.confirm(`确认删除收藏组"${data.label}"吗?`, '提示', {
-    confirmButtonText: '确定',
-    cancelButtonText: '取消',
-    type: 'warning'
-  }).then(() => {
-    const idx = customGroups.value.findIndex(g => g.id === data.id)
-    if (idx > -1) {
-      // 取消该组下视频的收藏
-      videoList.value.forEach(v => {
-        if (v.collectGroup === data.id) {
-          v.collected = false
-          v.collectGroup = ''
-        }
-      })
-      customGroups.value.splice(idx, 1)
-      if (currentNodeId.value === data.id) {
-        currentNodeId.value = 'all'
-      }
-      ElMessage.success('删除成功')
-    }
-  }).catch(() => {})
-}
-
-// ==================== 收藏 ====================
-const collectDialogVisible = ref(false)
-const collectTargetGroup = ref('')
-
-function handleCollectBatch() {
-  if (customGroups.value.length === 0) {
-    ElMessage.warning('请先创建收藏组')
-    return
-  }
-  collectTargetGroup.value = customGroups.value[0].id
-  collectDialogVisible.value = true
-}
-
-function handleCollectSubmit() {
-  if (!collectTargetGroup.value) {
-    ElMessage.warning('请选择收藏组')
-    return
-  }
-  selectedIds.value.forEach(id => {
-    const v = videoList.value.find(item => item.id === id)
-    if (v) {
-      v.collected = true
-      v.collectGroup = collectTargetGroup.value
-    }
-  })
-  // 更新收藏组计数
-  updateGroupCounts()
-  ElMessage.success('收藏成功')
-  selectedIds.value = []
-  collectDialogVisible.value = false
-}
-
-function handleToggleCollect(item) {
-  if (item.collected) {
-    item.collected = false
-    item.collectGroup = ''
-    ElMessage.success('已取消收藏')
-  } else {
-    if (customGroups.value.length === 0) {
-      ElMessage.warning('请先创建收藏组')
-      return
-    }
-    // 默认收藏到第一个组
-    item.collected = true
-    item.collectGroup = customGroups.value[0].id
-    ElMessage.success('已收藏到「' + customGroups.value[0].label + '」')
-  }
-  updateGroupCounts()
-}
-
-function updateGroupCounts() {
-  customGroups.value.forEach(g => {
-    g.count = videoList.value.filter(v => v.collected && v.collectGroup === g.id).length
-  })
-}
-
-// ==================== 查看视频 ====================
-const videoDialogVisible = ref(false)
-function closeVideo(done) {
-  videoDialogVisible.value = false
-  done?.()
-}
-const currentVideo = ref({})
-
-function handleViewVideo(item) {
-  currentVideo.value = { ...item }
-  videoDialogVisible.value = true
-}
+import { ref, computed, onDeactivated } from 'vue'
+import { ElMessage } from 'element-plus'
+import { getDrainageVideoTree, getDrainageVideoMap, favoriteDrainageVideo } from '@/api/drainageVideo'
+import { filterGroupVideos } from '@/utils/drainageVideoModel'
+import DrainageLiveTile from './DrainageLiveTile.vue'
+const rows=ref([]), tree=ref([]), node=ref(null), keyword=ref(''), applied=ref(''), page=ref(1), size=ref(10), loading=ref(false), error=ref(''), viewer=ref(null), pending=ref(new Set())
+const filtered=computed(()=>filterGroupVideos(rows.value,node.value,applied.value))
+const pageRows=computed(()=>filtered.value.slice((page.value-1)*size.value,page.value*size.value))
+function selectNode(value){node.value=value;page.value=1}
+function search(){applied.value=keyword.value;page.value=1}
+function findNode(nodes,id){for(const item of nodes){if(item.id===id)return item;const child=findNode(item.children || [],id);if(child)return child}return null}
+async function refresh(){
+  loading.value=true;error.value=''
+  try{const [resources,groups]=await Promise.all([getDrainageVideoMap(),getDrainageVideoTree()]);rows.value=resources.data || [];tree.value=groups.data || [];if(node.value)node.value=findNode(tree.value,node.value.id);page.value=Math.min(page.value,Math.max(1,Math.ceil(filtered.value.length/size.value)))}
+  catch(e){rows.value=[];tree.value=[];node.value=null;error.value=e.message || '视频分组加载失败'}finally{loading.value=false}
+}
+async function favorite(row){
+  if(pending.value.has(row.videoId))return
+  pending.value.add(row.videoId)
+  try{await favoriteDrainageVideo(row.videoId,!row.favorite);ElMessage.success(row.favorite?'已取消收藏':'已收藏');await refresh()}catch(e){ElMessage.error(e.message || '收藏操作失败')}finally{pending.value.delete(row.videoId)}
+}
+onDeactivated(()=>{viewer.value=null})
+refresh()
 </script>
-
-<style scoped>
-.spfzgl-container {
-  display: flex;
-  height: calc(100vh - 84px);
-  background: #f5f7fa;
-}
-
-.left-panel {
-  width: 250px;
-  min-width: 250px;
-  background: #fff;
-  border-right: 1px solid #e4e7ed;
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-}
-
-.panel-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 14px 16px;
-  border-bottom: 1px solid #e4e7ed;
-}
-
-.panel-title {
-  font-size: 15px;
-  font-weight: 600;
-  color: #303133;
-}
-
-.left-panel :deep(.el-tree) {
-  flex: 1;
-  overflow-y: auto;
-  padding: 8px 0;
-}
-
-.tree-node {
-  display: flex;
-  align-items: center;
-  flex: 1;
-  font-size: 14px;
-}
-
-.tree-node-label {
-  flex: 1;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.tree-node-count {
-  color: #909399;
-  font-size: 12px;
-  margin-left: 4px;
-}
-
-.tree-node-actions {
-  margin-left: 8px;
-  display: none;
-}
-
-.tree-node:hover .tree-node-actions {
-  display: inline-flex;
-}
-
-.action-icon {
-  color: #f56c6c;
-  cursor: pointer;
-  font-size: 14px;
-}
-
-.action-icon:hover {
-  color: #e04040;
-}
-
-.right-panel {
-  flex: 1;
-  padding: 16px;
-  overflow-y: auto;
-}
-
-.search-form {
-  margin-bottom: 12px;
-  background: #fff;
-  padding: 16px 16px 0;
-  border-radius: 4px;
-}
-
-.toolbar {
-  margin-bottom: 12px;
-}
-
-.video-card {
-  background: #fff;
-  border-radius: 6px;
-  overflow: hidden;
-  margin-bottom: 16px;
-  border: 2px solid transparent;
-  cursor: pointer;
-  transition: border-color 0.2s, box-shadow 0.2s;
-}
-
-.video-card:hover {
-  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
-}
-
-.video-card.is-selected {
-  border-color: #409eff;
-}
-
-.video-thumb {
-  width: 100%;
-  height: 200px;
-  background: #000;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-.video-info {
-  padding: 12px;
-}
-
-.video-name {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  margin-bottom: 8px;
-}
-
-.name-text {
-  font-size: 14px;
-  font-weight: 500;
-  color: #303133;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-  flex: 1;
-}
-
-.video-meta {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  margin-bottom: 6px;
-}
-
-.status-tag {
-  display: inline-block;
-  padding: 1px 8px;
-  border-radius: 3px;
-  font-size: 12px;
-  font-weight: 500;
-}
-
-.status-tag.online {
-  background: #e1f3d8;
-  color: #67c23a;
-}
-
-.status-tag.offline {
-  background: #fde2e2;
-  color: #f56c6c;
-}
-
-.meta-text {
-  font-size: 13px;
-  color: #909399;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.video-actions {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  margin-top: 8px;
-  padding-top: 8px;
-  border-top: 1px solid #f0f0f0;
-}
-
-.pagination-wrap {
-  display: flex;
-  justify-content: flex-end;
-  margin-top: 16px;
-}
-
-/* 查看视频弹窗 */
-.video-player {
-  margin-bottom: 20px;
-}
-
-.video-placeholder {
-  width: 100%;
-  height: 320px;
-  background: #000;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  border-radius: 4px;
-}
-
-.video-placeholder-text {
-  color: #999;
-  margin-top: 10px;
-  font-size: 14px;
-}
-
-.video-detail-info {
-  padding: 0 4px;
-}
-
-.info-item {
-  display: flex;
-  align-items: center;
-  margin-bottom: 12px;
-  line-height: 32px;
-}
-
-.info-label {
-  color: #909399;
-  font-size: 14px;
-  white-space: nowrap;
-}
-
-.info-value {
-  color: #303133;
-  font-size: 14px;
-}
-
-.status-online {
-  color: #67c23a;
-  font-weight: bold;
-}
-
-.status-offline {
-  color: #f56c6c;
-  font-weight: bold;
-}
-</style>
+<style scoped>.group-page{display:flex;gap:18px;padding:20px;min-height:calc(100vh - 100px)}aside{width:260px;flex-shrink:0;overflow:auto;border-right:1px solid #eee}main{flex:1;min-width:0}.viewer{height:420px}.el-alert{margin-bottom:14px}</style>

+ 53 - 428
src/views/subSystem/drainage/spjc/spgngl.vue

@@ -1,439 +1,64 @@
 <template>
   <div class="app-container">
-    <!-- 搜索区域 -->
-    <el-form :model="queryParams" ref="queryRef" :inline="true" class="search-form">
-      <el-form-item label="设备名称">
-        <el-input v-model="queryParams.deviceName" placeholder="请输入" clearable style="width: 180px" />
-      </el-form-item>
-      <el-form-item label="所属区域">
-        <el-select v-model="queryParams.area" placeholder="请选择" clearable style="width: 180px">
-          <el-option v-for="item in areaOptions" :key="item" :label="item" :value="item" />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="设备类型">
-        <el-select v-model="queryParams.deviceType" placeholder="请选择" clearable style="width: 180px">
-          <el-option v-for="item in deviceTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="关联状态">
-        <el-select v-model="queryParams.status" placeholder="请选择" clearable style="width: 180px">
-          <el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
-        </el-select>
-      </el-form-item>
-      <el-form-item>
-        <el-button type="primary" @click="handleQuery">搜索</el-button>
-        <el-button @click="resetQuery">重置</el-button>
-      </el-form-item>
+    <el-form :inline="true" @submit.prevent="search">
+      <el-form-item label="视频设备"><el-input v-model="query.query" clearable placeholder="设备名称/编码" @keyup.enter="search" /></el-form-item>
+      <el-form-item label="所属片区"><el-select v-model="query.district" clearable style="width:160px"><el-option v-for="d in districts" :key="d.value" :label="d.label" :value="d.value" /></el-select></el-form-item>
+      <el-form-item label="关联状态"><el-select v-model="query.unbound" clearable style="width:160px"><el-option label="已关联" :value="false" /><el-option label="未关联" :value="true" /></el-select></el-form-item>
+      <el-form-item><el-button type="primary" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
     </el-form>
-
-    <!-- 操作按钮区域 -->
-    <div class="toolbar">
-      <el-button type="primary" @click="handleAdd">新增关联</el-button>
-      <el-button type="danger" plain :disabled="selectedRows.length === 0" @click="handleBatchUnbind">批量解除关联</el-button>
-    </div>
-
-    <!-- 数据表格 -->
-    <el-table :data="pagedList" border style="width: 100%" @selection-change="handleSelectionChange">
-      <el-table-column type="selection" width="55" align="center" />
-      <el-table-column label="序号" type="index" width="60" align="center" />
-      <el-table-column label="设备编号" prop="deviceNo" min-width="130" />
-      <el-table-column label="设备名称" prop="deviceName" min-width="140" />
-      <el-table-column label="设备类型" prop="deviceType" min-width="110">
-        <template #default="{ row }">
-          <span>{{ deviceTypeText(row.deviceType) }}</span>
-        </template>
-      </el-table-column>
-      <el-table-column label="安装位置" prop="location" min-width="150" />
-      <el-table-column label="所属区域" prop="area" min-width="100" />
-      <el-table-column label="关联视频" prop="videoName" min-width="150">
-        <template #default="{ row }">
-          <span>{{ row.videoName || '-' }}</span>
-        </template>
-      </el-table-column>
-      <el-table-column label="关联状态" prop="status" min-width="100" align="center">
-        <template #default="{ row }">
-          <el-tag :type="row.status === '1' ? 'success' : 'info'">{{ row.status === '1' ? '已关联' : '未关联' }}</el-tag>
-        </template>
-      </el-table-column>
-      <el-table-column label="操作" width="220" align="center" fixed="right">
-        <template #default="{ row }">
-          <el-button link type="primary" @click="handleBind(row)" v-if="row.status !== '1'">关联视频</el-button>
-          <el-button link type="danger" @click="handleUnbind(row)" v-if="row.status === '1'">解除关联</el-button>
-          <el-button link type="primary" @click="handleViewVideo(row)" v-if="row.status === '1'">查看视频</el-button>
-        </template>
-      </el-table-column>
+    <el-alert v-if="error" :title="error" type="error" show-icon :closable="false" />
+    <el-table v-loading="loading" :data="rows" border empty-text="暂无排水视频设备,请先在视频设备管理中纳管">
+      <el-table-column prop="deviceCode" label="设备编码" min-width="180" show-overflow-tooltip /><el-table-column prop="deviceName" label="设备名称" min-width="160" />
+      <el-table-column prop="districtName" label="所属片区" width="100" /><el-table-column prop="location" label="安装位置" min-width="180" show-overflow-tooltip />
+      <el-table-column prop="typeName" label="接入类型" width="110" /><el-table-column label="关联易积水点" min-width="160"><template #default="{ row }">{{ row.pointName || '未关联' }}</template></el-table-column>
+      <el-table-column label="操作" width="240"><template #default="{ row }">
+        <el-button v-hasPermi="['drainage:video:bind']" type="primary" link :disabled="saving || Boolean(row.pointId)" @click="openBind(row)">关联点位</el-button>
+        <el-button v-if="row.pointId" v-hasPermi="['drainage:video:bind']" type="danger" link :disabled="saving" @click="unbind(row)">解除关联</el-button>
+        <el-button v-hasPermi="['video:device:play']" type="primary" link @click="viewer = row">查看视频</el-button>
+      </template></el-table-column>
     </el-table>
-
-    <!-- 分页 -->
-    <div class="pagination-wrap">
-      <el-pagination
-        v-model:current-page="pagination.current"
-        v-model:page-size="pagination.size"
-        :page-sizes="[10, 20, 50]"
-        :total="filteredList.length"
-        layout="total, sizes, prev, pager, next, jumper"
-        background
-      />
-    </div>
-
-    <!-- 新增关联弹窗 -->
-    <el-dialog v-model="bindDialogVisible" title="设备关联视频" width="560px" :before-close="handleBindClose">
-      <el-form ref="bindFormRef" :model="bindForm" :rules="bindRules" label-width="100px">
-        <el-form-item label="监测设备" prop="deviceId">
-          <el-select v-model="bindForm.deviceId" placeholder="请选择监测设备" style="width: 100%" filterable>
-            <el-option v-for="item in unboundDevices" :key="item.id" :label="item.deviceName + '(' + item.deviceNo + ')'" :value="item.id" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="关联视频" prop="videoId">
-          <el-select v-model="bindForm.videoId" placeholder="请选择关联视频" style="width: 100%" filterable>
-            <el-option v-for="item in videoList" :key="item.id" :label="item.name" :value="item.id" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="关联说明" prop="remark">
-          <el-input v-model="bindForm.remark" type="textarea" :rows="3" placeholder="请输入关联说明" />
-        </el-form-item>
+    <pagination v-show="total > 0" v-model:page="query.pageNum" v-model:limit="query.pageSize" :total="total" @pagination="load" />
+    <el-dialog v-model="bindingVisible" title="排水视频关联易积水点" width="520px" :close-on-click-modal="!saving" :show-close="!saving" :close-on-press-escape="!saving">
+      <el-form label-width="100px"><el-form-item label="视频设备">{{ bindingRow?.deviceName }}</el-form-item>
+        <el-form-item label="易积水点" required><el-select v-model="pointId" filterable style="width:100%" placeholder="请选择基础数据中的易积水点" :disabled="saving"><el-option v-for="p in points" :key="p.id" :value="p.id" :label="`${p.name}(${p.area || '未设置区域'})`" /></el-select></el-form-item>
       </el-form>
-      <template #footer>
-        <el-button @click="handleBindClose">取 消</el-button>
-        <el-button type="primary" @click="handleBindSubmit">确 定</el-button>
-      </template>
+      <el-alert title="一个点位可关联多个视频;每个视频只能关联一个点位。原有监测设备关联会保留。" type="info" :closable="false" />
+      <template #footer><el-button :disabled="saving" @click="bindingVisible=false">取消</el-button><el-button type="primary" :loading="saving" :disabled="!pointId" @click="bind">保存</el-button></template>
     </el-dialog>
-
-    <!-- 查看视频弹窗 -->
-    <el-drawer v-model="videoDialogVisible" title="关联视频查看" size="700px" :before-close="handleVideoClose" class="detail-drawer" direction="rtl">
-      <div class="video-player">
-        <div class="video-placeholder">
-          <el-icon :size="48" color="#fff"><VideoPlay /></el-icon>
-          <span class="video-placeholder-text">视频播放区域</span>
-        </div>
-      </div>
-      <div class="video-info">
-        <el-row :gutter="20">
-          <el-col :span="12">
-            <div class="info-item">
-              <span class="info-label">视频名称:</span>
-              <span class="info-value">{{ currentVideo.videoName || '-' }}</span>
-            </div>
-          </el-col>
-          <el-col :span="12">
-            <div class="info-item">
-              <span class="info-label">关联设备:</span>
-              <span class="info-value">{{ currentVideo.deviceName || '-' }}</span>
-            </div>
-          </el-col>
-        </el-row>
-        <el-row :gutter="20">
-          <el-col :span="12">
-            <div class="info-item">
-              <span class="info-label">安装位置:</span>
-              <span class="info-value">{{ currentVideo.location || '-' }}</span>
-            </div>
-          </el-col>
-          <el-col :span="12">
-            <div class="info-item">
-              <span class="info-label">视频状态:</span>
-              <span class="info-value">
-                <span :class="currentVideo.videoStatus === '在线' ? 'status-online' : 'status-offline'">
-                  {{ currentVideo.videoStatus || '-' }}
-                </span>
-              </span>
-            </div>
-          </el-col>
-        </el-row>
-        <el-row :gutter="20">
-          <el-col :span="12">
-            <div class="info-item">
-              <span class="info-label">最近记录时间:</span>
-              <span class="info-value">{{ currentVideo.lastRecordTime || '-' }}</span>
-            </div>
-          </el-col>
-        </el-row>
-      </div>
-    </el-drawer>
+    <el-dialog :model-value="Boolean(viewer)" :title="viewer?.deviceName || '实时视频'" width="760px" @update:model-value="viewer=null"><div class="viewer"><DrainageLiveTile v-if="viewer" :key="viewer.videoId" :video-id="viewer.videoId" /></div></el-dialog>
   </div>
 </template>
-
 <script setup name="Spgngl">
-import { ref, computed } from 'vue'
+import { reactive, ref, onDeactivated } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { VideoPlay } from '@element-plus/icons-vue'
-
-// ==================== 选项配置 ====================
-const areaOptions = ['沅陵大道', '城北路段', '河西片区', '人民路', '南环路', '开发区']
-
-const deviceTypeOptions = [
-  { label: '液位计', value: '1' },
-  { label: '流量计', value: '2' },
-  { label: '雨量计', value: '3' },
-  { label: '水质监测仪', value: '4' },
-  { label: '视频监控', value: '5' }
-]
-
-const statusOptions = [
-  { label: '已关联', value: '1' },
-  { label: '未关联', value: '0' }
-]
-
-function deviceTypeText(val) {
-  const map = { '1': '液位计', '2': '流量计', '3': '雨量计', '4': '水质监测仪', '5': '视频监控' }
-  return map[val] || '-'
-}
-
-// ==================== 视频列表 ====================
-const videoList = ref([
-  { id: 1, name: '沅陵大道与人民路交叉口摄像头' },
-  { id: 2, name: '城北路段管网监控摄像头' },
-  { id: 3, name: '河西片区排水口监控' },
-  { id: 4, name: '人民路地下管网监测视频' },
-  { id: 5, name: '南环路污水处理厂入口监控' },
-  { id: 6, name: '开发区排水泵站视频监控' },
-  { id: 7, name: '北环路河道排放口监控' },
-  { id: 8, name: '沅陵大道排水井监测视频' }
-])
-
-// ==================== 搜索参数 ====================
-const queryParams = ref({
-  deviceName: '',
-  area: '',
-  deviceType: '',
-  status: ''
-})
-
-// ==================== 表格数据 ====================
-const tableData = ref([
-  { id: 1, deviceNo: 'DEV-2025-001', deviceName: '城北液位监测仪', deviceType: '1', location: '城北路段排水井A3号', area: '城北路段', videoId: 2, videoName: '城北路段管网监控摄像头', status: '1', remark: '' },
-  { id: 2, deviceNo: 'DEV-2025-002', deviceName: '河西流量监测计', deviceType: '2', location: '河西大道排水管道B7号', area: '河西片区', videoId: 3, videoName: '河西片区排水口监控', status: '1', remark: '' },
-  { id: 3, deviceNo: 'DEV-2025-003', deviceName: '南区水质监测仪', deviceType: '4', location: '南环路污水处理厂入口', area: '南环路', videoId: 5, videoName: '南环路污水处理厂入口监控', status: '1', remark: '' },
-  { id: 4, deviceNo: 'DEV-2025-004', deviceName: '东区雨量计', deviceType: '3', location: '东环路市政大楼楼顶', area: '开发区', videoId: null, videoName: '', status: '0', remark: '' },
-  { id: 5, deviceNo: 'DEV-2025-005', deviceName: '人民路液位计', deviceType: '1', location: '人民路地下管网C2号', area: '人民路', videoId: 4, videoName: '人民路地下管网监测视频', status: '1', remark: '' },
-  { id: 6, deviceNo: 'DEV-2025-006', deviceName: '开发区流量计', deviceType: '2', location: '开发区排水泵站出口', area: '开发区', videoId: null, videoName: '', status: '0', remark: '' },
-  { id: 7, deviceNo: 'DEV-2025-007', deviceName: '沅陵大道雨量计', deviceType: '3', location: '沅陵大道气象站', area: '沅陵大道', videoId: 1, videoName: '沅陵大道与人民路交叉口摄像头', status: '1', remark: '' },
-  { id: 8, deviceNo: 'DEV-2025-008', deviceName: '河西水质监测仪', deviceType: '4', location: '河西片区河道排放口', area: '河西片区', videoId: null, videoName: '', status: '0', remark: '' },
-  { id: 9, deviceNo: 'DEV-2025-009', deviceName: '城北视频监控', deviceType: '5', location: '城北路段排水泵站', area: '城北路段', videoId: null, videoName: '', status: '0', remark: '' },
-  { id: 10, deviceNo: 'DEV-2025-010', deviceName: '南环路液位计', deviceType: '1', location: '南环路排水井D5号', area: '南环路', videoId: null, videoName: '', status: '0', remark: '' },
-  { id: 11, deviceNo: 'DEV-2025-011', deviceName: '人民路流量计', deviceType: '2', location: '人民路主干排水管道', area: '人民路', videoId: 4, videoName: '人民路地下管网监测视频', status: '1', remark: '' },
-  { id: 12, deviceNo: 'DEV-2025-012', deviceName: '沅陵大道液位计', deviceType: '1', location: '沅陵大道排水井E2号', area: '沅陵大道', videoId: 8, videoName: '沅陵大道排水井监测视频', status: '1', remark: '' }
-])
-
-// 多选
-const selectedRows = ref([])
-function handleSelectionChange(selection) {
-  selectedRows.value = selection
-}
-
-// 过滤后的列表
-const filteredList = computed(() => {
-  return tableData.value.filter(item => {
-    const nameMatch = !queryParams.value.deviceName || item.deviceName.includes(queryParams.value.deviceName)
-    const areaMatch = !queryParams.value.area || item.area === queryParams.value.area
-    const typeMatch = !queryParams.value.deviceType || item.deviceType === queryParams.value.deviceType
-    const statusMatch = !queryParams.value.status || item.status === queryParams.value.status
-    return nameMatch && areaMatch && typeMatch && statusMatch
-  })
-})
-
-// 分页
-const pagination = ref({
-  current: 1,
-  size: 10
-})
-
-const pagedList = computed(() => {
-  const start = (pagination.value.current - 1) * pagination.value.size
-  const end = start + pagination.value.size
-  return filteredList.value.slice(start, end)
-})
-
-// 搜索
-function handleQuery() {
-  pagination.value.current = 1
-}
-
-// 重置
-function resetQuery() {
-  queryParams.value = {
-    deviceName: '',
-    area: '',
-    deviceType: '',
-    status: ''
-  }
-  pagination.value.current = 1
-}
-
-// ==================== 新增关联弹窗 ====================
-const bindDialogVisible = ref(false)
-const bindFormRef = ref(null)
-
-const unboundDevices = computed(() => {
-  return tableData.value.filter(item => item.status === '0')
-})
-
-const bindForm = ref({
-  deviceId: null,
-  videoId: null,
-  remark: ''
-})
-
-const bindRules = {
-  deviceId: [{ required: true, message: '请选择监测设备', trigger: 'change' }],
-  videoId: [{ required: true, message: '请选择关联视频', trigger: 'change' }]
-}
-
-function handleAdd() {
-  bindForm.value = { deviceId: null, videoId: null, remark: '' }
-  bindDialogVisible.value = true
-}
-
-function handleBind(row) {
-  bindForm.value = { deviceId: row.id, videoId: null, remark: '' }
-  bindDialogVisible.value = true
-}
-
-function handleBindClose() {
-  bindDialogVisible.value = false
-}
-
-function handleBindSubmit() {
-  bindFormRef.value.validate(valid => {
-    if (!valid) return
-    const device = tableData.value.find(item => item.id === bindForm.value.deviceId)
-    const video = videoList.value.find(item => item.id === bindForm.value.videoId)
-    if (device && video) {
-      device.videoId = video.id
-      device.videoName = video.name
-      device.status = '1'
-      device.remark = bindForm.value.remark
-      ElMessage.success('关联成功')
-    }
-    bindDialogVisible.value = false
-  })
-}
-
-// ==================== 解除关联 ====================
-function handleUnbind(row) {
-  ElMessageBox.confirm(`确认解除设备"${row.deviceName}"与视频"${row.videoName}"的关联吗?`, '提示', {
-    confirmButtonText: '确定',
-    cancelButtonText: '取消',
-    type: 'warning'
-  }).then(() => {
-    row.videoId = null
-    row.videoName = ''
-    row.status = '0'
-    row.remark = ''
-    ElMessage.success('解除关联成功')
-  }).catch(() => {})
-}
-
-function handleBatchUnbind() {
-  const boundRows = selectedRows.value.filter(item => item.status === '1')
-  if (boundRows.length === 0) {
-    ElMessage.warning('选中的设备中没有已关联的记录')
-    return
-  }
-  ElMessageBox.confirm(`确认解除选中的 ${boundRows.length} 条关联吗?`, '提示', {
-    confirmButtonText: '确定',
-    cancelButtonText: '取消',
-    type: 'warning'
-  }).then(() => {
-    boundRows.forEach(row => {
-      row.videoId = null
-      row.videoName = ''
-      row.status = '0'
-      row.remark = ''
-    })
-    ElMessage.success('批量解除关联成功')
-  }).catch(() => {})
-}
-
-// ==================== 查看视频弹窗 ====================
-const videoDialogVisible = ref(false)
-const currentVideo = ref({})
-
-function handleViewVideo(row) {
-  currentVideo.value = {
-    videoName: row.videoName,
-    deviceName: row.deviceName,
-    location: row.location,
-    videoStatus: Math.random() > 0.3 ? '在线' : '离线',
-    lastRecordTime: '2025-12-15 14:32:18'
-  }
-  videoDialogVisible.value = true
-}
-
-function handleVideoClose() {
-  videoDialogVisible.value = false
-}
+import { listDrainageVideos, getDrainageVideoPoints, associateDrainageVideo } from '@/api/drainageVideo'
+import { districts } from '@/utils/drainageVideoModel'
+import DrainageLiveTile from './DrainageLiveTile.vue'
+const query = reactive({ pageNum:1, pageSize:10, query:'', district:undefined, unbound:undefined })
+const rows=ref([]), total=ref(0), loading=ref(false), error=ref(''), viewer=ref(null), points=ref([])
+const bindingVisible=ref(false), bindingRow=ref(null), pointId=ref(''), saving=ref(false)
+let generation=0
+async function load() {
+  const version=++generation; loading.value=true; error.value=''
+  try { const result=await listDrainageVideos({ ...query, district: query.district === '' ? undefined : query.district, unbound: query.unbound === '' ? undefined : query.unbound }); if(version===generation){rows.value=result.rows || [];total.value=result.total || 0} }
+  catch(e) { if(version===generation){error.value=e.message || '视频设备加载失败';rows.value=[];total.value=0} }
+  finally { if(version===generation)loading.value=false }
+}
+function search(){query.pageNum=1;load()}
+function reset(){Object.assign(query,{pageNum:1,query:'',district:undefined,unbound:undefined});load()}
+async function openBind(row) {try { points.value=(await getDrainageVideoPoints()).data || [];bindingRow.value=row;pointId.value='';bindingVisible.value=true } catch { ElMessage.error('易积水点加载失败,请重试') }}
+async function bind(){
+  if(saving.value||!pointId.value)return
+  saving.value=true
+  try { await associateDrainageVideo(bindingRow.value.videoId,pointId.value);bindingVisible.value=false;ElMessage.success('关联成功');await load() }
+  catch(e){ElMessage.error(e.message || '关联失败')}finally{saving.value=false}
+}
+async function unbind(row){
+  try { await ElMessageBox.confirm(`解除“${row.deviceName}”与“${row.pointName}”的关联?`, '解除关联',{type:'warning'}) } catch{return}
+  saving.value=true
+  try{await associateDrainageVideo(row.videoId,null);ElMessage.success('已解除关联');await load()}catch(e){ElMessage.error(e.message || '解除关联失败')}finally{saving.value=false}
+}
+onDeactivated(()=>{viewer.value=null})
+load()
 </script>
-
-<style scoped>
-.app-container {
-  padding: 20px;
-}
-
-.search-form {
-  margin-bottom: 16px;
-}
-
-.toolbar {
-  margin-bottom: 16px;
-}
-
-.pagination-wrap {
-  display: flex;
-  justify-content: flex-end;
-  margin-top: 16px;
-}
-
-.video-player {
-  margin-bottom: 20px;
-}
-
-.video-placeholder {
-  width: 100%;
-  height: 300px;
-  background: #000;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  border-radius: 4px;
-}
-
-.video-placeholder-text {
-  color: #999;
-  margin-top: 10px;
-  font-size: 14px;
-}
-
-.video-info {
-  padding: 0 4px;
-}
-
-.info-item {
-  display: flex;
-  align-items: center;
-  margin-bottom: 12px;
-  line-height: 32px;
-}
-
-.info-label {
-  color: #909399;
-  font-size: 14px;
-  white-space: nowrap;
-}
-
-.info-value {
-  color: #303133;
-  font-size: 14px;
-}
-
-.status-online {
-  color: #67c23a;
-  font-weight: bold;
-}
-
-.status-offline {
-  color: #f56c6c;
-  font-weight: bold;
-}
-</style>
+<style scoped>.viewer{height:420px}.el-alert{margin-bottom:14px}</style>

+ 24 - 336
src/views/subSystem/drainage/spjc/spplgl.vue

@@ -1,347 +1,35 @@
 <template>
-  <div class="spplgl-container">
-    <!-- 顶部工具栏 -->
+  <div class="batch-page" v-loading="loading">
     <div class="toolbar">
-      <div class="toolbar-left">
-        <span class="toolbar-label">视图模式:</span>
-        <el-button-group>
-          <el-button :type="gridMode === 1 ? 'primary' : 'default'" @click="gridMode = 1">1宫格</el-button>
-          <el-button :type="gridMode === 4 ? 'primary' : 'default'" @click="gridMode = 4">4宫格</el-button>
-          <el-button :type="gridMode === 9 ? 'primary' : 'default'" @click="gridMode = 9">9宫格</el-button>
-        </el-button-group>
-        <span class="toolbar-label" style="margin-left: 20px;">监控视频:</span>
-        <el-select
-          v-model="selectedVideoIds"
-          multiple
-          collapse-tags
-          collapse-tags-tooltip
-          placeholder="选择监控视频"
-          style="width: 340px"
-        >
-          <el-option v-for="v in allVideos" :key="v.id" :label="v.name" :value="v.id" />
-        </el-select>
-      </div>
-      <div class="toolbar-right">
-        <el-button @click="handleFullscreen" :icon="FullScreen">全屏</el-button>
-        <el-button :disabled="currentPage <= 1" @click="currentPage--">上一页</el-button>
-        <span class="page-info">{{ currentPage }} / {{ totalPages }}</span>
-        <el-button :disabled="currentPage >= totalPages" @click="currentPage++">下一页</el-button>
-      </div>
+      <span>视图模式</span><el-radio-group v-model="mode"><el-radio-button :label="1">1宫格</el-radio-button><el-radio-button :label="4">4宫格</el-radio-button><el-radio-button :label="9">9宫格</el-radio-button></el-radio-group>
+      <el-select v-model="ids" multiple filterable collapse-tags collapse-tags-tooltip placeholder="批量选择监测视频" style="width:320px"><el-option v-for="row in rows" :key="row.videoId" :value="row.videoId" :label="`${row.deviceName}(${row.districtName})`" /></el-select>
+      <el-button @click="ids=rows.map(row=>row.videoId)">选择全部</el-button><el-button @click="ids=[]">清空</el-button><el-button @click="load">刷新</el-button><el-button @click="fullscreen">全屏</el-button>
     </div>
-
-    <!-- 视频宫格区域 -->
-    <div class="grid-area" ref="gridAreaRef">
-      <div
-        class="video-grid"
-        :style="gridStyle"
-      >
-        <div
-          v-for="(slot, idx) in currentSlots"
-          :key="slot ? slot.id : 'empty-' + idx"
-          class="grid-cell"
-          @dblclick="handleDblClick(slot)"
-        >
-          <template v-if="slot">
-            <div class="cell-player">
-              <el-icon :size="gridMode <= 4 ? 48 : 32" color="#fff"><VideoPlay /></el-icon>
-              <span class="cell-player-name">{{ slot.name }}</span>
-            </div>
-            <div class="cell-bar">
-              <span class="cell-bar-name">{{ slot.name }}</span>
-              <span :class="['cell-bar-status', slot.status === '在线' ? 'online' : 'offline']">{{ slot.status }}</span>
-              <span class="cell-bar-time">{{ currentTime }}</span>
-            </div>
-            <span class="cell-index">{{ idx + 1 }}</span>
-          </template>
-          <template v-else>
-            <div class="cell-player cell-empty">
-              <el-icon :size="32" color="#555"><VideoPlay /></el-icon>
-              <span class="cell-empty-text">未选择视频</span>
-            </div>
-            <span class="cell-index">{{ idx + 1 }}</span>
-          </template>
+    <el-alert v-if="error" :title="error" type="error" :closable="false" /><el-alert v-if="!canPlay" title="当前账号无视频实时播放权限,请联系管理员授权。" type="warning" :closable="false" />
+    <div ref="area" class="grid-area">
+      <div class="video-grid" :style="{gridTemplateColumns:`repeat(${Math.sqrt(mode)},minmax(0,1fr))`,gridTemplateRows:`repeat(${Math.sqrt(mode)},minmax(0,1fr))`}">
+        <div v-for="(row,index) in view.slots" :key="row?.videoId || `empty-${index}`" class="cell">
+          <template v-if="row"><DrainageLiveTile :video-id="row.videoId" :enabled="canPlay" /><div class="bar">{{ row.deviceName }} · {{ row.pointName || '未关联点位' }}</div></template><div v-else class="empty">{{ ids.length ? '本页无视频':'请选择需要监测的视频' }}</div>
         </div>
       </div>
     </div>
+    <div class="pager"><span>已选 {{ view.total }} 个视频 · 当前 {{ view.current }}/{{ view.pages }} 页</span><el-button :disabled="page<=1" @click="page--">上一页</el-button><el-button :disabled="page>=view.pages" @click="page++">下一页</el-button></div>
   </div>
 </template>
-
 <script setup name="Spplgl">
-import { ref, computed, onMounted, onUnmounted } from 'vue'
-import { FullScreen, VideoPlay } from '@element-plus/icons-vue'
+import { ref, computed, watch } from 'vue'
 import { ElMessage } from 'element-plus'
-
-// ==================== 模拟视频数据 ====================
-const allVideos = ref([
-  { id: 1, name: '沅陵大道积水点摄像头', status: '在线', type: '积水点' },
-  { id: 2, name: '城北路段积水监测视频', status: '在线', type: '积水点' },
-  { id: 3, name: '河西片区积水点监控', status: '离线', type: '积水点' },
-  { id: 4, name: '南环路积水监测摄像头', status: '在线', type: '积水点' },
-  { id: 5, name: '人民路积水点视频', status: '在线', type: '积水点' },
-  { id: 6, name: '开发区排水泵站视频', status: '在线', type: '泵站' },
-  { id: 7, name: '城北泵站监控摄像头', status: '在线', type: '泵站' },
-  { id: 8, name: '河西泵站视频监控', status: '离线', type: '泵站' },
-  { id: 9, name: '沅陵大道下穿立交监控', status: '在线', type: '下穿立交' },
-  { id: 10, name: '城北下穿立交视频', status: '在线', type: '下穿立交' },
-  { id: 11, name: '人民路管网监测视频', status: '在线', type: '管网' },
-  { id: 12, name: '城北路段管网监控', status: '离线', type: '管网' }
-])
-
-// 默认选中前4个
-const selectedVideoIds = ref([1, 2, 3, 4])
-
-// ==================== 宫格模式 ====================
-const gridMode = ref(4)
-const currentPage = ref(1)
-const gridAreaRef = ref(null)
-
-const selectedVideos = computed(() => {
-  return selectedVideoIds.value
-    .map(id => allVideos.value.find(v => v.id === id))
-    .filter(Boolean)
-})
-
-const totalPages = computed(() => {
-  if (selectedVideos.value.length === 0) return 1
-  return Math.ceil(selectedVideos.value.length / gridMode.value)
-})
-
-const currentSlots = computed(() => {
-  const start = (currentPage.value - 1) * gridMode.value
-  const end = start + gridMode.value
-  const pageVideos = selectedVideos.value.slice(start, end)
-  // 补齐空位
-  const slots = []
-  for (let i = 0; i < gridMode.value; i++) {
-    slots.push(pageVideos[i] || null)
-  }
-  return slots
-})
-
-const gridStyle = computed(() => {
-  const cols = gridMode.value === 1 ? 1 : gridMode.value === 4 ? 2 : 3
-  return {
-    'grid-template-columns': `repeat(${cols}, 1fr)`,
-    'grid-template-rows': gridMode.value === 1 ? '1fr' : (gridMode.value === 4 ? 'repeat(2, 1fr)' : 'repeat(3, 1fr)')
-  }
-})
-
-// ==================== 实时时间 ====================
-const currentTime = ref('')
-let timer = null
-
-function updateTime() {
-  const now = new Date()
-  const pad = n => String(n).padStart(2, '0')
-  currentTime.value = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
-}
-
-onMounted(() => {
-  updateTime()
-  timer = setInterval(updateTime, 1000)
-})
-
-onUnmounted(() => {
-  if (timer) clearInterval(timer)
-})
-
-// ==================== 交互 ====================
-function handleDblClick(slot) {
-  if (!slot) return
-  if (gridMode.value !== 1) {
-    gridMode.value = 1
-    // 找到该视频在选中列表中的索引,跳转到对应页
-    const idx = selectedVideos.value.findIndex(v => v.id === slot.id)
-    if (idx >= 0) {
-      currentPage.value = Math.floor(idx / 1) + 1
-    }
-    ElMessage.info(`已放大查看:${slot.name}`)
-  } else {
-    gridMode.value = 4
-    currentPage.value = 1
-  }
-}
-
-function handleFullscreen() {
-  if (!gridAreaRef.value) return
-  if (document.fullscreenElement) {
-    document.exitFullscreen()
-  } else {
-    gridAreaRef.value.requestFullscreen()
-  }
-}
+import { getDrainageVideoMap } from '@/api/drainageVideo'
+import { videoSlots } from '@/utils/drainageVideoModel'
+import { checkPermi } from '@/utils/permission'
+import DrainageLiveTile from './DrainageLiveTile.vue'
+const rows=ref([]), ids=ref([]), mode=ref(4), page=ref(1), loading=ref(false), error=ref(''), area=ref()
+const canPlay=computed(()=>checkPermi(['video:device:play']))
+const view=computed(()=>videoSlots(rows.value,ids.value,mode.value,page.value))
+watch([mode,ids],()=>{page.value=1},{deep:true})
+watch(()=>view.value.current,value=>{page.value=value})
+async function load(){loading.value=true;error.value='';try{rows.value=(await getDrainageVideoMap()).data || [];ids.value=ids.value.filter(id=>rows.value.some(row=>row.videoId===id))}catch(e){rows.value=[];ids.value=[];error.value=e.message || '视频资源加载失败'}finally{loading.value=false}}
+async function fullscreen(){try{if(document.fullscreenElement)await document.exitFullscreen();else await area.value?.requestFullscreen()}catch{ElMessage.warning('浏览器不支持全屏或拒绝了全屏请求')}}
+load()
 </script>
-
-<style scoped>
-.spplgl-container {
-  display: flex;
-  flex-direction: column;
-  height: calc(100vh - 84px);
-  background: #1a1a2e;
-}
-
-.toolbar {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 10px 20px;
-  background: #16213e;
-  border-bottom: 1px solid #2a2a4a;
-  flex-shrink: 0;
-}
-
-.toolbar-left {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.toolbar-right {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.toolbar-label {
-  color: #c0c4cc;
-  font-size: 14px;
-  white-space: nowrap;
-}
-
-.page-info {
-  color: #c0c4cc;
-  font-size: 14px;
-  padding: 0 8px;
-  min-width: 60px;
-  text-align: center;
-}
-
-/* 深色主题下 select 样式调整 */
-.toolbar :deep(.el-select) {
-  --el-fill-color-blank: #1a1a2e;
-}
-
-.toolbar :deep(.el-input__wrapper) {
-  background: #1a1a2e;
-  box-shadow: 0 0 0 1px #3a3a5a inset;
-}
-
-.toolbar :deep(.el-input__inner) {
-  color: #c0c4cc;
-}
-
-/* 宫格区域 */
-.grid-area {
-  flex: 1;
-  padding: 8px;
-  overflow: hidden;
-}
-
-.grid-area:fullscreen {
-  padding: 8px;
-  background: #1a1a2e;
-}
-
-.video-grid {
-  display: grid;
-  gap: 6px;
-  height: 100%;
-}
-
-.grid-cell {
-  background: #0f0f23;
-  border-radius: 4px;
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-  position: relative;
-  border: 1px solid #2a2a4a;
-  cursor: pointer;
-  transition: border-color 0.2s;
-}
-
-.grid-cell:hover {
-  border-color: #409eff;
-}
-
-.cell-player {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  background: #000;
-}
-
-.cell-player-name {
-  color: #666;
-  font-size: 13px;
-  margin-top: 10px;
-}
-
-.cell-empty {
-  background: #0a0a1a;
-}
-
-.cell-empty-text {
-  color: #444;
-  font-size: 13px;
-  margin-top: 8px;
-}
-
-.cell-bar {
-  display: flex;
-  align-items: center;
-  padding: 6px 10px;
-  background: #16213e;
-  gap: 10px;
-  flex-shrink: 0;
-}
-
-.cell-bar-name {
-  color: #c0c4cc;
-  font-size: 12px;
-  flex: 1;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.cell-bar-status {
-  font-size: 12px;
-  font-weight: 500;
-  padding: 0 6px;
-  border-radius: 2px;
-}
-
-.cell-bar-status.online {
-  color: #67c23a;
-  background: rgba(103, 194, 58, 0.15);
-}
-
-.cell-bar-status.offline {
-  color: #f56c6c;
-  background: rgba(245, 108, 108, 0.15);
-}
-
-.cell-bar-time {
-  color: #606266;
-  font-size: 12px;
-  white-space: nowrap;
-}
-
-.cell-index {
-  position: absolute;
-  top: 6px;
-  left: 8px;
-  background: rgba(0, 0, 0, 0.6);
-  color: #fff;
-  font-size: 12px;
-  width: 22px;
-  height: 22px;
-  border-radius: 4px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-</style>
+<style scoped>.batch-page{display:flex;flex-direction:column;height:calc(100vh - 84px);min-height:600px;background:#eef2f7;padding:12px}.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px}.grid-area{flex:1;min-height:0;background:#101820;padding:8px}.grid-area:fullscreen{height:100vh}.video-grid{display:grid;height:100%;gap:8px}.cell{min-height:0;overflow:hidden;background:#101820;display:flex;flex-direction:column}.cell :deep(.live-tile){flex:1;min-height:0}.cell :deep(.tile-message){min-height:0;font-size:13px}.bar{flex-shrink:0;color:#ddd;padding:8px;background:#213247;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.empty{display:grid;place-items:center;height:100%;color:#8998a9}.pager{display:flex;justify-content:flex-end;align-items:center;gap:12px;padding:12px 0 0}.el-alert{margin-bottom:10px}</style>

+ 250 - 0
tests/e2e/drainageVideo.live.cjs

@@ -0,0 +1,250 @@
+// 使用用户正常登录的独立测试浏览器;不读取/输出Cookie、Token或密码。
+const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
+const assert = require('node:assert/strict')
+async function main() {
+  const browser = await chromium.connectOverCDP('http://127.0.0.1:9225')
+  try {
+    const pages = browser.contexts()[0].pages()
+    const page = pages.find(p => /^http:\/\/(127\.0\.0\.1|localhost)(\/|:)/.test(p.url()))
+    if (!page) throw new Error('测试浏览器中没有本机前端页面')
+    console.log('当前页面:', new URL(page.url()).pathname)
+    if (process.argv[2] === 'inspect') {
+      console.log((await page.locator('body').innerText()).slice(0,1800))
+      return
+    }
+    if (process.argv[2] === 'final') {
+      const result=await page.evaluate(async()=>{
+        const api=await import('/src/api/drainageVideo.js')
+        const rows=(await api.getDrainageVideoMap()).data
+        const tree=(await api.getDrainageVideoTree()).data
+        return {videos:rows.length,remainingFixtures:rows.filter(r=>r.videoId.startsWith('TEST_DV_')).length,hasFavoritesNode:tree[0]?.id==='favorites'}
+      })
+      assert.equal(result.videos,2);assert.equal(result.remainingFixtures,0);assert.equal(result.hasFavoritesNode,true)
+      const anonymous=await page.request.get('http://127.0.0.1:8301/api/drainage/video/resources')
+      assert.equal((await anonymous.json()).code,401)
+      await page.goto('http://127.0.0.1/subSystem/drainage/subSystem/drainaged/spjc/spfzgl')
+      await page.waitForFunction(()=>document.querySelectorAll('.el-table__row').length===2,{},{timeout:15000})
+      console.log('最终8301认证接口和两条真实视频可用,匿名请求拒绝,TEST_DV_资源已全部清理;窗口已刷新到视频分组。')
+      return
+    }
+    if (process.argv[2] === 'routes') {
+      if(new URL(page.url()).pathname==='/portal')await page.getByText('进入后台',{exact:true}).click()
+      await page.waitForTimeout(1500)
+      console.log(await page.evaluate(async()=>{
+        const request=(await import('/src/utils/request.js')).default
+        const data=(await request({url:'/getRouters'})).data
+        const found=[]
+        function walk(nodes,parent=''){for(const r of nodes){const path=r.path.startsWith('/')?r.path:`${parent}/${r.path}`;if(/spjc/.test(r.component || ''))found.push({path,name:r.name,component:r.component});walk(r.children || [],path)}}
+        walk(data);return found
+      }))
+      return
+    }
+    if (process.argv[2] === 'gis-fixture') {
+      const {execFileSync}=require('node:child_process')
+      const sql=statement=>{
+        try{return execFileSync('psql',['-h','192.168.110.30','-p','54321','-U','app_user','-d','pipi_ner','-v','ON_ERROR_STOP=1','-q','-t','-A'],{input:statement,encoding:'utf8',env:process.env,stdio:['pipe','pipe','pipe']}).trim()}
+        catch{throw Error('GIS测试数据SQL执行失败;未输出媒体地址或凭据,请检查独立测试记录')}
+      }
+      const fixture='TEST_DV_VIDEO_1'
+      const fixtureSessions=[]
+      const track=async response=>{
+        if(response.url().includes(`/api/video/devices/${fixture}/live`)){
+          try{const data=await response.json();if(data.data?.sessionId)fixtureSessions.push(data.data.sessionId)}catch{}
+        }
+      }
+      page.on('response',track)
+      const old=JSON.parse(sql(`SELECT row_to_json(v) FROM app_user.equipment_video_binding v WHERE equipment_id='${fixture}';`))
+      const quoted=value=>value==null?'NULL':`'${String(value).replace(/'/g,"''")}'`
+      const live=await page.evaluate(async()=>{
+        const api=await import('/src/api/drainageVideo.js'),video=await import('/src/api/video/index.js')
+        const row=(await api.getDrainageVideoMap()).data.find(r=>!r.videoId.startsWith('TEST_DV_'))
+        const data=(await video.startLive(row.videoId)).data
+        return {sessionId:data.sessionId,url:data.ws_flv||data.flv||data.url}
+      })
+      try {
+        assert.ok(live.url)
+        // 本轮临时图上测试资源引用已获授权的实时媒体,仅写测试记录,不改真实设备位置或接入信息。
+        sql(`UPDATE app_user.equipment_video_binding SET source_type='MANUAL',gb_device_id=NULL,gb_channel_id=NULL,video_url=${quoted(live.url)} WHERE equipment_id='${fixture}';`)
+        await page.goto('http://127.0.0.1/subSystem/drainage/subSystem/drainaged/spjc/jcspGIS')
+        await page.waitForFunction(()=>document.querySelector('.gis-map-canvas canvas'),{},{timeout:30000})
+        await page.getByText('国标视频',{exact:true}).click()
+        await page.waitForTimeout(500)
+        assert.equal(await page.locator('.resource').count(),1)
+        await page.getByText('地址视频',{exact:true}).click()
+        assert.equal(await page.locator('.resource').count(),0)
+        await page.getByText('地址视频',{exact:true}).click()
+        await page.locator('.resource').getByRole('button',{name:'TEST_DV_排水视频1',exact:true}).click()
+        await page.waitForTimeout(500)
+        await page.locator('.gis-marker-label-text').filter({hasText:'TEST_DV_排水视频1'}).first().click({force:true})
+        await page.waitForFunction(()=>document.querySelector('.live-tile[data-playing="true"]'),{},{timeout:45000})
+        const fs=require('node:fs'),path=require('node:path');const report=path.resolve('tests/e2e/reports/drainage-video');fs.mkdirSync(report,{recursive:true})
+        await page.screenshot({path:path.join(report,'gis-controlled-live.png')})
+        await Promise.all([page.waitForResponse(r=>/\/api\/video\/sessions\/[^/]+\/stop/.test(r.url())),page.locator('.el-dialog__headerbtn:visible').click()])
+        await page.goto('http://127.0.0.1/subSystem/drainage/subSystem/drainaged/spjc/spfzgl')
+        await page.waitForTimeout(1000)
+        assert.equal(Number(sql(`SELECT count(*) FROM app_user.video_play_session WHERE video_id='${fixture}' AND status='ACTIVE';`)),0)
+        console.log('GIS两类图层切换、地图标记点击、真实媒体解码及关闭会话通过(独立测试位置,不代表真实摄像机位置)。')
+      } finally {
+        await page.goto('http://127.0.0.1/subSystem/drainage/subSystem/drainaged/spjc/spfzgl')
+        for(const session of fixtureSessions)await page.evaluate(async session=>{const video=await import('/src/api/video/index.js');await video.stopVideo(session)},session)
+        await page.evaluate(async session=>{const video=await import('/src/api/video/index.js');await video.stopVideo(session)},live.sessionId)
+        sql(`UPDATE app_user.equipment_video_binding SET source_type=${quoted(old.source_type)},gb_device_id=${quoted(old.gb_device_id)},gb_channel_id=${quoted(old.gb_channel_id)},video_url=${quoted(old.video_url)} WHERE equipment_id='${fixture}';`)
+      }
+      page.off('response',track)
+      return
+    }
+    if (process.argv[2] === 'ui') {
+      const failures=[]
+      page.on('pageerror',e=>failures.push(e.message))
+      const root='/subSystem/drainage/subSystem/drainaged/'
+      const fs=require('node:fs'),path=require('node:path')
+      const report=path.resolve('tests/e2e/reports/drainage-video')
+      fs.mkdirSync(report,{recursive:true})
+      const api=fn=>page.evaluate(async fnText=>{const api=await import('/src/api/drainageVideo.js');return await new Function('api',`return (${fnText})(api)`)(api)},fn.toString())
+      await api(async api=>{
+        for(const row of (await api.getDrainageVideoMap()).data.filter(r=>r.videoId.startsWith('TEST_DV_'))){
+          if(row.pointId)await api.associateDrainageVideo(row.videoId,null)
+          if(row.favorite)await api.favoriteDrainageVideo(row.videoId,false)
+        }
+      })
+      // 点位关联从页面操作,API仅用于断言。
+      await page.goto('http://127.0.0.1'+root+'spcj/spgngl')
+      await page.getByPlaceholder('设备名称/编码').fill('TEST_DV_CODE_1')
+      await page.getByRole('button',{name:'查询',exact:true}).click()
+      const row=page.locator('.el-table__row').filter({has:page.getByText('TEST_DV_CODE_1',{exact:true})})
+      await row.getByRole('button',{name:'关联点位',exact:true}).click()
+      await page.locator('.el-dialog:visible .el-select__wrapper').click()
+      await page.getByRole('option').filter({hasText:'TEST_DV_易积水点A'}).click()
+      const [saved]=await Promise.all([page.waitForResponse(r=>r.url().includes('/devices/TEST_DV_VIDEO_1/waterlogging')&&r.request().method()==='PUT'),page.getByRole('button',{name:'保存',exact:true}).click()])
+      assert.equal((await saved.json()).code,200)
+      await page.waitForTimeout(600)
+      assert.equal(await api(async api=>(await api.getDrainageVideoMap()).data.find(r=>r.videoId==='TEST_DV_VIDEO_1').pointId),'TEST_DV_POINT_A')
+      await page.screenshot({path:path.join(report,'association.png')})
+      await row.getByRole('button',{name:'解除关联',exact:true}).click()
+      await page.getByRole('button',{name:'确定',exact:true}).click()
+      await page.waitForTimeout(600)
+      assert.equal(await api(async api=>(await api.getDrainageVideoMap()).data.find(r=>r.videoId==='TEST_DV_VIDEO_1').pointId),null)
+      // 分组收藏从页面操作,再切换我的收藏、刷新确认持久化。
+      await page.goto('http://127.0.0.1'+root+'spjc/spfzgl')
+      await page.getByPlaceholder('名称/编码').fill('TEST_DV_CODE_1')
+      await page.getByRole('button',{name:'查询',exact:true}).click()
+      const groupRow=page.locator('.el-table__row').filter({has:page.getByText('TEST_DV_CODE_1',{exact:true})})
+      await groupRow.getByRole('button',{name:'收藏',exact:true}).click()
+      await page.getByText(/我的收藏(1)/).first().click()
+      await page.waitForTimeout(500)
+      assert.equal(await page.locator('.el-table__row').count(),1)
+      await page.getByRole('button',{name:'刷新',exact:true}).click()
+      await page.waitForTimeout(500)
+      await page.screenshot({path:path.join(report,'groups.png')})
+      await page.getByRole('button',{name:'取消收藏',exact:true}).click()
+      await page.waitForFunction(()=>document.querySelectorAll('.el-table__row').length===0,{},{timeout:15000})
+      assert.equal(await page.locator('.el-table__row').count(),0)
+      // 宫格布局及翻页用12个独立未注册测试设备,不伪造画面。
+      await page.goto('http://127.0.0.1'+root+'spjc/spplgl')
+      await page.getByRole('button',{name:'选择全部',exact:true}).click()
+      await page.waitForTimeout(500)
+      assert.equal(await page.locator('.cell').count(),4)
+      await page.getByRole('button',{name:'下一页',exact:true}).click()
+      await page.waitForTimeout(500)
+      assert.match(await page.locator('.pager').innerText(),/当前 2\//)
+      await page.getByText('9宫格',{exact:true}).click()
+      assert.equal(await page.locator('.cell').count(),9)
+      await page.screenshot({path:path.join(report,'batch-nine.png')})
+      await page.getByText('1宫格',{exact:true}).click()
+      assert.equal(await page.locator('.cell').count(),1)
+      await page.getByRole('button',{name:'清空',exact:true}).click()
+      // 两台真实视频同时解码成功后截图。
+      const realRows=await api(async api=>(await api.getDrainageVideoMap()).data.filter(r=>!r.videoId.startsWith('TEST_DV_')))
+      await page.getByText('4宫格',{exact:true}).click()
+      for(const video of realRows.slice(0,2)){
+        await page.locator('.batch-page .el-select__wrapper').click()
+        await page.getByRole('option').filter({hasText:`${video.deviceName}(${video.districtName})`}).click()
+        await page.keyboard.press('Escape')
+      }
+      await page.waitForFunction(()=>document.querySelectorAll('.live-tile[data-playing="true"]').length>=2,{},{timeout:45000})
+      await page.screenshot({path:path.join(report,'batch-real-live.png')})
+      // GIS真实地图加载、分类切换、点击视频资源播放。
+      await page.goto('http://127.0.0.1'+root+'spjc/jcspGIS')
+      await page.waitForFunction(()=>document.querySelector('.gis-map-canvas canvas'),{},{timeout:30000})
+      await page.getByText('国标视频',{exact:true}).click()
+      await page.waitForTimeout(300)
+      assert.equal(await page.locator('.resource').count(),0)
+      await page.getByText('国标视频',{exact:true}).click()
+      await page.waitForTimeout(300)
+      const marker=page.locator('img[src^="data:image/svg+xml"]').first()
+      const hasMarker=await marker.count()
+      if(hasMarker)await marker.click({force:true})
+      else await page.locator('.resource').first().getByRole('button',{name:'查看视频',exact:true}).click()
+      // 若图标选中的是离线测试设备,关闭再选真实视频;仍验证图标打开了弹窗。
+      await page.waitForTimeout(500)
+      const dialogs=page.locator('.el-dialog:visible')
+      assert.equal(await dialogs.count(),1)
+      await page.locator('.el-dialog__headerbtn:visible').click()
+      const realLocated=realRows.find(r=>r.longitude!=null&&r.latitude!=null&&!(Number(r.longitude)===0&&Number(r.latitude)===0))
+      if(realLocated){
+        await page.locator('.resource').filter({hasText:realLocated.deviceName}).getByRole('button',{name:'查看视频',exact:true}).click()
+        await page.waitForFunction(()=>document.querySelector('.live-tile[data-playing="true"]'),{},{timeout:45000})
+        await page.screenshot({path:path.join(report,'gis-real-live.png')})
+        await page.locator('.el-dialog__headerbtn:visible').click()
+      }else console.log('两台真实摄像机没有有效坐标,真实位置上图验收待补齐;独立GIS媒体测试另行执行。')
+      await page.goto('http://127.0.0.1'+root+'spjc/spfzgl')
+      assert.deepEqual(failures,[])
+      console.log('页面关联/解除、个人收藏持久化、1/4/9宫格分页、两路真实解码、GIS图层和点击播放通过;截图已保存。')
+      return
+    }
+    const result = await page.evaluate(async () => {
+      const api = await import('/src/api/drainageVideo.js')
+      const video = await import('/src/api/video/index.js')
+      const request = (await import('/src/utils/request.js')).default
+      const rows = (await api.getDrainageVideoMap()).data
+      const testRows = rows.filter(r => r.videoId.startsWith('TEST_DV_'))
+      const checks = { fixtureVideos: testRows.length, leakedSourceFields: rows.some(r => 'videoUrl' in r || 'maintainerPhone' in r), api: [] }
+      const point = 'TEST_DV_POINT_A', other = 'TEST_DV_POINT_B', id = 'TEST_DV_VIDEO_1'
+      await api.associateDrainageVideo(id, point)
+      await api.associateDrainageVideo('TEST_DV_VIDEO_4', point)
+      checks.api.push('一个点位关联多个视频')
+      const linked = await api.listDrainageVideos({ pageNum:1,pageSize:100,query:'TEST_DV_',unbound:false })
+      checks.linked = linked.rows.length
+      const unbound = await api.listDrainageVideos({ pageNum:1,pageSize:100,query:'TEST_DV_',unbound:true })
+      checks.unbound = unbound.rows.length
+      const first = await api.listDrainageVideos({ pageNum:1,pageSize:5,query:'TEST_DV_' })
+      const second = await api.listDrainageVideos({ pageNum:2,pageSize:5,query:'TEST_DV_' })
+      checks.pages = [first.rows.length,second.rows.length,first.total]
+      const tree=(await api.getDrainageVideoTree()).data
+      checks.treeMatches=tree.some(root=>root.label==='城南' && root.children?.some(p=>p.pointId===point && p.videoIds.includes(id) && p.videoIds.includes('TEST_DV_VIDEO_4')))
+      try { await api.associateDrainageVideo(id, other);checks.conflictRejected=false } catch { checks.conflictRejected=true }
+      try { await api.associateDrainageVideo('TEST_DV_SENSOR', point);checks.nonVideoRejected=false } catch { checks.nonVideoRejected=true }
+      await api.favoriteDrainageVideo(id,true)
+      await request({ url:`/api/drainage/video/devices/${id}/favorite`,method:'put',data:{enabled:true},headers:{repeatSubmit:false} })
+      checks.favoriteIdempotent=(await api.listDrainageVideos({query:'TEST_DV_',favoriteOnly:true})).rows.filter(row=>row.videoId===id).length===1
+      await api.favoriteDrainageVideo(id,false)
+      checks.favoriteRemoved=!(await api.listDrainageVideos({query:'TEST_DV_',favoriteOnly:true})).rows.some(row=>row.videoId===id)
+      await api.associateDrainageVideo(id,null)
+      const after=(await api.getDrainageVideoMap()).data
+      checks.unboundCleared=after.find(row=>row.videoId===id)?.pointId==null
+      // 只对当前真实视频创建本用户自己的会话,并在finally中停止;不操作其他用户会话。
+      checks.realStreams=[]
+      const sessions=[]
+      try {
+        for(const row of rows.filter(r=>!r.videoId.startsWith('TEST_DV_')).slice(0,2)) {
+          try {
+            const live=(await video.startLive(row.videoId)).data
+            sessions.push(live.sessionId)
+            await video.heartbeatVideo(live.sessionId)
+            checks.realStreams.push({validSession:Boolean(live.sessionId),playableAddress:Boolean(live.url||live.ws_flv||live.flv||live.wss_flv||live.https_flv)})
+          } catch(e) { checks.realStreams.push({error:e.message || '播放失败'}) }
+        }
+      } finally { for(const session of sessions)await video.stopVideo(session) }
+      await api.associateDrainageVideo('TEST_DV_VIDEO_4',null)
+      return checks
+    })
+    assert.equal(result.fixtureVideos,12)
+    assert.equal(result.leakedSourceFields,false)
+    assert.equal(result.linked,2)
+    assert.equal(result.unbound,10)
+    assert.deepEqual(result.pages,[5,5,12])
+    for(const key of ['treeMatches','conflictRejected','nonVideoRejected','favoriteIdempotent','favoriteRemoved','unboundCleared'])assert.equal(result[key],true,key)
+    console.log(JSON.stringify(result,null,2))
+  } finally { await browser.close() }
+}
+main().catch(e=>{console.error(e.stack || e.message);process.exitCode=1})

部分文件因文件數量過多而無法顯示