Kaynağa Gözat

feat(lss): add LSS node update functionality and enhance UI for editing

- Introduced a new API function to update LSS node details, including name, address, and Ably information.
- Added an LSS editing drawer in the UI for displaying and modifying LSS node information.
- Enhanced the LSS node type to include additional fields such as public IP and heartbeat status.
- Updated the camera management dialog to support vendor selection and improved form validation.
- Refactored related components for better readability and consistency in the LSS management interface.
yb 1 hafta önce
ebeveyn
işleme
2e07883f9f
4 değiştirilmiş dosya ile 304 ekleme ve 94 silme
  1. 11 1
      src/api/lss.ts
  2. 3 3
      src/layout/index.vue
  3. 4 0
      src/types/index.ts
  4. 286 90
      src/views/lss/index.vue

+ 11 - 1
src/api/lss.ts

@@ -43,7 +43,17 @@ export function deleteLssNode(lssId: string): Promise<BaseResponse> {
   return post('/admin/lss-nodes/delete', { lssId })
 }
 
-// 7. 获取 LSS 节点统计信息
+// 7. 更新 LSS 节点
+export function updateLssNode(data: {
+  lssId: string
+  lssName?: string
+  address?: string
+  ablyInfo?: string
+}): Promise<BaseResponse> {
+  return post('/admin/lss-nodes/update', data)
+}
+
+// 8. 获取 LSS 节点统计信息
 export function getLssNodeStats(): Promise<IBaseResponse<LssNodeStatsDTO>> {
   return get('/admin/lss-nodes/stats')
 }

+ 3 - 3
src/layout/index.vue

