Ver código fonte

feat(camera-vendor): implement camera vendor management feature with API integration and UI

- Added a new script for initializing camera vendor data with predefined entries.
- Created API functions for listing, adding, updating, and deleting camera vendors.
- Developed a dedicated UI for managing camera vendors, including search, pagination, and action buttons for CRUD operations.
- Enhanced types to include camera vendor-specific data structures and request interfaces.
- Updated the router and layout to include camera vendor management in the application.
yb 1 semana atrás
pai
commit
81264a0ee3

+ 164 - 0
scripts/init-camera-vendors.sh

@@ -0,0 +1,164 @@
+#!/bin/bash
+
+# 摄像头厂家初始化脚本
+# 使用方法: ./init-camera-vendors.sh <token>
+# 示例: ./init-camera-vendors.sh "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+
+API_BASE="https://tg-live-game.pwtk.cc/api"
+TOKEN="$1"
+
+if [ -z "$TOKEN" ]; then
+  echo "请提供 token 参数"
+  echo "使用方法: ./init-camera-vendors.sh <token>"
+  exit 1
+fi
+
+echo "开始录入摄像头厂家数据..."
+
+# 1. HIKVISION 海康威视
+echo "正在添加: HIKVISION (海康威视)..."
+curl -s -X POST "${API_BASE}/admin/camera-vendors/create" \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer ${TOKEN}" \
+  -d '{
+    "code": "HIKVISION",
+    "name": "海康威视",
+    "description": "PTZ球机,支持H.264/H.265编码,浏览器可直接播放。型号示例: DS-2DE2A404IW-DE3",
+    "supportOnvif": true,
+    "supportPtz": true,
+    "supportIsapi": true,
+    "supportGb28181": true,
+    "supportAudio": true,
+    "resolution": "1920x1080",
+    "defaultPort": 80,
+    "defaultRtspPort": 554,
+    "rtspUrlTemplate": "rtsp://{username}:{password}@{ip}:{port}/Streaming/Channels/{channel}01",
+    "enabled": true,
+    "sortOrder": 1
+  }'
+echo ""
+
+# 2. ANPVIZ
+echo "正在添加: ANPVIZ..."
+curl -s -X POST "${API_BASE}/admin/camera-vendors/create" \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer ${TOKEN}" \
+  -d '{
+    "code": "ANPVIZ",
+    "name": "ANPVIZ",
+    "description": "枪机,H.265编码需FFmpeg转码为H.264。型号示例: L12D2_19_IR_AF。ONVIF专业级支持",
+    "supportOnvif": true,
+    "supportPtz": false,
+    "supportIsapi": false,
+    "supportGb28181": false,
+    "supportAudio": true,
+    "resolution": "1920x1080",
+    "defaultPort": 80,
+    "defaultRtspPort": 554,
+    "rtspUrlTemplate": "rtsp://{username}:{password}@{ip}:{port}/Streaming/Channels/{channel}01",
+    "enabled": true,
+    "sortOrder": 2
+  }'
+echo ""
+
+# 3. CT-IP500 (大华风格)
+echo "正在添加: CT-IP500..."
+curl -s -X POST "${API_BASE}/admin/camera-vendors/create" \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer ${TOKEN}" \
+  -d '{
+    "code": "CT-IP500",
+    "name": "CT-IP500 (杂牌/大华风格)",
+    "description": "枪机,H.264编码,浏览器可直接播放。RTSP路径为大华风格。ONVIF端口8999。Web页面无法访问,需用Window查看",
+    "supportOnvif": true,
+    "supportPtz": false,
+    "supportIsapi": false,
+    "supportGb28181": false,
+    "supportAudio": false,
+    "resolution": "1920x1080",
+    "defaultPort": 80,
+    "defaultRtspPort": 554,
+    "rtspUrlTemplate": "rtsp://{username}:{password}@{ip}:{port}/cam/realmonitor?channel={channel}&subtype=0",
+    "enabled": true,
+    "sortOrder": 3
+  }'
+echo ""
+
+# 4. SVBC
+echo "正在添加: SVBC..."
+curl -s -X POST "${API_BASE}/admin/camera-vendors/create" \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer ${TOKEN}" \
+  -d '{
+    "code": "SVBC",
+    "name": "SVBC",
+    "description": "枪机,H.265编码(2560x1920)需FFmpeg转码。ONVIF端口8080。RTSP路径简单: /1为主码流, /2为子码流",
+    "supportOnvif": true,
+    "supportPtz": false,
+    "supportIsapi": false,
+    "supportGb28181": false,
+    "supportAudio": true,
+    "resolution": "2560x1920",
+    "defaultPort": 80,
+    "defaultRtspPort": 554,
+    "rtspUrlTemplate": "rtsp://{username}:{password}@{ip}:{port}/{channel}",
+    "enabled": true,
+    "sortOrder": 4
+  }'
+echo ""
+
+# 5. Reolink
+echo "正在添加: Reolink..."
+curl -s -X POST "${API_BASE}/admin/camera-vendors/create" \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer ${TOKEN}" \
+  -d '{
+    "code": "REOLINK",
+    "name": "Reolink",
+    "description": "固定摄像头,需要通过Reolink App进行安装配置。需要安装他们的软件,在同一个网络里面扫描二维码进行设备注册",
+    "supportOnvif": false,
+    "supportPtz": false,
+    "supportIsapi": false,
+    "supportGb28181": false,
+    "supportAudio": true,
+    "resolution": "1920x1080",
+    "defaultPort": 80,
+    "defaultRtspPort": 554,
+    "rtspUrlTemplate": "",
+    "enabled": true,
+    "sortOrder": 5
+  }'
+echo ""
+
+# 6. DAHUA 大华 (额外添加,因为CT-IP500使用大华风格)
+echo "正在添加: DAHUA (大华)..."
+curl -s -X POST "${API_BASE}/admin/camera-vendors/create" \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer ${TOKEN}" \
+  -d '{
+    "code": "DAHUA",
+    "name": "大华",
+    "description": "大华摄像头,RTSP路径格式: /cam/realmonitor?channel=1&subtype=0 (主码流) 或 subtype=1 (子码流)",
+    "supportOnvif": true,
+    "supportPtz": true,
+    "supportIsapi": false,
+    "supportGb28181": true,
+    "supportAudio": true,
+    "resolution": "1920x1080",
+    "defaultPort": 80,
+    "defaultRtspPort": 554,
+    "rtspUrlTemplate": "rtsp://{username}:{password}@{ip}:{port}/cam/realmonitor?channel={channel}&subtype=0",
+    "enabled": true,
+    "sortOrder": 6
+  }'
+echo ""
+
+echo "✅ 摄像头厂家数据录入完成!"
+echo ""
+echo "录入的厂家列表:"
+echo "  1. HIKVISION (海康威视) - PTZ球机"
+echo "  2. ANPVIZ - 枪机"
+echo "  3. CT-IP500 (杂牌/大华风格) - 枪机"
+echo "  4. SVBC - 枪机"
+echo "  5. Reolink - 固定摄像头"
+echo "  6. DAHUA (大华) - 通用大华风格"

