|
|
@@ -0,0 +1,175 @@
|
|
|
+<template>
|
|
|
+ <div v-loading="loading">
|
|
|
+ <div class="camera-toolbar">
|
|
|
+ <div class="camera-toolbar-copy">
|
|
|
+ <span class="camera-toolbar-title">摄像头与视频流</span>
|
|
|
+ <span class="camera-toolbar-note">绑定通道后,实时画面会显示在进出场操作页。</span>
|
|
|
+ </div>
|
|
|
+ <div class="camera-toolbar-actions">
|
|
|
+ <el-button type="success" icon="Plus" @click="openDialog('add')">新增摄像头</el-button>
|
|
|
+ <el-button icon="Refresh" @click="fetchData">刷新</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-table :data="list" size="small" height="505" border stripe>
|
|
|
+ <el-table-column label="摄像头" min-width="170">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <div class="camera-name">{{ row.device_name }}</div>
|
|
|
+ <div class="camera-code">{{ row.device_code }}</div>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="绑定通道" min-width="150">
|
|
|
+ <template #default="{ row }">{{ row.channel_binding?.channel_name || `通道 #${row.channel_id}` }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="设备地址" min-width="160">
|
|
|
+ <template #default="{ row }">{{ row.ip_address }}:{{ row.port }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="播放方式" width="130">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-tag :type="row.stream_protocol === 'system-mjpeg' ? 'success' : 'info'">
|
|
|
+ {{ protocolLabel(row.stream_protocol) }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="启用" width="80" align="center">
|
|
|
+ <template #default="{ row }"><el-tag :type="row.is_active ? 'success' : 'info'">{{ row.is_active ? '是' : '否' }}</el-tag></template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="状态" width="90" align="center">
|
|
|
+ <template #default="{ row }"><el-tag :type="isOnline(row.status) ? 'success' : 'danger'">{{ isOnline(row.status) ? '在线' : '离线' }}</el-tag></template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作" width="170" fixed="right" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-button text type="primary" icon="Edit" @click="openDialog('edit', row)">配置</el-button>
|
|
|
+ <el-button text type="danger" icon="Delete" @click="handleDelete(row.ID)">删除</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <div class="camera-pagination">
|
|
|
+ <el-pagination v-model:current-page="query.page" v-model:page-size="query.page_size" :page-sizes="[10, 30, 50, 100]" :total="total" layout="total, sizes, prev, pager, next, jumper" @current-change="fetchData" @size-change="onSizeChange" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-dialog v-model="dialogVisible" :title="isEdit ? '配置摄像头' : '新增摄像头'" width="760" :close-on-click-modal="false">
|
|
|
+ <el-alert type="info" :closable="false" show-icon>
|
|
|
+ <template #title>系统转换使用 RTSP/HTTP 原始地址,经本机 FFmpeg 转为浏览器可播放画面;边缘转换则填写边缘端提供的播放地址。</template>
|
|
|
+ </el-alert>
|
|
|
+ <el-form label-width="128" class="camera-form">
|
|
|
+ <div class="form-grid">
|
|
|
+ <el-form-item label="摄像头编码" required><el-input v-model.trim="form.device_code" placeholder="例如 CAM-IN-01" /></el-form-item>
|
|
|
+ <el-form-item label="摄像头名称" required><el-input v-model.trim="form.device_name" placeholder="例如 入口摄像头" /></el-form-item>
|
|
|
+ <el-form-item label="绑定通道" required>
|
|
|
+ <el-select v-model="form.channel_id" placeholder="请选择通道" style="width: 100%"><el-option v-for="item in channelList" :key="item.ID" :label="`${item.channel_name} (${directionLabel(item.direction)})`" :value="item.ID" /></el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="摄像头 IP" required><el-input v-model.trim="form.ip_address" placeholder="例如 192.168.110.67" /></el-form-item>
|
|
|
+ <el-form-item label="RTSP 端口" required><el-input-number v-model="form.port" :min="1" :max="65535" controls-position="right" style="width: 100%" /></el-form-item>
|
|
|
+ <el-form-item label="设备通道号"><el-input-number v-model="form.channel" :min="1" :max="999" controls-position="right" style="width: 100%" /></el-form-item>
|
|
|
+ </div>
|
|
|
+ <el-form-item label="播放方式" required>
|
|
|
+ <el-radio-group v-model="form.stream_protocol">
|
|
|
+ <el-radio value="system-mjpeg">系统端转换</el-radio>
|
|
|
+ <el-radio value="webrtc">边缘 WebRTC</el-radio>
|
|
|
+ <el-radio value="hls">边缘 HLS</el-radio>
|
|
|
+ <el-radio value="mp4">MP4 地址</el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item v-if="form.stream_protocol === 'system-mjpeg'" label="RTSP/HTTP 原始地址" required>
|
|
|
+ <el-input v-model.trim="form.source_url" placeholder="rtsp://用户名:密码@192.168.110.67:554/实际视频路径;编辑时保留掩码即可" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item v-else label="浏览器播放地址" required>
|
|
|
+ <el-input v-model.trim="form.stream_url" placeholder="例如 https://edge.example.com/live/whep 或 https://.../index.m3u8" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="抓拍地址"><el-input v-model.trim="form.snapshot_url" placeholder="可选,用于抓拍图片接口" /></el-form-item>
|
|
|
+ <el-form-item label="启用"><el-switch v-model="form.is_active" active-text="启用" inactive-text="停用" /></el-form-item>
|
|
|
+ <el-form-item label="备注"><el-input v-model="form.description" type="textarea" :rows="2" /></el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitForm">保存配置</el-button></template>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
|
|
+import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
+import { addCamera, deleteCamera, editCamera, obtainCameraList } from '@/api/camera'
|
|
|
+import { obtainChannelList } from '@/api/channel'
|
|
|
+
|
|
|
+const props = defineProps({ parkId: { type: Number, default: 0 } })
|
|
|
+const loading = ref(false)
|
|
|
+const saving = ref(false)
|
|
|
+const list = ref([])
|
|
|
+const total = ref(0)
|
|
|
+const channelList = ref([])
|
|
|
+const dialogVisible = ref(false)
|
|
|
+const isEdit = ref(false)
|
|
|
+const query = reactive({ parking_lot_id: 0, page: 1, page_size: 10 })
|
|
|
+const form = reactive({ id: 0, device_code: '', device_name: '', channel_id: null, ip_address: '', port: 554, username: '', password: '', channel: 1, is_active: true, stream_protocol: 'system-mjpeg', source_url: '', stream_url: '', snapshot_url: '', description: '' })
|
|
|
+
|
|
|
+const protocolLabel = (protocol) => ({ 'system-mjpeg': '系统端转换', webrtc: '边缘 WebRTC', hls: '边缘 HLS', mp4: 'MP4 地址' }[protocol] || protocol)
|
|
|
+const directionLabel = (direction) => ({ in: '进场', out: '出场', inout: '进出' }[direction] || direction)
|
|
|
+const isOnline = (status) => status === 'online' || status === 'connected'
|
|
|
+
|
|
|
+const fetchData = async () => {
|
|
|
+ if (!props.parkId) { list.value = []; total.value = 0; return }
|
|
|
+ loading.value = true
|
|
|
+ try {
|
|
|
+ query.parking_lot_id = props.parkId
|
|
|
+ const res = await obtainCameraList({ ...query })
|
|
|
+ if (res.code === 0) { list.value = res.data.list || []; total.value = res.data.total || 0 }
|
|
|
+ } finally { loading.value = false }
|
|
|
+}
|
|
|
+
|
|
|
+const fetchChannels = async () => {
|
|
|
+ const res = await obtainChannelList({ parking_lot_id: props.parkId, page: 1, page_size: 100 })
|
|
|
+ if (res.code === 0) channelList.value = res.data.list || []
|
|
|
+}
|
|
|
+
|
|
|
+const resetForm = () => Object.assign(form, { id: 0, device_code: '', device_name: '', channel_id: null, ip_address: '', port: 554, username: '', password: '', channel: 1, is_active: true, stream_protocol: 'system-mjpeg', source_url: '', stream_url: '', snapshot_url: '', description: '' })
|
|
|
+
|
|
|
+const openDialog = async (mode, row) => {
|
|
|
+ if (!props.parkId) { ElMessage.warning('请先选择停车场'); return }
|
|
|
+ await fetchChannels()
|
|
|
+ isEdit.value = mode === 'edit'
|
|
|
+ resetForm()
|
|
|
+ if (row) Object.assign(form, { id: row.ID, device_code: row.device_code, device_name: row.device_name, channel_id: row.channel_id, ip_address: row.ip_address, port: row.port, username: row.username, channel: row.channel || 1, is_active: row.is_active, stream_protocol: row.stream_protocol || 'system-mjpeg', source_url: row.source_url, stream_url: row.stream_url, snapshot_url: row.snapshot_url, description: row.description })
|
|
|
+ dialogVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+const submitForm = async () => {
|
|
|
+ if (!form.device_code || !form.device_name || !form.channel_id || !form.ip_address) { ElMessage.error('请填写摄像头编码、名称、IP 并绑定通道'); return }
|
|
|
+ if (form.stream_protocol === 'system-mjpeg' && !form.source_url) { ElMessage.error('系统端转换必须填写 RTSP/HTTP 原始地址'); return }
|
|
|
+ if (form.stream_protocol !== 'system-mjpeg' && !form.stream_url) { ElMessage.error('边缘播放模式必须填写浏览器播放地址'); return }
|
|
|
+ saving.value = true
|
|
|
+ try {
|
|
|
+ const res = isEdit.value ? await editCamera(form) : await addCamera(form)
|
|
|
+ if (res.code !== 0) throw new Error(res.msg || '保存失败')
|
|
|
+ ElMessage.success('摄像头配置已保存')
|
|
|
+ dialogVisible.value = false
|
|
|
+ await fetchData()
|
|
|
+ } catch (error) { ElMessage.error(error.message || '保存失败') } finally { saving.value = false }
|
|
|
+}
|
|
|
+
|
|
|
+const handleDelete = (id) => ElMessageBox.confirm('删除后进出场页面将不再显示此摄像头,确定继续吗?', '删除摄像头', { type: 'warning' }).then(async () => {
|
|
|
+ const res = await deleteCamera(id)
|
|
|
+ if (res.code === 0) { ElMessage.success('摄像头已删除'); await fetchData() } else ElMessage.error(res.msg || '删除失败')
|
|
|
+}).catch(() => {})
|
|
|
+
|
|
|
+const onSizeChange = (size) => { query.page_size = size; query.page = 1; fetchData() }
|
|
|
+watch(() => props.parkId, () => { query.page = 1; fetchData() }, { immediate: true })
|
|
|
+
|
|
|
+let refreshTimer
|
|
|
+onMounted(() => { refreshTimer = window.setInterval(fetchData, 10000) })
|
|
|
+onUnmounted(() => { if (refreshTimer) window.clearInterval(refreshTimer) })
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.camera-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; padding: 12px 14px; border-left: 3px solid #0d9b8a; background: #f4fbfa; }
|
|
|
+.camera-toolbar-copy { display: grid; gap: 3px; min-width: 0; }
|
|
|
+.camera-toolbar-title { color: #183d45; font-size: 15px; font-weight: 650; }
|
|
|
+.camera-toolbar-note, .camera-code { color: #71808a; font-size: 12px; }
|
|
|
+.camera-toolbar-actions, .camera-pagination { display: flex; justify-content: flex-end; gap: 8px; }
|
|
|
+.camera-pagination { margin-top: 10px; }
|
|
|
+.camera-name { color: #26363d; font-weight: 600; }
|
|
|
+.camera-form { margin-top: 18px; }
|
|
|
+.form-grid { display: grid; grid-template-columns: 1fr 1fr; column-gap: 16px; }
|
|
|
+@media (max-width: 760px) { .camera-toolbar { align-items: flex-start; flex-direction: column; } .form-grid { grid-template-columns: 1fr; } }
|
|
|
+</style>
|