@@ -215,14 +215,14 @@ interface MenuItem {
 // Menu configuration with Iconify icon names
 const menuItems: MenuItem[] = [
   { path: '/', title: '仪表盘', icon: 'mdi:view-dashboard' },
-  { path: '/machine', title: '机器管理', icon: 'mdi:monitor' },
-  { path: '/camera', title: '摄像头管理', icon: 'mdi:video' },
-  { path: '/camera-vendor', title: '摄像头厂家', icon: 'mdi:domain' },
   { path: '/lss', title: 'LSS 管理', icon: 'mdi:power-plug' },
+  { path: '/camera', title: '摄像头管理', icon: 'mdi:video' },
+  { path: '/machine', title: '机器管理', icon: 'mdi:monitor' },
   { path: '/live-stream', title: 'LiveStream 管理', icon: 'mdi:broadcast' },
   { path: '/cc', title: 'Cloudflare Stream', icon: 'mdi:cloud' },
   { path: '/webrtc', title: 'WebRTC 流', icon: 'mdi:wifi' },
   { path: '/monitor', title: '多视频监控', icon: 'mdi:video' },
+  { path: '/camera-vendor', title: '摄像头配置', icon: 'mdi:domain' },
   {
     path: '/demo',
     title: '视频测试',

+ 4 - 0
src/types/index.ts

@@ -356,9 +356,13 @@ export interface LssNodeDTO {
   lssName: string
   machineId?: string
   address: string
+  publicIp?: string
   maxTasks: number
   currentTasks: number
   status: LssNodeStatus
+  heartbeat?: LssHeartbeatStatus
+  heartbeatTime?: string
+  ablyInfo?: string
   ffmpegVersion?: string
   systemInfo?: string
   enabled: boolean

+ 286 - 90
src/views/lss/index.vue

@@ -118,7 +118,55 @@
       </el-descriptions>
     </el-drawer>
 
-    <!-- 摄像头列表抽屉 -->
+    <!-- LSS 编辑抽屉 -->
+    <el-drawer
+      v-model="lssEditDrawerVisible"
+      direction="rtl"
+      size="500px"
+      :with-header="false"
+      destroy-on-close
+      class="lss-edit-drawer"
+    >
+      <div class="drawer-content">
+        <div class="drawer-header">LSS详情</div>
+        <div class="drawer-body">
+          <div class="lss-detail-form">
+            <div class="form-item">
+              <label class="form-label">LSS ID:</label>
+              <span class="form-value">{{ currentLss?.lssId }}</span>
+            </div>
+            <div class="form-item">
+              <label class="form-label">名称:</label>
+              <el-input v-model="lssEditForm.lssName" placeholder="请输入名称" />
+            </div>
+            <div class="form-item">
+              <label class="form-label">地址:</label>
+              <el-input v-model="lssEditForm.address" placeholder="请输入地址" />
+            </div>
+            <div class="form-item">
+              <label class="form-label">IP:</label>
+              <span class="form-value">{{ currentLss?.publicIp || '-' }}</span>
+            </div>
+            <div class="form-item">
+              <label class="form-label">心跳:</label>
+              <span class="form-value" :class="getHeartbeatClass(currentLss?.heartbeat)">
+                {{ formatHeartbeat(currentLss) }}
+              </span>
+            </div>
+            <div class="form-item">
+              <label class="form-label">ably信息:</label>
+              <el-input v-model="lssEditForm.ablyInfo" placeholder="请输入ably信息" />
+            </div>
+          </div>
+        </div>
+        <div class="drawer-footer">
+          <el-button @click="lssEditDrawerVisible = false">{{ t('取消') }}</el-button>
+          <el-button type="primary" :loading="lssUpdating" @click="handleUpdateLss">{{ t('更新') }}</el-button>
+        </div>
+      </div>
+    </el-drawer>
+
+    <!-- 设备列表抽屉 -->
     <el-drawer
       v-model="cameraDrawerVisible"
       :title="`设备列表 - ${currentLss?.lssName || ''}`"
@@ -199,33 +247,28 @@
     <!-- 摄像头编辑弹窗 -->
     <el-dialog
       v-model="cameraDialogVisible"
-      :title="isEditCamera ? '编辑摄像头' : '绑定摄像头'"
+      :title="isEditCamera ? '编辑摄像头' : '添加摄像头'"
       width="500px"
       :close-on-click-modal="false"
     >
       <el-form ref="cameraFormRef" :model="cameraForm" :rules="cameraRules" label-width="100px">
-        <!-- 新增时显示摄像头选择下拉 -->
-        <el-form-item v-if="!isEditCamera" label="选择摄像头" prop="selectedCameraId">
+        <!-- 新增时显示厂商选择下拉 -->
+        <el-form-item v-if="!isEditCamera" label="选择厂商" prop="selectedVendorId">
           <el-select
-            v-model="cameraForm.selectedCameraId"
-            placeholder="请选择摄像头"
+            v-model="cameraForm.selectedVendorId"
+            placeholder="请选择厂商"
             style="width: 100%"
             filterable
-            @change="handleCameraSelect"
+            @change="handleVendorSelect"
           >
-            <el-option
-              v-for="cam in availableCameras"
-              :key="cam.id"
-              :label="`${cam.ip} - ${cam.cameraId}`"
-              :value="cam.id"
-            />
+            <el-option v-for="vendor in availableVendors" :key="vendor.id" :label="vendor.name" :value="vendor.id" />
           </el-select>
         </el-form-item>
         <el-form-item label="IP 地址" prop="ip">
-          <el-input v-model="cameraForm.ip" disabled placeholder="IP 地址" />
+          <el-input v-model="cameraForm.ip" :disabled="isEditCamera" placeholder="请输入 IP 地址" />
         </el-form-item>
         <el-form-item label="摄像头 ID" prop="cameraId">
-          <el-input v-model="cameraForm.cameraId" disabled placeholder="摄像头 ID" />
+          <el-input v-model="cameraForm.cameraId" :disabled="isEditCamera" placeholder="请输入摄像头 ID" />
         </el-form-item>
         <el-form-item label="名称" prop="name">
           <el-input v-model="cameraForm.name" placeholder="请输入名称" />
@@ -239,12 +282,15 @@
         <el-form-item label="密码" prop="password">
           <el-input v-model="cameraForm.password" type="password" show-password placeholder="请输入密码" />
         </el-form-item>
-        <el-form-item label="品牌" prop="brand">
+        <!-- 编辑时显示品牌选择 -->
+        <el-form-item v-if="isEditCamera" label="品牌" prop="brand">
           <el-select v-model="cameraForm.brand" placeholder="请选择品牌" style="width: 100%">
-            <el-option label="海康威视" value="hikvision" />
-            <el-option label="大华" value="dahua" />
-            <el-option label="宇视" value="uniview" />
-            <el-option label="其他" value="other" />
+            <el-option
+              v-for="vendor in availableVendors"
+              :key="vendor.code"
+              :label="vendor.name"
+              :value="vendor.code"
+            />
           </el-select>
         </el-form-item>
         <el-form-item label="能力" prop="capability">
@@ -295,21 +341,18 @@
 import { ref, reactive, onMounted, computed } from 'vue'
 import { Search, RefreshRight, Delete, View, Edit, VideoCamera, Plus } from '@element-plus/icons-vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { listLssNodes, deleteLssNode, setLssNodeEnabled } from '@/api/lss'
-import {
-  adminListCameras,
-  adminAddCamera,
-  adminUpdateCamera,
-  adminDeleteCamera,
-  adminListAllCameras
-} from '@/api/camera'
+import { listLssNodes, deleteLssNode, setLssNodeEnabled, updateLssNode } from '@/api/lss'
+import { adminListCameras, adminAddCamera, adminUpdateCamera, adminDeleteCamera } from '@/api/camera'
+import { listCameraVendors } from '@/api/camera-vendor'
 import type {
   LssNodeDTO,
   LssNodeStatus,
   LssNodeListRequest,
+  LssHeartbeatStatus,
   CameraInfoDTO,
   CameraAddRequest,
-  CameraUpdateRequest
+  CameraUpdateRequest,
+  CameraVendorDTO
 } from '@/types'
 import type { FormInstance, FormRules } from 'element-plus'
 import dayjs from 'dayjs'
@@ -375,6 +418,33 @@ function formatBrand(brand: string | undefined): string {
   return brand ? brandMap[brand] || brand.toUpperCase() : '-'
 }
 
+// 格式化心跳状态
+function formatHeartbeat(lss: LssNodeDTO | null | undefined): string {
+  if (!lss) return '-'
+  const status = lss.heartbeat || (lss.status === 'ONLINE' ? 'active' : 'dead')
+  const time = lss.heartbeatTime || lss.updatedAt
+  if (status === 'active') {
+    return `active [${formatTime(time)}]`
+  } else if (status === 'hold') {
+    return `hold [${formatTime(time)}]`
+  } else {
+    return `dead (离线)`
+  }
+}
+
+// 获取心跳状态样式类
+function getHeartbeatClass(status: LssHeartbeatStatus | undefined): string {
+  switch (status) {
+    case 'active':
+      return 'status-active'
+    case 'hold':
+      return 'status-hold'
+    case 'dead':
+    default:
+      return 'status-dead'
+  }
+}
+
 // 查看参数配置
 function handleViewConfig(row: CameraInfoDTO) {
   ElMessage.info(`查看 ${row.name} 的参数配置`)
@@ -395,7 +465,16 @@ const tableRef = ref()
 const detailDrawerVisible = ref(false)
 const currentLss = ref<LssNodeDTO | null>(null)
 
-// 摄像头列表抽屉状态
+// LSS 编辑抽屉状态
+const lssEditDrawerVisible = ref(false)
+const lssUpdating = ref(false)
+const lssEditForm = reactive({
+  lssName: '',
+  address: '',
+  ablyInfo: ''
+})
+
+// 设备列表抽屉状态
 const cameraDrawerVisible = ref(false)
 const cameraLoading = ref(false)
 const cameraList = ref<CameraInfoDTO[]>([])
@@ -412,11 +491,11 @@ const cameraFormRef = ref<FormInstance>()
 const isEditCamera = ref(false)
 const cameraSubmitting = ref(false)
 const currentCamera = ref<CameraInfoDTO | null>(null)
-const availableCameras = ref<CameraInfoDTO[]>([])
+const availableVendors = ref<CameraVendorDTO[]>([])
 
 // 摄像头表单
 const cameraForm = reactive({
-  selectedCameraId: null as number | null,
+  selectedVendorId: null as number | null,
   cameraId: '',
   name: '',
   ip: '',
@@ -434,8 +513,10 @@ const cameraForm = reactive({
 
 // 摄像头表单验证规则(动态)
 const cameraRules = computed<FormRules>(() => ({
-  selectedCameraId: isEditCamera.value ? [] : [{ required: true, message: '请选择摄像头', trigger: 'change' }],
-  name: [{ required: false, message: '请输入名称', trigger: 'blur' }]
+  selectedVendorId: isEditCamera.value ? [] : [{ required: true, message: '请选择厂商', trigger: 'change' }],
+  cameraId: isEditCamera.value ? [] : [{ required: true, message: '请输入摄像头 ID', trigger: 'blur' }],
+  ip: isEditCamera.value ? [] : [{ required: true, message: '请输入 IP 地址', trigger: 'blur' }],
+  name: [{ required: true, message: '请输入名称', trigger: 'blur' }]
 }))
 
 // 排序状态
@@ -537,7 +618,11 @@ function handleViewDetail(row: LssNodeDTO) {
 }
 
 function handleEdit(row: LssNodeDTO) {
-  handleCameraList(row)
+  currentLss.value = row
+  lssEditForm.lssName = row.lssName || ''
+  lssEditForm.address = row.address || ''
+  lssEditForm.ablyInfo = row.ablyInfo || ''
+  lssEditDrawerVisible.value = true
 }
 
 async function handleCameraList(row: LssNodeDTO) {
@@ -548,6 +633,32 @@ async function handleCameraList(row: LssNodeDTO) {
   await loadCameraList()
 }
 
+async function handleUpdateLss() {
+  if (!currentLss.value) return
+
+  lssUpdating.value = true
+  try {
+    const res = await updateLssNode({
+      lssId: currentLss.value.lssId,
+      lssName: lssEditForm.lssName,
+      address: lssEditForm.address,
+      ablyInfo: lssEditForm.ablyInfo
+    })
+    if (res.success) {
+      ElMessage.success('更新成功')
+      lssEditDrawerVisible.value = false
+      getList()
+    } else {
+      ElMessage.error(res.errMessage || '更新失败')
+    }
+  } catch (error) {
+    console.error('更新 LSS 失败', error)
+    ElMessage.error('更新失败')
+  } finally {
+    lssUpdating.value = false
+  }
+}
+
 async function loadCameraList() {
   if (!currentLss.value) return
   cameraLoading.value = true
@@ -586,7 +697,7 @@ function handleCameraReset() {
 }
 
 function resetCameraForm() {
-  cameraForm.selectedCameraId = null
+  cameraForm.selectedVendorId = null
   cameraForm.cameraId = ''
   cameraForm.name = ''
   cameraForm.ip = ''
@@ -603,35 +714,27 @@ function resetCameraForm() {
   cameraFormRef.value?.clearValidate()
 }
 
-async function loadAvailableCameras() {
+async function loadAvailableVendors() {
   try {
-    // 获取所有摄像头(不传 lssId 获取未绑定的)
-    const res = await adminListAllCameras()
+    const res = await listCameraVendors({ enabled: true })
     if (res.success && res.data) {
-      // 过滤掉已绑定到当前 LSS 的摄像头
-      availableCameras.value = res.data.filter((cam) => !cam.lssId || cam.lssId !== currentLss.value?.lssId)
+      availableVendors.value = res.data.list || []
     }
   } catch (error) {
-    console.error('获取可用摄像头列表失败', error)
+    console.error('获取厂商列表失败', error)
   }
 }
 
-function handleCameraSelect(cameraId: number) {
-  const camera = availableCameras.value.find((cam) => cam.id === cameraId)
-  if (camera) {
-    cameraForm.cameraId = camera.cameraId
-    cameraForm.ip = camera.ip
-    cameraForm.name = camera.name || ''
-    cameraForm.port = camera.port || 80
-    cameraForm.username = camera.username || ''
-    cameraForm.brand = camera.brand || ''
-    cameraForm.capability = camera.capability || 'switch_only'
-    cameraForm.rtspUrl = camera.rtspUrl || ''
-    cameraForm.model = camera.model || ''
-    cameraForm.channelNo = camera.channelNo || ''
-    cameraForm.remark = camera.remark || ''
-    cameraForm.enabled = camera.enabled
-    currentCamera.value = camera
+function handleVendorSelect(vendorId: number) {
+  const vendor = availableVendors.value.find((v) => v.id === vendorId)
+  if (vendor) {
+    cameraForm.brand = vendor.code
+    // 设置厂商默认端口
+    if (vendor.defaultPort) {
+      cameraForm.port = vendor.defaultPort
+    }
+    // 根据厂商设置默认能力
+    cameraForm.capability = vendor.supportPtz ? 'ptz_enabled' : 'switch_only'
   }
 }
 
@@ -639,14 +742,14 @@ async function handleAddCamera() {
   isEditCamera.value = false
   currentCamera.value = null
   resetCameraForm()
-  await loadAvailableCameras()
+  await loadAvailableVendors()
   cameraDialogVisible.value = true
 }
 
-function handleEditCamera(row: CameraInfoDTO) {
+async function handleEditCamera(row: CameraInfoDTO) {
   isEditCamera.value = true
   currentCamera.value = row
-  cameraForm.selectedCameraId = row.id
+  cameraForm.selectedVendorId = null
   cameraForm.cameraId = row.cameraId
   cameraForm.name = row.name
   cameraForm.ip = row.ip
@@ -660,6 +763,7 @@ function handleEditCamera(row: CameraInfoDTO) {
   cameraForm.channelNo = row.channelNo || ''
   cameraForm.remark = row.remark || ''
   cameraForm.enabled = row.enabled
+  await loadAvailableVendors()
   cameraDialogVisible.value = true
 }
 
@@ -671,36 +775,62 @@ async function handleSubmitCamera() {
 
     cameraSubmitting.value = true
     try {
-      if (!currentCamera.value) {
-        ElMessage.error('请选择摄像头')
-        return
-      }
-
-      // 无论新增还是编辑,都是更新摄像头信息(绑定 lssId)
-      const data: CameraUpdateRequest = {
-        id: currentCamera.value.id,
-        name: cameraForm.name,
-        port: cameraForm.port,
-        username: cameraForm.username,
-        brand: cameraForm.brand,
-        capability: cameraForm.capability,
-        lssId: currentLss.value?.lssId,
-        rtspUrl: cameraForm.rtspUrl,
-        model: cameraForm.model,
-        channelNo: cameraForm.channelNo,
-        remark: cameraForm.remark,
-        enabled: cameraForm.enabled
-      }
-      if (cameraForm.password) {
-        data.password = cameraForm.password
-      }
-      const res = await adminUpdateCamera(data)
-      if (res.success) {
-        ElMessage.success(isEditCamera.value ? '更新成功' : '绑定成功')
-        cameraDialogVisible.value = false
-        loadCameraList()
+      if (isEditCamera.value) {
+        // 编辑模式:更新摄像头信息
+        if (!currentCamera.value) {
+          ElMessage.error('摄像头信息错误')
+          return
+        }
+        const data: CameraUpdateRequest = {
+          id: currentCamera.value.id,
+          name: cameraForm.name,
+          port: cameraForm.port,
+          username: cameraForm.username,
+          brand: cameraForm.brand,
+          capability: cameraForm.capability,
+          lssId: currentLss.value?.lssId,
+          rtspUrl: cameraForm.rtspUrl,
+          model: cameraForm.model,
+          channelNo: cameraForm.channelNo,
+          remark: cameraForm.remark,
+          enabled: cameraForm.enabled
+        }
+        if (cameraForm.password) {
+          data.password = cameraForm.password
+        }
+        const res = await adminUpdateCamera(data)
+        if (res.success) {
+          ElMessage.success('更新成功')
+          cameraDialogVisible.value = false
+          loadCameraList()
+        } else {
+          ElMessage.error(res.errMessage || '更新失败')
+        }
       } else {
-        ElMessage.error(res.errMessage || '操作失败')
+        // 新增模式:创建摄像头并绑定到当前 LSS
+        const data: CameraAddRequest = {
+          cameraId: cameraForm.cameraId,
+          name: cameraForm.name,
+          ip: cameraForm.ip,
+          port: cameraForm.port,
+          username: cameraForm.username,
+          password: cameraForm.password,
+          brand: cameraForm.brand,
+          capability: cameraForm.capability,
+          lssId: currentLss.value?.lssId,
+          rtspUrl: cameraForm.rtspUrl,
+          model: cameraForm.model,
+          channelNo: cameraForm.channelNo,
+          remark: cameraForm.remark
+        }
+        const res = await adminAddCamera(data)
+        if (res.success) {
+          ElMessage.success('添加成功')
+          cameraDialogVisible.value = false
+          loadCameraList()
+        } else {
+          ElMessage.error(res.errMessage || '添加失败')
+        }
       }
     } catch (error) {
       console.error('保存摄像头失败', error)
@@ -897,6 +1027,72 @@ onMounted(() => {
   }
 }
 
+// LSS 编辑抽屉样式
+.lss-edit-drawer {
+  :deep(.el-drawer__body) {
+    padding: 0;
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+  }
+}
+
+.drawer-content {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.drawer-header {
+  flex-shrink: 0;
+  padding: 16px 20px;
+  font-size: 16px;
+  font-weight: 500;
+  color: #303133;
+  border-bottom: 1px solid #e5e7eb;
+}
+
+.drawer-body {
+  flex: 1;
+  overflow-y: auto;
+  padding: 20px;
+}
+
+.lss-detail-form {
+  .form-item {
+    display: flex;
+    align-items: flex-start;
+    margin-bottom: 16px;
+
+    .form-label {
+      flex-shrink: 0;
+      width: 80px;
+      line-height: 32px;
+      color: #606266;
+      font-size: 14px;
+    }
+
+    .form-value {
+      line-height: 32px;
+      color: #303133;
+      font-size: 14px;
+    }
+
+    .el-input {
+      flex: 1;
+    }
+  }
+}
+
+.drawer-footer {
+  flex-shrink: 0;
+  display: flex;
+  justify-content: flex-end;
+  padding: 12px 20px;
+  border-top: 1px solid #e5e7eb;
+  gap: 12px;
+}
+
 // 表格样式
 :deep(.el-table) {
   --el-table-row-hover-bg-color: #f0f0ff;