+ 58 - 0
src/api/camera-vendor.ts

@@ -0,0 +1,58 @@
+import { get, post } from '@/utils/request'
+import type {
+  IBaseResponse,
+  IPageResponse,
+  IListResponse,
+  BaseResponse,
+  CameraVendorDTO,
+  CameraVendorAddRequest,
+  CameraVendorUpdateRequest,
+  CameraVendorListRequest
+} from '@/types'
+
+// 获取厂家列表 (分页)
+export function listCameraVendors(params?: CameraVendorListRequest): Promise<IPageResponse<CameraVendorDTO>> {
+  return post('/admin/camera-vendors/list', params || {})
+}
+
+// 获取所有启用的厂家(用于下拉选择)
+export function listEnabledCameraVendors(): Promise<IListResponse<CameraVendorDTO>> {
+  return get('/admin/camera-vendors/enabled')
+}
+
+// 获取所有厂家
+export function listAllCameraVendors(): Promise<IListResponse<CameraVendorDTO>> {
+  return get('/admin/camera-vendors/all')
+}
+
+// 获取厂家详情
+export function getCameraVendor(id: number): Promise<IBaseResponse<CameraVendorDTO>> {
+  return get('/admin/camera-vendors/detail', { id })
+}
+
+// 根据代码获取厂家
+export function getCameraVendorByCode(code: string): Promise<IBaseResponse<CameraVendorDTO>> {
+  return get('/admin/camera-vendors/byCode', { code })
+}
+
+// 创建厂家
+export function addCameraVendor(data: CameraVendorAddRequest): Promise<IBaseResponse<CameraVendorDTO>> {
+  return post('/admin/camera-vendors/create', data)
+}
+
+// 更新厂家
+export function updateCameraVendor(data: CameraVendorUpdateRequest): Promise<IBaseResponse<CameraVendorDTO>> {
+  return post('/admin/camera-vendors/update', data)
+}
+
+// 删除厂家
+export function deleteCameraVendor(id: number): Promise<BaseResponse> {
+  return post('/admin/camera-vendors/delete', undefined, {
+    params: { id }
+  })
+}
+
+// 初始化默认厂家数据
+export function initCameraVendors(): Promise<BaseResponse> {
+  return post('/admin/camera-vendors/init')
+}

+ 29 - 17
src/components/monitor/CameraSelector.vue

@@ -91,36 +91,48 @@ const filteredCameras = computed(() => {
   return cameras.value.filter((camera) => camera.name.toLowerCase().includes(keyword))
 })
 
