|
@@ -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>
|