Просмотр исходного кода

feat(live-stream): implement LiveStream management feature with API integration and UI

- Added LiveStream management page with search, pagination, and action buttons for starting/stopping streams.
- Created API functions for listing, adding, updating, and deleting LiveStreams.
- Introduced LiveStream types and request interfaces in types.
- Updated router to include LiveStream management route.
- Enhanced layout to include LiveStream management in the menu.
- Implemented UI components for displaying and managing LiveStreams.
yb 1 неделя назад
Родитель
Сommit
e5ff0d895e
5 измененных файлов с 968 добавлено и 0 удалено
  1. 59 0
      src/api/live-stream.ts
  2. 1 0
      src/layout/index.vue
  3. 6 0
      src/router/index.ts
  4. 54 0
      src/types/index.ts
  5. 848 0
      src/views/live-stream/index.vue

+ 59 - 0
src/api/live-stream.ts

@@ -0,0 +1,59 @@
+import { post } from '@/utils/request'
+import type {
+  IPageResponse,
+  IBaseResponse,
+  BaseResponse,
+  LiveStreamDTO,
+  LiveStreamListRequest,
+  LiveStreamAddRequest,
+  LiveStreamUpdateRequest,
+  LssDTO,
+  CameraInfoDTO
+} from '@/types'
+
+// ==================== LiveStream Admin APIs ====================
+
+// 获取 LiveStream 列表 (分页)
+export function listLiveStreams(params?: LiveStreamListRequest): Promise<IPageResponse<LiveStreamDTO>> {
+  return post('/admin/live-stream/list', params || {})
+}
+
+// 获取单个 LiveStream
+export function getLiveStream(id: number): Promise<IBaseResponse<LiveStreamDTO>> {
+  return post('/admin/live-stream/get', { id })
+}
+
+// 创建 LiveStream
+export function addLiveStream(data: LiveStreamAddRequest): Promise<IBaseResponse<LiveStreamDTO>> {
+  return post('/admin/live-stream/add', data)
+}
+
+// 更新 LiveStream
+export function updateLiveStream(data: LiveStreamUpdateRequest): Promise<IBaseResponse<LiveStreamDTO>> {
+  return post('/admin/live-stream/update', data)
+}
+
+// 删除 LiveStream
+export function deleteLiveStream(id: number): Promise<BaseResponse> {
+  return post('/admin/live-stream/delete', { id })
+}
+
+// 启动 LiveStream
+export function startLiveStream(id: number): Promise<BaseResponse> {
+  return post('/admin/live-stream/start', { id })
+}
+
+// 停止 LiveStream
+export function stopLiveStream(id: number): Promise<BaseResponse> {
+  return post('/admin/live-stream/stop', { id })
+}
+
+// 获取 LSS 下拉列表(用于选择)
+export function getLssOptions(): Promise<IBaseResponse<LssDTO[]>> {
+  return post('/admin/lss/options', {})
+}
+
+// 获取摄像头下拉列表(用于选择)
+export function getCameraOptions(): Promise<IBaseResponse<CameraInfoDTO[]>> {
+  return post('/admin/camera/options', {})
+}

+ 1 - 0
src/layout/index.vue