+// 预设的 WebRTC 摄像头流列表
+const presetWebrtcCameras: CameraItem[] = [
+  // ANPVIZ
+  { id: 'anpviz', name: 'ANPVIZ 主码流', streamType: 'webrtc', streamUrl: 'anpviz', online: true },
+  { id: 'anpviz_raw', name: 'ANPVIZ 原始流', streamType: 'webrtc', streamUrl: 'anpviz_raw', online: true },
+  { id: 'anpviz_sub', name: 'ANPVIZ 子码流', streamType: 'webrtc', streamUrl: 'anpviz_sub', online: true },
+  // CT-IP500
+  { id: 'ct-ip500', name: 'CT-IP500 主码流', streamType: 'webrtc', streamUrl: 'ct-ip500', online: true },
+  { id: 'ct-ip500_sub', name: 'CT-IP500 子码流', streamType: 'webrtc', streamUrl: 'ct-ip500_sub', online: true },
+  // HIKVISION 海康威视
+  { id: 'hikvision', name: '海康威视 主码流', streamType: 'webrtc', streamUrl: 'hikvision', online: true },
+  { id: 'hikvision_sub', name: '海康威视 子码流', streamType: 'webrtc', streamUrl: 'hikvision_sub', online: true },
+  // SVBC
+  { id: 'svbc', name: 'SVBC 主码流', streamType: 'webrtc', streamUrl: 'svbc', online: true },
+  { id: 'svbc_raw', name: 'SVBC 原始流', streamType: 'webrtc', streamUrl: 'svbc_raw', online: true },
+  { id: 'svbc_sub', name: 'SVBC 子码流', streamType: 'webrtc', streamUrl: 'svbc_sub', online: true }
+]
+
 // 加载摄像头列表
 async function loadCameras() {
   loading.value = true
   try {
     const res = await adminListCameras()
-    if (res.success && res.data.list) {
-      cameras.value = res.data.list.map((item) => ({
+    if (res.success && res.data.list && res.data.list.length > 0) {
+      // 从 API 加载的摄像头
+      const apiCameras = res.data.list.map((item) => ({
         id: String(item.id),
         name: item.name || `摄像头 ${item.id}`,
-        // 根据实际数据判断 streamType,这里暂时默认为 webrtc
         streamType: (item as any).streamType || 'webrtc',
         streamUrl: (item as any).streamUrl || (item as any).rtspUrl || '',
         online: (item as any).online !== false
-      }))
+      })) as CameraItem[]
+      // 合并 API 摄像头和预设 WebRTC 摄像头
+      cameras.value = [...presetWebrtcCameras, ...apiCameras]
+    } else {
+      // 使用预设 WebRTC 摄像头
+      cameras.value = presetWebrtcCameras
     }
   } catch (error) {
     console.error('Failed to load cameras:', error)
-    // 使用模拟数据
-    cameras.value = [
-      { id: '1', name: '大门入口', streamType: 'webrtc', streamUrl: 'camera1', online: true },
-      {
-        id: '2',
-        name: '停车场',
-        streamType: 'cloudflare',
-        streamUrl: 'b51e49994b6fd9e56b6f1fdfcd339fe6',
-        online: true
-      },
-      { id: '3', name: '仓库', streamType: 'webrtc', streamUrl: 'camera3', online: false },
-      { id: '4', name: '办公区', streamType: 'webrtc', streamUrl: 'camera4', online: true }
-    ]
+    // 使用预设 WebRTC 摄像头
+    cameras.value = presetWebrtcCameras
   } finally {
     loading.value = false
   }

+ 15 - 1
src/components/monitor/VideoCell.vue

@@ -47,8 +47,12 @@
               </el-button>
             </div>
           </div>
+        </div>
+      </transition>
 
-          <!-- 底部 PTZ 控制 -->
+      <!-- 右下角 PTZ 控制面板 -->
+      <transition name="fade">
+        <div v-show="isHovering" class="video-cell__ptz">
           <PtzOverlay :camera-id="slotData.cameraId" @ptz-action="handlePtzAction" />
         </div>
       </transition>
@@ -219,6 +223,16 @@ function handlePtzAction(action: string, params?: any) {
     display: flex;
     gap: 4px;
   }
+
+  &__ptz {
+    position: absolute;
+    right: 8px;
+    bottom: 8px;
+    background: rgba(0, 0, 0, 0.75);
+    border-radius: var(--radius-base);
+    backdrop-filter: blur(4px);
+    z-index: 10;
+  }
 }
 
 .fade-enter-active,

+ 1 - 0
src/layout/index.vue

@@ -217,6 +217,7 @@ 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: '/live-stream', title: 'LiveStream 管理', icon: 'mdi:broadcast' },
   { path: '/cc', title: 'Cloudflare Stream', icon: 'mdi:cloud' },

+ 6 - 0
src/router/index.ts

@@ -38,6 +38,12 @@ const routes: RouteRecordRaw[] = [
         component: () => import('@/views/camera/index.vue'),
         meta: { title: '摄像头管理', icon: 'VideoCamera' }
       },