@@ -218,6 +218,7 @@ const menuItems: MenuItem[] = [
   { path: '/machine', title: '机器管理', icon: 'mdi:monitor' },
   { path: '/camera', title: '摄像头管理', icon: 'mdi:video' },
   { path: '/lss', title: 'LSS 管理', icon: 'mdi:power-plug' },
+  { 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' },

+ 6 - 0
src/router/index.ts

@@ -44,6 +44,12 @@ const routes: RouteRecordRaw[] = [
         component: () => import('@/views/lss/index.vue'),
         meta: { title: 'LSS 管理', icon: 'Connection' }
       },
+      {
+        path: 'live-stream',
+        name: 'LiveStream',
+        component: () => import('@/views/live-stream/index.vue'),
+        meta: { title: 'LiveStream 管理', icon: 'VideoCamera' }
+      },
       {
         path: 'cloud',
         name: 'cloud',

+ 54 - 0
src/types/index.ts

@@ -348,3 +348,57 @@ export interface LssDTO {
 export interface LssListRequest extends PageRequest {
   heartbeat?: LssHeartbeatStatus
 }
+
+// ==================== LiveStream 相关类型 ====================
+
+// 推流方式
+export type StreamMethod = 'ffmpeg' | 'obs' | 'gstreamer'
+
+// LiveStream 状态
+export type LiveStreamStatus = 'running' | 'stopped' | 'error'
+
+// LiveStream 信息
+export interface LiveStreamDTO {
+  id: number
+  streamSn: string
+  name: string
+  lssId?: number
+  lssName?: string
+  cameraId?: number
+  cameraName?: string
+  streamMethod: StreamMethod
+  commandTemplate?: string
+  status: LiveStreamStatus
+  startedAt?: string
+  stoppedAt?: string
+  playUrl?: string
+  createdAt: string
+  updatedAt: string
+}
+
+// LiveStream 列表请求参数
+export interface LiveStreamListRequest extends PageRequest {
+  aoAgent?: string
+  feature?: string
+  status?: LiveStreamStatus
+  lssId?: number
+}
+
+// LiveStream 创建请求
+export interface LiveStreamAddRequest {
+  name: string
+  lssId?: number
+  cameraId?: number
+  streamMethod: StreamMethod
+  commandTemplate?: string
+}
+
+// LiveStream 更新请求
+export interface LiveStreamUpdateRequest {
+  id: number
+  name?: string
+  lssId?: number
+  cameraId?: number
+  streamMethod?: StreamMethod
+  commandTemplate?: string
+}

+ 848 - 0
src/views/live-stream/index.vue

@@ -0,0 +1,848 @@
+<template>
+  <div class="page-container">
+    <!-- 搜索表单 -->
+    <div class="search-form">
+      <el-form :model="searchForm" inline>
+        <el-form-item :label="t('Stream SN')">
+          <el-input
+            v-model.trim="searchForm.aoAgent"
+            placeholder="请输入AO代理"
+            clearable
+            @keyup.enter="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item :label="t('功能')">
+          <el-input v-model.trim="searchForm.feature" placeholder="请输入功能" clearable @keyup.enter="handleSearch" />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="Search" @click="handleSearch">{{ t('查询') }}</el-button>
+          <el-button :icon="RefreshRight" @click="handleReset">{{ t('重置') }}</el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <!-- 数据表格 -->
+    <div class="table-wrapper">
+      <el-table
+        ref="tableRef"
+        v-loading="loading"
+        :data="streamList"
+        stripe
+        size="default"
+        height="100%"
+        @sort-change="handleSortChange"
+      >
+        <el-table-column prop="streamSn" :label="t('stream sn(生成)')" min-width="140" show-overflow-tooltip />
+        <el-table-column prop="name" :label="t('name(手填)')" min-width="120" show-overflow-tooltip>
+          <template #default="{ row }">
+            <el-link type="primary" @click="handleEdit(row)">{{ row.name }}</el-link>
+          </template>
+        </el-table-column>
+        <el-table-column prop="lssName" :label="t('LSS (下拉)')" min-width="120" show-overflow-tooltip>
+          <template #default="{ row }">
+            <span>{{ row.lssName ? `${row.lssId}/${row.lssName}` : '-' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="cameraName" :label="t('摄像头编号(下拉选择)')" min-width="150" show-overflow-tooltip>
+          <template #default="{ row }">
+            <span>{{ row.cameraName || '-' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="streamMethod" :label="t('推流方式')" width="100" align="center">
+          <template #default="{ row }">
+            <el-tag size="small">{{ row.streamMethod }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="commandTemplate" :label="t('命令模板')" width="90" align="center">
+          <template #default="{ row }">
+            <el-link v-if="row.commandTemplate" type="primary" @click="showCommandTemplate(row)">
+              {{ t('查看') }}
+            </el-link>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column :label="t('操作')" width="100" align="center">
+          <template #default="{ row }">
+            <el-button
+              v-if="row.status !== 'running'"
+              type="primary"
+              link
+              :loading="actionLoading[row.id]"
+              @click="handleStart(row)"
+            >
+              {{ t('启动') }}
+            </el-button>
+            <el-button v-else type="danger" link :loading="actionLoading[row.id]" @click="handleStop(row)">
+              {{ t('关闭') }}
+            </el-button>
+          </template>
+        </el-table-column>
+        <el-table-column prop="startedAt" :label="t('启动时间')" width="160" align="center">
+          <template #default="{ row }">
+            {{ formatDateTime(row.startedAt) }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="stoppedAt" :label="t('关闭时间')" width="160" align="center">
+          <template #default="{ row }">
+            {{ formatDateTime(row.stoppedAt) }}
+          </template>
+        </el-table-column>
+        <el-table-column :label="t('观看')" width="80" align="center">
+          <template #default="{ row }">
+            <el-button
+              type="primary"
+              link
+              :icon="View"
+              :disabled="row.status !== 'running'"
+              @click="handleWatch(row)"
+            />
+          </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="550px" destroy-on-close>
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
+        <el-form-item :label="t('Stream SN')" prop="streamSn">
+          <el-input v-model="form.streamSn" placeholder="自动生成" disabled />
+        </el-form-item>
+        <el-form-item :label="t('名称')" prop="name">
+          <el-input v-model="form.name" placeholder="请输入名称" />
+        </el-form-item>
+        <el-form-item :label="t('LSS')" prop="lssId">
+          <el-select v-model="form.lssId" placeholder="请选择 LSS" clearable filterable style="width: 100%">
+            <el-option v-for="lss in lssOptions" :key="lss.id" :label="`${lss.lssId} / ${lss.name}`" :value="lss.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item :label="t('摄像头')" prop="cameraId">
+          <el-select v-model="form.cameraId" placeholder="请选择摄像头" clearable filterable style="width: 100%">
+            <el-option
+              v-for="camera in cameraOptions"
+              :key="camera.id"
+              :label="`${camera.cameraId} / ${camera.name}`"
+              :value="camera.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item :label="t('推流方式')" prop="streamMethod">
+          <el-select v-model="form.streamMethod" placeholder="请选择推流方式" style="width: 100%">
+            <el-option label="ffmpeg" value="ffmpeg" />
+            <el-option label="obs" value="obs" />
+            <el-option label="gstreamer" value="gstreamer" />
+          </el-select>
+        </el-form-item>
+        <el-form-item :label="t('命令模板')" prop="commandTemplate">
+          <el-input v-model="form.commandTemplate" type="textarea" :rows="4" placeholder="请输入命令模板" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">{{ t('取消') }}</el-button>
+        <el-button type="primary" :loading="submitLoading" @click="handleSubmit">{{ t('确定') }}</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 命令模板查看弹窗 -->
+    <el-dialog v-model="templateDialogVisible" :title="t('命令模板')" width="600px">
+      <pre class="command-template">{{ currentTemplate }}</pre>
+      <template #footer>
+        <el-button type="primary" @click="templateDialogVisible = false">{{ t('关闭') }}</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 观看弹窗 -->
+    <el-dialog v-model="watchDialogVisible" :title="t('观看直播')" width="800px" destroy-on-close>
+      <div v-if="currentWatchUrl" class="watch-container">
+        <video ref="videoRef" controls autoplay style="width: 100%; max-height: 450px; background: #000">
+          <source :src="currentWatchUrl" type="application/x-mpegURL" />
+        </video>
+        <div class="watch-url">
+          <span>{{ t('播放地址') }}:</span>
+          <el-link type="primary" :href="currentWatchUrl" target="_blank">{{ currentWatchUrl }}</el-link>
+        </div>
+      </div>
+      <div v-else class="watch-empty">{{ t('暂无播放地址') }}</div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, computed } from 'vue'
+import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
+import { Search, RefreshRight, View } from '@element-plus/icons-vue'
+import {
+  listLiveStreams,
+  addLiveStream,
+  updateLiveStream,
+  startLiveStream,
+  stopLiveStream,
+  getLssOptions,
+  getCameraOptions
+} from '@/api/live-stream'
+import type { LiveStreamDTO, LssDTO, CameraInfoDTO, StreamMethod } 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('YYYYMMDD-HH:mm:ss')
+}
+
+const loading = ref(false)
+const submitLoading = ref(false)
+const actionLoading = ref<Record<number, boolean>>({})
+const streamList = ref<LiveStreamDTO[]>([])
+const dialogVisible = ref(false)
+const templateDialogVisible = ref(false)
+const watchDialogVisible = ref(false)
+const formRef = ref<FormInstance>()
+const currentTemplate = ref('')
+const currentWatchUrl = ref('')
+
+// 下拉选项
+const lssOptions = ref<LssDTO[]>([])
+const cameraOptions = ref<CameraInfoDTO[]>([])
+
+// 排序状态
+const sortState = reactive<{
+  prop: string
+  order: 'ascending' | 'descending' | null
+}>({
+  prop: '',
+  order: null
+})
+
+// 搜索表单
+const searchForm = reactive({
+  aoAgent: '',
+  feature: ''
+})
+
+// 分页相关
+const currentPage = ref(1)
+const pageSize = ref(20)
+const total = ref(0)
+
+// 表单数据
+const form = reactive<{
+  id?: number
+  streamSn: string
+  name: string
+  lssId?: number
+  cameraId?: number
+  streamMethod: StreamMethod
+  commandTemplate: string
+}>({
+  streamSn: '',
+  name: '',
+  lssId: undefined,
+  cameraId: undefined,
+  streamMethod: 'ffmpeg',
+  commandTemplate: ''
+})
+
+const isEdit = computed(() => !!form.id)
+const dialogTitle = computed(() => (isEdit.value ? t('编辑 LiveStream') : t('新增 LiveStream')))
+
+const rules: FormRules = {
+  name: [{ required: true, message: t('请输入名称'), trigger: 'blur' }],
+  streamMethod: [{ required: true, message: t('请选择推流方式'), trigger: 'change' }]
+}
+
+// 生成测试数据
+function generateMockData(): LiveStreamDTO[] {
+  const mockData: LiveStreamDTO[] = [
+    {
+      id: 1,
+      streamSn: 'LS-20260119-001',
+      name: '大厅摄像头直播',
+      lssId: 1,
+      lssName: 'LSS-Tokyo-01',
+      cameraId: 101,
+      cameraName: 'CAM-LOBBY-01',
+      streamMethod: 'ffmpeg',
+      commandTemplate:
+        'ffmpeg -i rtsp://192.168.1.100:554/stream1 -c:v libx264 -preset ultrafast -tune zerolatency -f flv rtmp://live.example.com/app/stream1',
+      status: 'running',
+      startedAt: '2026-01-19T11:11:11',
+      stoppedAt: undefined,
+      playUrl: 'https://live.example.com/hls/stream1.m3u8',
+      createdAt: '2026-01-15T10:00:00',
+      updatedAt: '2026-01-19T11:11:11'
+    },
+    {
+      id: 2,
+      streamSn: 'LS-20260119-002',
+      name: '入口监控',
+      lssId: 1,
+      lssName: 'LSS-Tokyo-01',
+      cameraId: 102,
+      cameraName: 'CAM-ENTRANCE-01',
+      streamMethod: 'ffmpeg',
+      commandTemplate:
+        'ffmpeg -i rtsp://192.168.1.101:554/stream1 -c:v libx264 -f flv rtmp://live.example.com/app/stream2',
+      status: 'stopped',
+      startedAt: '2026-01-18T09:00:00',
+      stoppedAt: '2026-01-18T18:00:00',
+      playUrl: undefined,
+      createdAt: '2026-01-15T10:30:00',
+      updatedAt: '2026-01-18T18:00:00'
+    },
+    {
+      id: 3,
+      streamSn: 'LS-20260119-003',
+      name: '仓库区域',
+      lssId: 2,
+      lssName: 'LSS-Osaka-01',
+      cameraId: 103,
+      cameraName: 'CAM-WAREHOUSE-01',
+      streamMethod: 'obs',
+      commandTemplate: undefined,
+      status: 'running',
+      startedAt: '2026-01-19T08:30:00',
+      stoppedAt: undefined,
+      playUrl: 'https://live.example.com/hls/stream3.m3u8',
+      createdAt: '2026-01-16T14:00:00',
+      updatedAt: '2026-01-19T08:30:00'
+    },
+    {
+      id: 4,
+      streamSn: 'LS-20260119-004',
+      name: '停车场入口',
+      lssId: 2,
+      lssName: 'LSS-Osaka-01',
+      cameraId: 104,
+      cameraName: 'CAM-PARKING-01',
+      streamMethod: 'ffmpeg',
+      commandTemplate:
+        'ffmpeg -i rtsp://192.168.1.104:554/ch1 -c:v copy -c:a aac -f flv rtmp://live.example.com/app/parking',
+      status: 'error',
+      startedAt: '2026-01-19T07:00:00',
+      stoppedAt: '2026-01-19T07:15:00',
+      playUrl: undefined,
+      createdAt: '2026-01-17T09:00:00',
+      updatedAt: '2026-01-19T07:15:00'
+    },
+    {
+      id: 5,
+      streamSn: 'LS-20260119-005',
+      name: '会议室A',
+      lssId: 1,
+      lssName: 'LSS-Tokyo-01',
+      cameraId: 105,
+      cameraName: 'CAM-MEETING-A',
+      streamMethod: 'gstreamer',
+      commandTemplate:
+        'gst-launch-1.0 rtspsrc location=rtsp://192.168.1.105:554/stream ! rtph264depay ! h264parse ! flvmux ! rtmpsink location=rtmp://live.example.com/app/meetingA',
+      status: 'stopped',
+      startedAt: undefined,
+      stoppedAt: undefined,
+      playUrl: undefined,
+      createdAt: '2026-01-18T11:00:00',
+      updatedAt: '2026-01-18T11:00:00'
+    }
+  ]
+  return mockData
+}
+
+// 生成测试 LSS 选项
+function generateMockLssOptions(): LssDTO[] {
+  return [
+    {
+      id: 1,
+      lssId: 'LSS-001',
+      name: 'LSS-Tokyo-01',
+      address: '192.168.1.10',
+      publicIp: '203.0.113.10',
+      heartbeat: 'active',
+      heartbeatTime: '2026-01-19T12:00:00'
+    },
+    {
+      id: 2,
+      lssId: 'LSS-002',
+      name: 'LSS-Osaka-01',
+      address: '192.168.2.10',
+      publicIp: '203.0.113.20',
+      heartbeat: 'active',
+      heartbeatTime: '2026-01-19T12:00:00'
+    },
+    {
+      id: 3,
+      lssId: 'LSS-003',
+      name: 'LSS-Nagoya-01',
+      address: '192.168.3.10',
+      publicIp: '203.0.113.30',
+      heartbeat: 'hold',
+      heartbeatTime: '2026-01-19T11:50:00'
+    }
+  ]
+}
+
+// 生成测试摄像头选项
+function generateMockCameraOptions(): CameraInfoDTO[] {
+  return [
+    {
+      id: 101,
+      cameraId: 'CAM-001',
+      name: 'CAM-LOBBY-01',
+      ip: '192.168.1.100',
+      port: 554,
+      username: 'admin',
+      brand: 'HIKVISION',
+      capability: 'ptz_enabled',
+      status: 'ONLINE',
+      machineId: 'M001',
+      machineName: '机器1',
+      enabled: true,
+      channels: [],
+      createdAt: '2026-01-01',
+      updatedAt: '2026-01-19'
+    },
+    {
+      id: 102,
+      cameraId: 'CAM-002',
+      name: 'CAM-ENTRANCE-01',
+      ip: '192.168.1.101',
+      port: 554,
+      username: 'admin',
+      brand: 'DAHUA',
+      capability: 'switch_only',
+      status: 'ONLINE',
+      machineId: 'M001',
+      machineName: '机器1',
+      enabled: true,
+      channels: [],
+      createdAt: '2026-01-01',
+      updatedAt: '2026-01-19'
+    },
+    {
+      id: 103,
+      cameraId: 'CAM-003',
+      name: 'CAM-WAREHOUSE-01',
+      ip: '192.168.1.102',
+      port: 554,
+      username: 'admin',
+      brand: 'HIKVISION',
+      capability: 'ptz_enabled',
+      status: 'ONLINE',
+      machineId: 'M002',
+      machineName: '机器2',
+      enabled: true,
+      channels: [],
+      createdAt: '2026-01-01',
+      updatedAt: '2026-01-19'
+    },
+    {
+      id: 104,
+      cameraId: 'CAM-004',
+      name: 'CAM-PARKING-01',
+      ip: '192.168.1.104',
+      port: 554,
+      username: 'admin',
+      brand: 'AXIS',
+      capability: 'switch_only',
+      status: 'OFFLINE',
+      machineId: 'M002',
+      machineName: '机器2',
+      enabled: true,
+      channels: [],
+      createdAt: '2026-01-01',
+      updatedAt: '2026-01-19'
+    },
+    {
+      id: 105,
+      cameraId: 'CAM-005',
+      name: 'CAM-MEETING-A',
+      ip: '192.168.1.105',
+      port: 554,
+      username: 'admin',
+      brand: 'SONY',
+      capability: 'ptz_enabled',
+      status: 'ONLINE',
+      machineId: 'M001',
+      machineName: '机器1',
+      enabled: true,
+      channels: [],
+      createdAt: '2026-01-01',
+      updatedAt: '2026-01-19'
+    }
+  ]
+}
+
+async function getList() {
+  loading.value = true
+  try {
+    // 使用测试数据(后端 API 准备好后可切换)
+    const useMockData = true
+
+    if (useMockData) {
+      // 模拟网络延迟
+      await new Promise((resolve) => setTimeout(resolve, 300))
+      let mockData = generateMockData()
+
+      // 搜索过滤
+      if (searchForm.aoAgent) {
+        mockData = mockData.filter(
+          (item) =>
+            item.streamSn.toLowerCase().includes(searchForm.aoAgent.toLowerCase()) ||
+            item.name.toLowerCase().includes(searchForm.aoAgent.toLowerCase())
+        )
+      }
+
+      streamList.value = mockData
+      total.value = mockData.length
+    } else {
+      const params: Record<string, any> = {
+        page: currentPage.value,
+        size: pageSize.value
+      }
+      if (searchForm.aoAgent) {
+        params.aoAgent = searchForm.aoAgent
+      }
+      if (searchForm.feature) {
+        params.feature = searchForm.feature
+      }
+      if (sortState.prop && sortState.order) {
+        params.sortBy = sortState.prop
+        params.sortDir = sortState.order === 'ascending' ? 'ASC' : 'DESC'
+      }
+
+      const res = await listLiveStreams(params)
+      if (res.success) {
+        streamList.value = res.data.list
+        total.value = res.data.total || 0
+      }
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+async function loadOptions() {
+  const useMockData = true
+
+  if (useMockData) {
+    lssOptions.value = generateMockLssOptions()
+    cameraOptions.value = generateMockCameraOptions()
+    return
+  }
+
+  try {
+    const [lssRes, cameraRes] = await Promise.all([getLssOptions(), getCameraOptions()])
+    if (lssRes.success && lssRes.data) {
+      lssOptions.value = lssRes.data
+    }
+    if (cameraRes.success && cameraRes.data) {
+      cameraOptions.value = cameraRes.data
+    }
+  } catch (error) {
+    console.error('加载选项失败', error)
+  }
+}
+
+function handleSearch() {
+  currentPage.value = 1
+  getList()
+}
+
+function handleReset() {
+  searchForm.aoAgent = ''
+  searchForm.feature = ''
+  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 handleEdit(row: LiveStreamDTO) {
+  Object.assign(form, {
+    id: row.id,
+    streamSn: row.streamSn,
+    name: row.name,
+    lssId: row.lssId,
+    cameraId: row.cameraId,
+    streamMethod: row.streamMethod,
+    commandTemplate: row.commandTemplate || ''
+  })
+  dialogVisible.value = true
+}
+
+function showCommandTemplate(row: LiveStreamDTO) {
+  currentTemplate.value = row.commandTemplate || ''
+  templateDialogVisible.value = true
+}
+
+async function handleStart(row: LiveStreamDTO) {
+  actionLoading.value[row.id] = true
+  try {
+    const res = await startLiveStream(row.id)
+    if (res.success) {
+      ElMessage.success(t('启动成功'))
+      getList()
+    }
+  } catch (error: any) {
+    ElMessage.error(error.message || t('启动失败'))
+  } finally {
+    actionLoading.value[row.id] = false
+  }
+}
+
+async function handleStop(row: LiveStreamDTO) {
+  actionLoading.value[row.id] = true
+  try {
+    const res = await stopLiveStream(row.id)
+    if (res.success) {
+      ElMessage.success(t('已关闭'))
+      getList()
+    }
+  } catch (error: any) {
+    ElMessage.error(error.message || t('关闭失败'))
+  } finally {
+    actionLoading.value[row.id] = false
+  }
+}
+
+function handleWatch(row: LiveStreamDTO) {
+  currentWatchUrl.value = row.playUrl || ''
+  watchDialogVisible.value = true
+}
+
+async function handleSubmit() {
+  if (!formRef.value) return
+
+  await formRef.value.validate(async (valid) => {
+    if (valid) {
+      submitLoading.value = true
+      try {
+        if (isEdit.value) {
+          const res = await updateLiveStream({
+            id: form.id!,
+            name: form.name,
+            lssId: form.lssId,
+            cameraId: form.cameraId,
+            streamMethod: form.streamMethod,
+            commandTemplate: form.commandTemplate || undefined
+          })
+          if (res.success) {
+            ElMessage.success(t('修改成功'))
+            dialogVisible.value = false
+            getList()
+          }
+        } else {
+          const res = await addLiveStream({
+            name: form.name,
+            lssId: form.lssId,
+            cameraId: form.cameraId,
+            streamMethod: form.streamMethod,
+            commandTemplate: form.commandTemplate || undefined
+          })
+          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()
+  loadOptions()
+})
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding: 1rem;
+  box-sizing: border-box;
+  overflow: hidden;
+}
+
+.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;
+  }
+
+  :deep(.el-button--primary) {
+    background-color: #4f46e5;
+    border-color: #4f46e5;
+
+    &:hover,
+    &:focus {
+      background-color: #6366f1;
+      border-color: #6366f1;
+    }
+  }
+}
+
+.table-wrapper {
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
+}
+
+.pagination-container {
+  flex-shrink: 0;
+  display: flex;
+  justify-content: flex-end;
+  padding-top: 16px;
+
+  :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;
+    }
+  }
+}
+
+: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;
+    }
+  }
+}
+
+: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;
+    }
+  }
+}
+
+.command-template {
+  background: #f5f7fa;
+  padding: 16px;
+  border-radius: 4px;
+  font-family: monospace;
+  font-size: 13px;
+  white-space: pre-wrap;
+  word-break: break-all;
+  max-height: 400px;
+  overflow: auto;
+}
+
+.watch-container {
+  video {
+    border-radius: 4px;
+  }
+
+  .watch-url {
+    margin-top: 12px;
+    padding: 8px 12px;
+    background: #f5f7fa;
+    border-radius: 4px;
+    font-size: 13px;
+
+    span {
+      color: #666;
+      margin-right: 8px;
+    }
+  }
+}
+
+.watch-empty {
+  text-align: center;
+  padding: 40px;
+  color: #999;
+}
+</style>