+      {
+        path: 'camera-vendor',
+        name: 'CameraVendor',
+        component: () => import('@/views/camera-vendor/index.vue'),
+        meta: { title: '摄像头厂家', icon: 'OfficeBuilding' }
+      },
       {
         path: 'lss',
         name: 'LSS',

+ 68 - 0
src/types/index.ts

@@ -437,3 +437,71 @@ export interface LiveStreamUpdateRequest {
   streamMethod?: StreamMethod
   commandTemplate?: string
 }
+
+// ==================== 摄像头厂家相关类型 ====================
+
+// 摄像头厂家信息
+export interface CameraVendorDTO {
+  id: number
+  code: string
+  name: string
+  description?: string
+  logoUrl?: string
+  supportOnvif: boolean
+  supportPtz: boolean
+  supportIsapi: boolean
+  supportGb28181: boolean
+  supportAudio: boolean
+  resolution?: string
+  defaultPort?: number
+  defaultRtspPort?: number
+  rtspUrlTemplate?: string
+  enabled: boolean
+  sortOrder?: number
+  createdAt: string
+  updatedAt: string
+}
+
+// 摄像头厂家列表请求参数
+export interface CameraVendorListRequest extends PageRequest {
+  // 继承 PageRequest 的所有属性
+}
+
+// 创建摄像头厂家请求
+export interface CameraVendorAddRequest {
+  code: string
+  name: string
+  description?: string
+  logoUrl?: string
+  supportOnvif?: boolean
+  supportPtz?: boolean
+  supportIsapi?: boolean
+  supportGb28181?: boolean
+  supportAudio?: boolean
+  resolution?: string
+  defaultPort?: number
+  defaultRtspPort?: number
+  rtspUrlTemplate?: string
+  enabled?: boolean
+  sortOrder?: number
+}
+
+// 更新摄像头厂家请求
+export interface CameraVendorUpdateRequest {
+  id: number
+  code: string
+  name: string
+  description?: string
+  logoUrl?: string
+  supportOnvif?: boolean
+  supportPtz?: boolean
+  supportIsapi?: boolean
+  supportGb28181?: boolean
+  supportAudio?: boolean
+  resolution?: string
+  defaultPort?: number
+  defaultRtspPort?: number
+  rtspUrlTemplate?: string
+  enabled?: boolean
+  sortOrder?: number
+}

+ 832 - 0
src/views/camera-vendor/index.vue

@@ -0,0 +1,832 @@
+<template>
+  <div class="page-container">
+    <!-- 搜索表单 -->
+    <div class="search-form">
+      <el-form :model="searchForm" inline data-id="search-form">
+        <el-form-item :label="t('厂家代码')">
+          <el-input
+            v-model.trim="searchForm.code"
+            placeholder="请输入厂家代码"
+            clearable
+            data-id="search-code"
+            @keyup.enter="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item :label="t('厂家名称')">
+          <el-input
+            v-model.trim="searchForm.name"
+            placeholder="请输入厂家名称"
+            clearable
+            data-id="search-name"
+            @keyup.enter="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item :label="t('启用状态')">
+          <el-select v-model="searchForm.enabled" placeholder="全部" clearable data-id="search-enabled">
+            <el-option label="全部" value="" />
+            <el-option label="已启用" :value="true" />
+            <el-option label="已禁用" :value="false" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="Search" data-id="btn-search" @click="handleSearch">
+            {{ t('查询') }}
+          </el-button>
+          <el-button :icon="RefreshRight" data-id="btn-reset" @click="handleReset">{{ t('重置') }}</el-button>
+          <el-button type="primary" :icon="Plus" data-id="btn-add-vendor" @click="handleAdd">
+            {{ t('新增') }}
+          </el-button>
+          <el-button type="success" :icon="Setting" data-id="btn-init" @click="handleInit">
+            {{ t('初始化默认数据') }}
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <!-- 批量操作栏 -->
+    <div v-if="selectedRows.length > 0" class="batch-actions">
+      <span class="batch-info">{{ t('已选择') }} {{ selectedRows.length }} {{ t('项') }}</span>
+      <el-button type="danger" :icon="Delete" :loading="deleteLoading" @click="handleBatchDelete">
+        {{ t('批量删除') }}
+      </el-button>
+      <el-button @click="clearSelection">{{ t('取消选择') }}</el-button>
+    </div>
+
+    <!-- 数据表格 -->
+    <div class="table-wrapper">
+      <el-table
+        ref="tableRef"
+        v-loading="loading"
+        :data="sortedList"
+        stripe
+        size="default"
+        data-id="vendor-table"
+        height="100%"
+        @selection-change="handleSelectionChange"
+        @sort-change="handleSortChange"
+      >
+        <el-table-column type="selection" width="50" align="center" />
+        <el-table-column type="index" :label="t('序号')" width="60" align="center" />
+        <el-table-column prop="code" :label="t('厂家代码')" min-width="100" sortable="custom" show-overflow-tooltip />
+        <el-table-column prop="name" :label="t('厂家名称')" min-width="120" sortable="custom" show-overflow-tooltip>
+          <template #default="{ row }">
+            <el-link type="primary" :data-id="`link-edit-${row.code}`" @click="handleEdit(row)">
+              {{ row.name }}
+            </el-link>
+          </template>
+        </el-table-column>
+        <el-table-column prop="description" :label="t('描述')" min-width="150" show-overflow-tooltip />
+        <el-table-column :label="t('协议支持')" min-width="200" align="center">
+          <template #default="{ row }">
+            <el-tag v-if="row.supportOnvif" type="success" size="small" class="protocol-tag">ONVIF</el-tag>
+            <el-tag v-if="row.supportPtz" type="primary" size="small" class="protocol-tag">PTZ</el-tag>
+            <el-tag v-if="row.supportIsapi" type="warning" size="small" class="protocol-tag">ISAPI</el-tag>
+            <el-tag v-if="row.supportGb28181" type="info" size="small" class="protocol-tag">GB28181</el-tag>
+            <el-tag v-if="row.supportAudio" type="danger" size="small" class="protocol-tag">Audio</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="defaultPort" :label="t('默认端口')" width="100" align="center">
+          <template #default="{ row }">
+            {{ row.defaultPort || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="defaultRtspPort" :label="t('RTSP端口')" width="100" align="center">
+          <template #default="{ row }">
+            {{ row.defaultRtspPort || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="enabled" :label="t('启用')" sortable="custom" width="80" align="center">
+          <template #default="{ row }">
+            <el-tag :type="row.enabled ? 'success' : 'info'">
+              {{ row.enabled ? t('是') : t('否') }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="sortOrder" :label="t('排序')" width="80" sortable="custom" align="center">
+          <template #default="{ row }">
+            {{ row.sortOrder ?? 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdAt" :label="t('创建时间')" width="160" sortable="custom" align="center">
+          <template #default="{ row }">
+            {{ formatDateTime(row.createdAt) }}
+          </template>
+        </el-table-column>
+        <el-table-column :label="t('操作')" min-width="90" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" link :icon="Edit" :data-id="`btn-edit-${row.code}`" @click="handleEdit(row)">
+              {{ t('编辑') }}
+            </el-button>
+            <el-button
+              type="danger"
+              link
+              :icon="Delete"
+              :disabled="deleteLoading"
+              :data-id="`btn-delete-${row.code}`"
+              @click="handleDelete(row)"
+            >
+              {{ t('删除') }}
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <!-- 分页 -->
+    <div class="pagination-container">
+      <el-pagination
+        v-model:current-page="currentPage"
+        v-model:page-size="pageSize"
+        :page-sizes="[10, 20, 50, 100]"
+        :total="total"
+        layout="total, sizes, prev, pager, next, jumper"
+        background
+        @size-change="handleSizeChange"
+        @current-change="handleCurrentChange"
+      />
+    </div>
+
+    <!-- 新增/编辑弹窗 -->
+    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close data-id="dialog-vendor">
+      <div class="form-container">
+        <el-scrollbar>
+          <el-form ref="formRef" :model="form" :rules="rules" label-width="120px" data-id="form-vendor">
+            <el-form-item :label="t('厂家代码')" prop="code">
+              <el-input v-model="form.code" placeholder="请输入厂家代码" :disabled="isEdit" data-id="input-code" />
+            </el-form-item>
+            <el-form-item :label="t('厂家名称')" prop="name">
+              <el-input v-model="form.name" placeholder="请输入厂家名称" data-id="input-name" />
+            </el-form-item>
+            <el-form-item :label="t('描述')" prop="description">
+              <el-input
+                v-model="form.description"
+                type="textarea"
+                :rows="2"
+                placeholder="请输入描述"
+                data-id="input-description"
+              />
+            </el-form-item>
+            <el-form-item :label="t('Logo URL')" prop="logoUrl">
+              <el-input v-model="form.logoUrl" placeholder="请输入Logo URL" data-id="input-logo-url" />
+            </el-form-item>
+            <el-form-item :label="t('协议支持')">
+              <el-checkbox v-model="form.supportOnvif" data-id="check-onvif">ONVIF</el-checkbox>
+              <el-checkbox v-model="form.supportPtz" data-id="check-ptz">PTZ</el-checkbox>
+              <el-checkbox v-model="form.supportIsapi" data-id="check-isapi">ISAPI</el-checkbox>
+              <el-checkbox v-model="form.supportGb28181" data-id="check-gb28181">GB28181</el-checkbox>
+              <el-checkbox v-model="form.supportAudio" data-id="check-audio">Audio</el-checkbox>
+            </el-form-item>
+            <el-form-item :label="t('默认分辨率')" prop="resolution">
+              <el-input v-model="form.resolution" placeholder="如: 1920x1080" data-id="input-resolution" />
+            </el-form-item>
+            <el-row>
+              <el-col :span="12">
+                <el-form-item :label="t('默认端口')" prop="defaultPort">
+                  <el-input-number
+                    v-model="form.defaultPort"
+                    :min="1"
+                    :max="65535"
+                    placeholder="80"
+                    data-id="input-default-port"
+                  />
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item :label="t('RTSP端口')" prop="defaultRtspPort">
+                  <el-input-number
+                    v-model="form.defaultRtspPort"
+                    :min="1"
+                    :max="65535"
+                    placeholder="554"
+                    data-id="input-rtsp-port"
+                  />
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-form-item :label="t('RTSP URL模板')" prop="rtspUrlTemplate">
+              <el-input
+                v-model="form.rtspUrlTemplate"
+                type="textarea"
+                :rows="2"
+                placeholder="如: rtsp://{username}:{password}@{ip}:{port}/Streaming/Channels/{channel}"
+                data-id="input-rtsp-template"
+              />
+            </el-form-item>
+            <el-row>
+              <el-col :span="12">
+                <el-form-item :label="t('排序号')" prop="sortOrder">
+                  <el-input-number v-model="form.sortOrder" :min="0" :max="9999" data-id="input-sort-order" />
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item :label="t('启用状态')">
+                  <el-switch v-model="form.enabled" data-id="switch-enabled" />
+                </el-form-item>
+              </el-col>
+            </el-row>
+          </el-form>
+        </el-scrollbar>
+      </div>
+      <template #footer>
+        <el-button data-id="btn-cancel" @click="dialogVisible = false">{{ t('取消') }}</el-button>
+        <el-button type="primary" :loading="submitLoading" data-id="btn-submit" @click="handleSubmit">
+          {{ t('确定') }}
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, computed } from 'vue'
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type TableInstance } from 'element-plus'
+import { Plus, Edit, Delete, Search, RefreshRight, Setting } from '@element-plus/icons-vue'
+import {
+  listCameraVendors,
+  addCameraVendor,
+  updateCameraVendor,
+  deleteCameraVendor,
+  initCameraVendors
+} from '@/api/camera-vendor'
+import type { CameraVendorDTO, CameraVendorAddRequest, CameraVendorUpdateRequest } from '@/types'
+import dayjs from 'dayjs'
+import { useI18n } from 'vue-i18n'
+
+const { t } = useI18n({ useScope: 'global' })
+
+// 格式化时间
+function formatDateTime(dateStr: string | undefined): string {
+  if (!dateStr) return '-'
+  return dayjs(dateStr).format('YYYY-MM-DD HH:mm')
+}
+
+const loading = ref(false)
+const submitLoading = ref(false)
+const deleteLoading = ref(false)
+const vendorList = ref<CameraVendorDTO[]>([])
+const dialogVisible = ref(false)
+const formRef = ref<FormInstance>()
+const tableRef = ref<TableInstance>()
+
+// 选中的行
+const selectedRows = ref<CameraVendorDTO[]>([])
+
+// 排序状态
+const sortState = reactive<{
+  prop: string
+  order: 'ascending' | 'descending' | null
+}>({
+  prop: '',
+  order: null
+})
+
+// 搜索表单
+const searchForm = reactive<{
+  code: string
+  name: string
+  enabled: boolean | ''
+}>({
+  code: '',
+  name: '',
+  enabled: ''
+})
+
+// 分页相关
+const currentPage = ref(1)
+const pageSize = ref(20)
+const total = ref(0)
+
+// 排序后的数据
+const sortedList = computed(() => {
+  const list = [...vendorList.value]
+  if (sortState.prop && sortState.order) {
+    list.sort((a, b) => {
+      const aVal = a[sortState.prop as keyof CameraVendorDTO]
+      const bVal = b[sortState.prop as keyof CameraVendorDTO]
+
+      // 处理空值
+      if (aVal == null && bVal == null) return 0
+      if (aVal == null) return sortState.order === 'ascending' ? -1 : 1
+      if (bVal == null) return sortState.order === 'ascending' ? 1 : -1
+
+      // 比较
+      let result = 0
+      if (typeof aVal === 'number' && typeof bVal === 'number') {
+        result = aVal - bVal
+      } else if (typeof aVal === 'boolean' && typeof bVal === 'boolean') {
+        result = aVal === bVal ? 0 : aVal ? 1 : -1
+      } else {
+        result = String(aVal).localeCompare(String(bVal))
+      }
+
+      return sortState.order === 'ascending' ? result : -result
+    })
+  }
+  return list
+})
+
+const form = reactive<{
+  id?: number
+  code: string
+  name: string
+  description: string
+  logoUrl: string
+  supportOnvif: boolean
+  supportPtz: boolean
+  supportIsapi: boolean
+  supportGb28181: boolean
+  supportAudio: boolean
+  resolution: string
+  defaultPort: number | undefined
+  defaultRtspPort: number | undefined
+  rtspUrlTemplate: string
+  enabled: boolean
+  sortOrder: number
+}>({
+  code: '',
+  name: '',
+  description: '',
+  logoUrl: '',
+  supportOnvif: false,
+  supportPtz: false,
+  supportIsapi: false,
+  supportGb28181: false,
+  supportAudio: false,
+  resolution: '',
+  defaultPort: undefined,
+  defaultRtspPort: undefined,
+  rtspUrlTemplate: '',
+  enabled: true,
+  sortOrder: 0
+})
+
+const isEdit = computed(() => !!form.id)
+const dialogTitle = computed(() => (isEdit.value ? t('编辑厂家') : t('新增厂家')))
+
+const rules: FormRules = {
+  code: [{ required: true, message: t('请输入厂家代码'), trigger: 'blur' }],
+  name: [{ required: true, message: t('请输入厂家名称'), trigger: 'blur' }]
+}
+
+async function getList() {
+  loading.value = true
+  try {
+    // 构建查询参数
+    const params: Record<string, any> = {
+      page: currentPage.value,
+      size: pageSize.value
+    }
+    // 搜索关键词
+    if (searchForm.code || searchForm.name) {
+      params.keyword = searchForm.code || searchForm.name
+    }
+    // 启用状态过滤
+    if (searchForm.enabled !== '') {
+      params.enabled = searchForm.enabled
+    }
+    // 排序
+    if (sortState.prop && sortState.order) {
+      params.sortBy = sortState.prop
+      params.sortDir = sortState.order === 'ascending' ? 'ASC' : 'DESC'
+    }
+
+    const res = await listCameraVendors(params)
+    if (res.success) {
+      vendorList.value = res.data.list
+      total.value = res.data.total || 0
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleSearch() {
+  currentPage.value = 1
+  getList()
+}
+
+function handleReset() {
+  searchForm.code = ''
+  searchForm.name = ''
+  searchForm.enabled = ''
+  currentPage.value = 1
+  sortState.prop = ''
+  sortState.order = null
+  getList()
+}
+
+// 排序变化处理
+function handleSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) {
+  sortState.prop = prop || ''
+  sortState.order = order
+  getList()
+}
+
+// 选择变化处理
+function handleSelectionChange(rows: CameraVendorDTO[]) {
+  selectedRows.value = rows
+}
+
+// 清除选择
+function clearSelection() {
+  tableRef.value?.clearSelection()
+}
+
+// 批量删除
+async function handleBatchDelete() {
+  if (selectedRows.value.length === 0) return
+
+  try {
+    await ElMessageBox.confirm(`${t('确定要删除选中的')} ${selectedRows.value.length} ${t('个厂家吗?')}`, t('提示'), {
+      type: 'warning'
+    })
+    deleteLoading.value = true
+    const deletePromises = selectedRows.value.map((row) => deleteCameraVendor(row.id))
+    await Promise.all(deletePromises)
+    ElMessage.success(`${t('成功删除')} ${selectedRows.value.length} ${t('个厂家')}`)
+    clearSelection()
+    getList()
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error(t('批量删除失败'), error)
+      ElMessage.error(t('批量删除失败'))
+    }
+  } finally {
+    deleteLoading.value = false
+  }
+}
+
+function handleAdd() {
+  Object.assign(form, {
+    id: undefined,
+    code: '',
+    name: '',
+    description: '',
+    logoUrl: '',
+    supportOnvif: false,
+    supportPtz: false,
+    supportIsapi: false,
+    supportGb28181: false,
+    supportAudio: false,
+    resolution: '',
+    defaultPort: undefined,
+    defaultRtspPort: undefined,
+    rtspUrlTemplate: '',
+    enabled: true,
+    sortOrder: 0
+  })
+  dialogVisible.value = true
+}
+
+function handleEdit(row: CameraVendorDTO) {
+  Object.assign(form, {
+    id: row.id,
+    code: row.code,
+    name: row.name,
+    description: row.description || '',
+    logoUrl: row.logoUrl || '',
+    supportOnvif: row.supportOnvif,
+    supportPtz: row.supportPtz,
+    supportIsapi: row.supportIsapi,
+    supportGb28181: row.supportGb28181,
+    supportAudio: row.supportAudio,
+    resolution: row.resolution || '',
+    defaultPort: row.defaultPort,
+    defaultRtspPort: row.defaultRtspPort,
+    rtspUrlTemplate: row.rtspUrlTemplate || '',
+    enabled: row.enabled,
+    sortOrder: row.sortOrder ?? 0
+  })
+  dialogVisible.value = true
+}
+
+async function handleDelete(row: CameraVendorDTO) {
+  if (deleteLoading.value) return
+
+  try {
+    await ElMessageBox.confirm(`${t('确定要删除厂家')} "${row.name}" ${t('吗?')}`, t('提示'), {
+      type: 'warning'
+    })
+    deleteLoading.value = true
+    const res = await deleteCameraVendor(row.id)
+    if (res.success) {
+      ElMessage.success(t('删除成功'))
+      getList()
+    }
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error(t('删除失败'), error)
+    }
+  } finally {
+    deleteLoading.value = false
+  }
+}
+
+async function handleInit() {
+  try {
+    await ElMessageBox.confirm(t('确定要初始化默认厂家数据吗?这将添加预设的摄像头厂家信息。'), t('提示'), {
+      type: 'warning'
+    })
+    loading.value = true
+    const res = await initCameraVendors()
+    if (res.success) {
+      ElMessage.success(t('初始化成功'))
+      getList()
+    }
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error(t('初始化失败'), error)
+      ElMessage.error(t('初始化失败'))
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+async function handleSubmit() {
+  if (!formRef.value) return
+
+  await formRef.value.validate(async (valid) => {
+    if (valid) {
+      submitLoading.value = true
+      try {
+        if (isEdit.value) {
+          const updateData: CameraVendorUpdateRequest = {
+            id: form.id!,
+            code: form.code,
+            name: form.name,
+            description: form.description || undefined,
+            logoUrl: form.logoUrl || undefined,
+            supportOnvif: form.supportOnvif,
+            supportPtz: form.supportPtz,
+            supportIsapi: form.supportIsapi,
+            supportGb28181: form.supportGb28181,
+            supportAudio: form.supportAudio,
+            resolution: form.resolution || undefined,
+            defaultPort: form.defaultPort,
+            defaultRtspPort: form.defaultRtspPort,
+            rtspUrlTemplate: form.rtspUrlTemplate || undefined,
+            enabled: form.enabled,
+            sortOrder: form.sortOrder
+          }
+          const res = await updateCameraVendor(updateData)
+          if (res.success) {
+            ElMessage.success(t('修改成功'))
+            dialogVisible.value = false
+            getList()
+          }
+        } else {
+          const addData: CameraVendorAddRequest = {
+            code: form.code,
+            name: form.name,
+            description: form.description || undefined,
+            logoUrl: form.logoUrl || undefined,
+            supportOnvif: form.supportOnvif,
+            supportPtz: form.supportPtz,
+            supportIsapi: form.supportIsapi,
+            supportGb28181: form.supportGb28181,
+            supportAudio: form.supportAudio,
+            resolution: form.resolution || undefined,
+            defaultPort: form.defaultPort,
+            defaultRtspPort: form.defaultRtspPort,
+            rtspUrlTemplate: form.rtspUrlTemplate || undefined,
+            enabled: form.enabled,
+            sortOrder: form.sortOrder
+          }
+          const res = await addCameraVendor(addData)
+          if (res.success) {
+            ElMessage.success(t('新增成功'))
+            dialogVisible.value = false
+            getList()
+          }
+        }
+      } finally {
+        submitLoading.value = false
+      }
+    }
+  })
+}
+
+function handleSizeChange(val: number) {
+  pageSize.value = val
+  currentPage.value = 1
+  getList()
+}
+
+function handleCurrentChange(val: number) {
+  currentPage.value = val
+  getList()
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding: 1rem;
+  box-sizing: border-box;
+  overflow: hidden;
+}
+
+.form-container {
+  padding: 18px 0;
+}
+
+// 批量操作栏
+.batch-actions {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 12px;
+  padding: 12px 16px;
+  background: #fef3c7;
+  border: 1px solid #f59e0b;
+
+  .batch-info {
+    font-size: 14px;
+    color: #92400e;
+    font-weight: 500;
+  }
+
+  :deep(.el-button--danger) {
+    background-color: #dc2626;
+    border-color: #dc2626;
+
+    &:hover {
+      background-color: #ef4444;
+      border-color: #ef4444;
+    }
+  }
+}
+
+.search-form {
+  flex-shrink: 0;
+  margin-bottom: 16px;
+  padding: 16px 16px 4px 16px;
+  background: #f5f7fa;
+
+  :deep(.el-form-item) {
+    margin-bottom: 12px;
+    margin-right: 16px;
+  }
+
+  :deep(.el-input),
+  :deep(.el-select) {
+    width: 160px;
+  }
+
+  // Indigo 主题按钮
+  :deep(.el-button--primary) {
+    background-color: #4f46e5;
+    border-color: #4f46e5;
+
+    &:hover,
+    &:focus {
+      background-color: #6366f1;
+      border-color: #6366f1;
+    }
+  }
+
+  :deep(.el-button--success) {
+    background-color: #10b981;
+    border-color: #10b981;
+
+    &:hover,
+    &:focus {
+      background-color: #34d399;
+      border-color: #34d399;
+    }
+  }
+}
+
+.table-wrapper {
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
+}
+
+.pagination-container {
+  flex-shrink: 0;
+  display: flex;
+  justify-content: flex-end;
+  padding-top: 16px;
+
+  // Indigo 主题分页
+  :deep(.el-pagination) {
+    .el-pager li.is-active {
+      background-color: #4f46e5;
+      color: #fff;
+    }
+
+    .el-pager li:not(.is-active):hover {
+      color: #4f46e5;
+    }
+
+    .btn-prev:hover,
+    .btn-next:hover {
+      color: #4f46e5;
+    }
+  }
+}
+
+// 协议标签样式
+.protocol-tag {
+  margin: 2px;
+}
+
+// 表格样式
+:deep(.el-table) {
+  --el-table-row-hover-bg-color: #f0f0ff;
+
+  .el-table__row--striped td.el-table__cell {
+    background-color: #f8f9fc;
+  }
+
+  .el-table__header th {
+    background-color: #f5f7fa;
+    color: #333;
+    font-weight: 600;
+  }
+
+  .el-link--primary {
+    color: #4f46e5;
+
+    &:hover {
+      color: #6366f1;
+    }
+  }
+
+  .el-button--primary.is-link {
+    color: #4f46e5;
+
+    &:hover {
+      color: #6366f1;
+    }
+  }
+
+  .el-table__column-filter-trigger,
+  .caret-wrapper {
+    .sort-caret.ascending {
+      border-bottom-color: #4f46e5;
+    }
+
+    .sort-caret.descending {
+      border-top-color: #4f46e5;
+    }
+  }
+
+  .el-checkbox__input.is-checked .el-checkbox__inner {
+    background-color: #4f46e5;
+    border-color: #4f46e5;
+  }
+
+  .el-checkbox__input.is-indeterminate .el-checkbox__inner {
+    background-color: #4f46e5;
+    border-color: #4f46e5;
+  }
+}
+
+// 弹窗 Indigo 主题
+:deep(.el-dialog) {
+  .el-dialog__header {
+    border-bottom: 1px solid #e5e7eb;
+    padding-bottom: 16px;
+  }
+
+  .el-dialog__footer {
+    border-top: 1px solid #e5e7eb;
+    padding-top: 16px;
+  }
+
+  .el-button--primary {
+    background-color: #4f46e5;
+    border-color: #4f46e5;
+
+    &:hover,
+    &:focus {
+      background-color: #6366f1;
+      border-color: #6366f1;
+    }
+  }
+
+  .el-switch.is-checked .el-switch__core {
+    background-color: #4f46e5;
+    border-color: #4f46e5;
+  }
+
+  .el-checkbox__input.is-checked .el-checkbox__inner {
+    background-color: #4f46e5;
+    border-color: #4f46e5;
+  }
+
+  .el-checkbox__input.is-checked + .el-checkbox__label {
+    color: #4f46e5;
+  }
+}
+</style>

+ 37 - 4
src/views/demo/webrtc-stream.vue

@@ -14,8 +14,32 @@
           </el-input>
           <el-text type="info" style="margin-left: 10px">默认端口 1984</el-text>
         </el-form-item>
-        <el-form-item label="流名称">
-          <el-input v-model="config.streamName" placeholder="摄像头流名称,如 camera1" style="width: 300px" />
+        <el-form-item label="选择摄像头">
+          <el-select
+            v-model="config.streamName"
+            placeholder="选择摄像头流"
+            style="width: 300px"
+            @change="handleStreamChange"
+          >
+            <el-option-group label="ANPVIZ">
+              <el-option label="ANPVIZ 主码流" value="anpviz" />
+              <el-option label="ANPVIZ 原始流" value="anpviz_raw" />
+              <el-option label="ANPVIZ 子码流" value="anpviz_sub" />
+            </el-option-group>
+            <el-option-group label="CT-IP500">
+              <el-option label="CT-IP500 主码流" value="ct-ip500" />
+              <el-option label="CT-IP500 子码流" value="ct-ip500_sub" />
+            </el-option-group>
+            <el-option-group label="HIKVISION 海康威视">
+              <el-option label="海康威视 主码流" value="hikvision" />
+              <el-option label="海康威视 子码流" value="hikvision_sub" />
+            </el-option-group>
+            <el-option-group label="SVBC">
+              <el-option label="SVBC 主码流" value="svbc" />
+              <el-option label="SVBC 原始流" value="svbc_raw" />
+              <el-option label="SVBC 子码流" value="svbc_sub" />
+            </el-option-group>
+          </el-select>
         </el-form-item>
         <!-- http://localhost:1984/api/webrtc?src=camera1 -->
         <el-form-item label="完整URL">
@@ -187,7 +211,7 @@ const playerRef = ref<InstanceType<typeof VideoPlayer>>()
 // 配置
 const config = reactive({
   go2rtcUrl: 'localhost:1984',
-  streamName: 'camera1'
+  streamName: 'hikvision'
 })
 
 // 播放配置
@@ -271,13 +295,22 @@ function startPlay() {
     return
   }
   if (!config.streamName) {
-    ElMessage.warning('请输入流名称')
+    ElMessage.warning('请选择摄像头')
     return
   }
   isPlaying.value = true
   addLog(`开始 WebRTC 播放: ${config.streamName}`, 'success')
 }
 
+// 切换摄像头流
+function handleStreamChange(streamName: string) {
+  addLog(`切换摄像头: ${streamName}`, 'info')
+  if (isPlaying.value) {
+    // 如果正在播放,重新连接新的流
+    playerRef.value?.reconnect()
+  }
+}
+
 // 复制 URL
 function copyUrl() {
   if (!generatedUrl.value) {