|
|
@@ -53,6 +53,92 @@
|
|
|
@close="lastCompletion = null"
|
|
|
/>
|
|
|
|
|
|
+ <section class="monitor-wall" aria-labelledby="monitor-wall-title">
|
|
|
+ <div class="monitor-wall-heading">
|
|
|
+ <div>
|
|
|
+ <span class="section-kicker">{{ $t.value.workbenchCamera }}</span>
|
|
|
+ <h2 id="monitor-wall-title">通道监控</h2>
|
|
|
+ </div>
|
|
|
+ <div class="monitor-wall-summary">
|
|
|
+ <span>{{ channelMonitorCards.length }} 路通道</span>
|
|
|
+ <span class="summary-dot online" />
|
|
|
+ <span>{{ monitorOnlineCount }} 路在线</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="channelMonitorCards.length" class="monitor-grid">
|
|
|
+ <article
|
|
|
+ v-for="card in channelMonitorCards"
|
|
|
+ :key="card.key"
|
|
|
+ class="monitor-card"
|
|
|
+ :class="{ selected: card.device?.device_code === selectedGateDeviceCode }"
|
|
|
+ @click="selectMonitorDevice(card.device)"
|
|
|
+ >
|
|
|
+ <div class="monitor-card-heading">
|
|
|
+ <div class="monitor-card-title">
|
|
|
+ <strong>{{ card.channelName }}</strong>
|
|
|
+ <span>{{ card.device?.device_name || card.device?.device_code || '未绑定道闸' }}</span>
|
|
|
+ </div>
|
|
|
+ <el-tag size="small" effect="plain" :type="monitorGateTagType(card.device)">
|
|
|
+ {{ monitorGateStatusLabel(card.device) }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="monitor-stage">
|
|
|
+ <img
|
|
|
+ v-if="card.camera?.protocol === 'mjpeg' && card.camera?.play_url"
|
|
|
+ class="monitor-video"
|
|
|
+ :src="card.camera.play_url"
|
|
|
+ alt=""
|
|
|
+ @load="handleMonitorReady(card.key)"
|
|
|
+ @error="handleMonitorError(card.key)"
|
|
|
+ />
|
|
|
+ <video
|
|
|
+ v-else-if="card.camera?.play_url"
|
|
|
+ :ref="el => setMonitorVideoRef(card.key, el)"
|
|
|
+ class="monitor-video"
|
|
|
+ autoplay
|
|
|
+ muted
|
|
|
+ playsinline
|
|
|
+ @loadeddata="handleMonitorReady(card.key)"
|
|
|
+ @error="handleMonitorError(card.key)"
|
|
|
+ />
|
|
|
+ <div v-else class="monitor-empty">
|
|
|
+ <el-icon><VideoCamera /></el-icon>
|
|
|
+ <span>{{ card.camera ? $t.value.workbenchCameraNoStream : '未配置摄像头' }}</span>
|
|
|
+ </div>
|
|
|
+ <span class="monitor-status" :class="monitorStatusByCode[card.key] || 'idle'">
|
|
|
+ {{ monitorStatusLabel(card) }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="monitor-card-footer">
|
|
|
+ <span class="monitor-device-state">{{ card.camera?.device_name || '摄像头未配置' }}</span>
|
|
|
+ <div class="monitor-controls" @click.stop>
|
|
|
+ <el-button
|
|
|
+ type="success"
|
|
|
+ size="small"
|
|
|
+ :icon="Unlock"
|
|
|
+ :loading="monitorBusyByCode[card.device?.device_code] === 'open'"
|
|
|
+ :disabled="!card.device?.connected || !!monitorBusyByCode[card.device?.device_code]"
|
|
|
+ @click="handleMonitorGateControl(card.device, 'open')"
|
|
|
+ >
|
|
|
+ {{ $t.value.workbenchManualOpenGate }}
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ type="warning"
|
|
|
+ size="small"
|
|
|
+ :icon="Lock"
|
|
|
+ :loading="monitorBusyByCode[card.device?.device_code] === 'close'"
|
|
|
+ :disabled="!card.device?.connected || !!monitorBusyByCode[card.device?.device_code]"
|
|
|
+ @click="handleMonitorGateControl(card.device, 'close')"
|
|
|
+ >
|
|
|
+ {{ $t.value.workbenchManualCloseGate }}
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </article>
|
|
|
+ </div>
|
|
|
+ <el-empty v-else :image-size="64" description="暂无通道设备" />
|
|
|
+ </section>
|
|
|
+
|
|
|
<main class="workbench-grid">
|
|
|
<aside class="passage-panel">
|
|
|
<div class="panel-heading">
|
|
|
@@ -152,9 +238,10 @@
|
|
|
<el-image
|
|
|
v-if="exitData?.entry_image"
|
|
|
class="entry-image"
|
|
|
- :src="exitData.entry_image"
|
|
|
+ :src="getImageUrl(exitData.entry_image)"
|
|
|
fit="cover"
|
|
|
- :preview-src-list="[exitData.entry_image]"
|
|
|
+ :preview-src-list="[getImageUrl(exitData.entry_image)]"
|
|
|
+ preview-teleported
|
|
|
:alt="$t.value.entryImg"
|
|
|
/>
|
|
|
</div>
|
|
|
@@ -165,18 +252,6 @@
|
|
|
<h3>{{ $t.value.workbenchGateDeviceTitle }}</h3>
|
|
|
</div>
|
|
|
<div class="gate-controls">
|
|
|
- <el-select
|
|
|
- v-model="selectedGateDeviceCode"
|
|
|
- :placeholder="$t.value.workbenchGatePlaceholder"
|
|
|
- @change="handleGateDeviceChange"
|
|
|
- >
|
|
|
- <el-option
|
|
|
- v-for="device in availableGateDevices"
|
|
|
- :key="device.device_code"
|
|
|
- :label="gateDeviceLabel(device)"
|
|
|
- :value="device.device_code"
|
|
|
- />
|
|
|
- </el-select>
|
|
|
<el-tag v-if="selectedGateDevice" :type="selectedGateDevice.simulated ? 'warning' : selectedGateDevice.connected ? 'success' : 'danger'" effect="plain">
|
|
|
{{ selectedGateDevice.simulated ? $t.value.workbenchGateSimulated : selectedGateDevice.connected ? $t.value.workbenchGateOnline : $t.value.workbenchGateOffline }}
|
|
|
</el-tag>
|
|
|
@@ -336,7 +411,7 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
|
|
+import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
import {
|
|
|
BottomRight,
|
|
|
@@ -348,13 +423,16 @@ import {
|
|
|
Tickets,
|
|
|
TopRight,
|
|
|
Unlock,
|
|
|
+ VideoCamera,
|
|
|
WarningFilled
|
|
|
} from '@element-plus/icons-vue'
|
|
|
import { getChannelEvents, getVehicleOperationContext, handleVehiclePassage } from '@/api/vehicle'
|
|
|
import { controlGate, getGateDevices } from '@/api/gate'
|
|
|
+import { getCameraStreams } from '@/api/camera'
|
|
|
import { obtainPullOverList } from '@/api/pullOver'
|
|
|
import { getEnabledPaymentMethods, getPaymentEntryOptions } from '@/api/payment'
|
|
|
import { t } from '@/lang/index.js'
|
|
|
+import { getUrl } from '@/utils/image'
|
|
|
|
|
|
const queryForm = reactive({ plate_number: '', rfid_tag: '' })
|
|
|
const entryForm = reactive({ parking_lot_id: null })
|
|
|
@@ -365,6 +443,8 @@ const contextData = ref(null)
|
|
|
const submitting = ref(false)
|
|
|
const lastCompletion = ref(null)
|
|
|
|
|
|
+const getImageUrl = (url) => getUrl(url)
|
|
|
+
|
|
|
const paymentEntry = 'counter'
|
|
|
const paymentEntries = ref([])
|
|
|
const enabledPaymentMethods = ref([])
|
|
|
@@ -373,6 +453,13 @@ const paidAmount = ref(0)
|
|
|
const gateDevices = ref([])
|
|
|
const selectedGateDeviceCode = ref('')
|
|
|
const gateBusy = ref('')
|
|
|
+const cameras = ref([])
|
|
|
+const monitorStatusByCode = reactive({})
|
|
|
+const monitorErrorByCode = reactive({})
|
|
|
+const monitorBusyByCode = reactive({})
|
|
|
+const monitorVideoRefs = new Map()
|
|
|
+const monitorPeers = new Map()
|
|
|
+const monitorGenerations = new Map()
|
|
|
|
|
|
const recentEvents = ref([])
|
|
|
const lastEventID = ref(0)
|
|
|
@@ -425,6 +512,38 @@ const availableGateDevices = computed(() => gateDevices.value.filter(device => {
|
|
|
const selectedGateDevice = computed(() => gateDevices.value.find(device => device.device_code === selectedGateDeviceCode.value) || null)
|
|
|
const gateReady = computed(() => Boolean(selectedGateDevice.value?.connected))
|
|
|
const hasOnlineGateDevice = computed(() => availableGateDevices.value.some(device => device.connected))
|
|
|
+const channelMonitorCards = computed(() => {
|
|
|
+ const cards = []
|
|
|
+ const cardsByChannel = new Map()
|
|
|
+ const usedCameraCodes = new Set()
|
|
|
+ gateDevices.value.forEach(device => {
|
|
|
+ const channelKey = String(device.channel_id || device.channel_code || device.device_code)
|
|
|
+ const camera = cameras.value.find(item => String(item.channel_id) === String(device.channel_id)) || null
|
|
|
+ if (camera) usedCameraCodes.add(camera.device_code)
|
|
|
+ const existing = cardsByChannel.get(channelKey)
|
|
|
+ if (!existing || (!existing.device?.connected && device.connected)) {
|
|
|
+ const card = {
|
|
|
+ key: `channel-${channelKey}`,
|
|
|
+ channelName: device.channel_name || device.channel_code || `通道 ${device.channel_id || '-'}`,
|
|
|
+ device,
|
|
|
+ camera
|
|
|
+ }
|
|
|
+ cardsByChannel.set(channelKey, card)
|
|
|
+ if (!existing) cards.push(card)
|
|
|
+ else cards[cards.indexOf(existing)] = card
|
|
|
+ }
|
|
|
+ })
|
|
|
+ cameras.value.filter(camera => !usedCameraCodes.has(camera.device_code)).forEach(camera => {
|
|
|
+ cards.push({
|
|
|
+ key: `camera-${camera.device_code || camera.channel_id}`,
|
|
|
+ channelName: camera.channel_name || `通道 ${camera.channel_id || '-'}`,
|
|
|
+ device: null,
|
|
|
+ camera
|
|
|
+ })
|
|
|
+ })
|
|
|
+ return cards
|
|
|
+})
|
|
|
+const monitorOnlineCount = computed(() => channelMonitorCards.value.filter(card => card.device?.connected || card.camera?.online).length)
|
|
|
|
|
|
onMounted(async () => {
|
|
|
await loadReferenceData()
|
|
|
@@ -437,25 +556,24 @@ onUnmounted(() => {
|
|
|
clearInterval(eventTimer)
|
|
|
clearTimeout(scanTimer)
|
|
|
document.removeEventListener('keydown', onScanKey)
|
|
|
+ channelMonitorCards.value.forEach(card => disconnectMonitor(card.key))
|
|
|
})
|
|
|
|
|
|
async function loadReferenceData() {
|
|
|
- try {
|
|
|
- const [lotRes, entryRes, gateRes] = await Promise.all([
|
|
|
- obtainPullOverList({ page: 1, page_size: 100 }),
|
|
|
- getPaymentEntryOptions(),
|
|
|
- getGateDevices()
|
|
|
- ])
|
|
|
- parkingLotList.value = lotRes.code === 0 ? (lotRes.data?.list || []) : []
|
|
|
- paymentEntries.value = entryRes.code === 0 ? (entryRes.data || []) : []
|
|
|
- gateDevices.value = gateRes.code === 0 && Array.isArray(gateRes.data) ? gateRes.data : []
|
|
|
- await loadPaymentMethods()
|
|
|
- } catch (error) {
|
|
|
- parkingLotList.value = []
|
|
|
- paymentEntries.value = []
|
|
|
- enabledPaymentMethods.value = []
|
|
|
- gateDevices.value = []
|
|
|
- }
|
|
|
+ const [lotRes, entryRes, gateRes, cameraRes] = await Promise.all([
|
|
|
+ obtainPullOverList({ page: 1, page_size: 100 }).catch(() => ({ code: 1, data: {} })),
|
|
|
+ getPaymentEntryOptions().catch(() => ({ code: 1, data: [] })),
|
|
|
+ getGateDevices().catch(() => ({ code: 1, data: [] })),
|
|
|
+ getCameraStreams().catch(() => ({ code: 1, data: [] }))
|
|
|
+ ])
|
|
|
+ parkingLotList.value = lotRes.code === 0 ? (lotRes.data?.list || []) : []
|
|
|
+ paymentEntries.value = entryRes.code === 0 ? (entryRes.data || []) : []
|
|
|
+ gateDevices.value = gateRes.code === 0 && Array.isArray(gateRes.data) ? gateRes.data : []
|
|
|
+ cameras.value = cameraRes.code === 0 && Array.isArray(cameraRes.data) ? cameraRes.data : []
|
|
|
+ assignGateDevice()
|
|
|
+ await nextTick()
|
|
|
+ channelMonitorCards.value.forEach(card => connectMonitor(card))
|
|
|
+ await loadPaymentMethods()
|
|
|
}
|
|
|
|
|
|
async function loadPaymentMethods() {
|
|
|
@@ -611,7 +729,7 @@ function handlePaymentMethodChange() {
|
|
|
function assignGateDevice(action = contextAction.value) {
|
|
|
const direction = action === 'entry' ? 'in' : action === 'exit' ? 'out' : action
|
|
|
const current = gateDevices.value.find(device => device.device_code === selectedGateDeviceCode.value)
|
|
|
- const matches = device => device.connected && (device.direction === direction || device.direction === 'inout') &&
|
|
|
+ const matches = device => device.connected && (!direction || device.direction === direction || device.direction === 'inout') &&
|
|
|
(action !== 'entry' || !entryForm.parking_lot_id || !device.parking_lot_id || device.parking_lot_id === entryForm.parking_lot_id)
|
|
|
if (current && matches(current)) return
|
|
|
const next = gateDevices.value.find(matches)
|
|
|
@@ -626,6 +744,8 @@ function handleGateDeviceChange(deviceCode) {
|
|
|
if (contextAction.value === 'entry' && device?.parking_lot_id) {
|
|
|
entryForm.parking_lot_id = device.parking_lot_id
|
|
|
}
|
|
|
+ const card = channelMonitorCards.value.find(item => item.device?.device_code === deviceCode)
|
|
|
+ if (card) selectMonitorDevice(card.device)
|
|
|
}
|
|
|
|
|
|
function handleParkingLotChange() {
|
|
|
@@ -718,10 +838,137 @@ function clearCurrentVehicle() {
|
|
|
contextError.value = ''
|
|
|
entryForm.parking_lot_id = null
|
|
|
selectedGateDeviceCode.value = ''
|
|
|
+ channelMonitorCards.value.forEach(card => disconnectMonitor(card.key))
|
|
|
paymentMethod.value = enabledPaymentMethods.value[0]?.code || ''
|
|
|
paidAmount.value = 0
|
|
|
}
|
|
|
|
|
|
+function setMonitorVideoRef(key, element) {
|
|
|
+ if (element) monitorVideoRefs.set(key, element)
|
|
|
+ else monitorVideoRefs.delete(key)
|
|
|
+}
|
|
|
+
|
|
|
+function handleMonitorReady(key) {
|
|
|
+ monitorStatusByCode[key] = 'playing'
|
|
|
+ delete monitorErrorByCode[key]
|
|
|
+}
|
|
|
+
|
|
|
+function handleMonitorError(key) {
|
|
|
+ monitorStatusByCode[key] = 'error'
|
|
|
+ monitorErrorByCode[key] = t.value.workbenchCameraError
|
|
|
+}
|
|
|
+
|
|
|
+function monitorStatusLabel(card) {
|
|
|
+ const status = monitorStatusByCode[card.key]
|
|
|
+ if (!card.camera) return '无摄像头'
|
|
|
+ if (status === 'playing') return '视频在线'
|
|
|
+ if (status === 'error') return '视频异常'
|
|
|
+ if (!card.camera.online) return t.value.workbenchCameraOffline
|
|
|
+ return t.value.workbenchCameraConnecting
|
|
|
+}
|
|
|
+
|
|
|
+function monitorGateStatusLabel(device) {
|
|
|
+ if (!device) return '无道闸'
|
|
|
+ if (device.simulated) return t.value.workbenchGateSimulated
|
|
|
+ return device.connected ? t.value.workbenchGateOnline : t.value.workbenchGateOffline
|
|
|
+}
|
|
|
+
|
|
|
+function monitorGateTagType(device) {
|
|
|
+ if (!device?.connected) return 'danger'
|
|
|
+ return device.simulated ? 'warning' : 'success'
|
|
|
+}
|
|
|
+
|
|
|
+function selectMonitorDevice(device) {
|
|
|
+ if (!device?.device_code) return
|
|
|
+ selectedGateDeviceCode.value = device.device_code
|
|
|
+ if (contextAction.value === 'entry' && device.parking_lot_id) entryForm.parking_lot_id = device.parking_lot_id
|
|
|
+}
|
|
|
+
|
|
|
+function disconnectMonitor(key) {
|
|
|
+ monitorGenerations.set(key, (monitorGenerations.get(key) || 0) + 1)
|
|
|
+ const peer = monitorPeers.get(key)
|
|
|
+ if (peer) peer.close()
|
|
|
+ monitorPeers.delete(key)
|
|
|
+ const video = monitorVideoRefs.get(key)
|
|
|
+ if (video?.srcObject) video.srcObject = null
|
|
|
+ monitorStatusByCode[key] = 'idle'
|
|
|
+}
|
|
|
+
|
|
|
+async function connectMonitor(card) {
|
|
|
+ disconnectMonitor(card.key)
|
|
|
+ const generation = monitorGenerations.get(card.key)
|
|
|
+ const camera = card.camera
|
|
|
+ if (!camera?.play_url) return
|
|
|
+ monitorStatusByCode[card.key] = 'connecting'
|
|
|
+ if (camera.protocol !== 'webrtc') {
|
|
|
+ await nextTick()
|
|
|
+ const video = monitorVideoRefs.get(card.key)
|
|
|
+ if (video) {
|
|
|
+ video.src = camera.play_url
|
|
|
+ video.load()
|
|
|
+ video.play().catch(() => {})
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!window.RTCPeerConnection) {
|
|
|
+ handleMonitorError(card.key)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const peer = new RTCPeerConnection()
|
|
|
+ monitorPeers.set(card.key, peer)
|
|
|
+ peer.addTransceiver('video', { direction: 'recvonly' })
|
|
|
+ peer.ontrack = event => {
|
|
|
+ if (generation !== monitorGenerations.get(card.key)) return
|
|
|
+ const video = monitorVideoRefs.get(card.key)
|
|
|
+ if (video) {
|
|
|
+ video.srcObject = event.streams[0]
|
|
|
+ video.play().catch(() => {})
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const offer = await peer.createOffer()
|
|
|
+ await peer.setLocalDescription(offer)
|
|
|
+ const answer = await fetch(camera.play_url, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/sdp' },
|
|
|
+ body: offer.sdp
|
|
|
+ })
|
|
|
+ if (!answer.ok) throw new Error(`WHEP ${answer.status}`)
|
|
|
+ if (generation !== monitorGenerations.get(card.key)) return
|
|
|
+ await peer.setRemoteDescription({ type: 'answer', sdp: await answer.text() })
|
|
|
+ } catch (error) {
|
|
|
+ if (generation === monitorGenerations.get(card.key)) handleMonitorError(card.key)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function handleMonitorGateControl(device, action) {
|
|
|
+ if (!device?.connected || monitorBusyByCode[device.device_code]) return
|
|
|
+ const isOpen = action === 'open'
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm(
|
|
|
+ isOpen ? t.value.workbenchOpenGateConfirm : t.value.workbenchCloseGateConfirm,
|
|
|
+ t.value.workbenchGateConfirmTitle,
|
|
|
+ {
|
|
|
+ confirmButtonText: isOpen ? t.value.workbenchManualOpenGate : t.value.workbenchManualCloseGate,
|
|
|
+ cancelButtonText: t.value.Cancel,
|
|
|
+ type: 'warning'
|
|
|
+ }
|
|
|
+ )
|
|
|
+ } catch (error) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ monitorBusyByCode[device.device_code] = action
|
|
|
+ try {
|
|
|
+ const res = await controlGate(action, { device_code: device.device_code, valid_time: 2 })
|
|
|
+ if (res.code !== 0) throw new Error(res.msg || t.value.workbenchGateOperationFailed)
|
|
|
+ ElMessage.success(isOpen ? t.value.workbenchGateOpenSuccess : t.value.workbenchGateCloseSuccess)
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error(error?.message || t.value.workbenchGateOperationFailed)
|
|
|
+ } finally {
|
|
|
+ delete monitorBusyByCode[device.device_code]
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
function shortRFID(value) {
|
|
|
if (!value) return ''
|
|
|
return value.length > 12 ? `${value.slice(0, 6)}…${value.slice(-4)}` : value
|
|
|
@@ -869,6 +1116,47 @@ function formatDuration(minutes) {
|
|
|
.identity-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
|
|
.completion-alert { margin-top: 14px; }
|
|
|
|
|
|
+.monitor-wall {
|
|
|
+ margin-top: 16px;
|
|
|
+ border: 1px solid var(--line);
|
|
|
+ background: #172029;
|
|
|
+ color: #f5f8fa;
|
|
|
+}
|
|
|
+.monitor-wall-heading {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 16px;
|
|
|
+ min-height: 66px;
|
|
|
+ padding: 0 18px;
|
|
|
+ border-bottom: 1px solid #33414c;
|
|
|
+}
|
|
|
+.monitor-wall-heading h2 { margin: 4px 0 0; color: #fff; font-size: 17px; }
|
|
|
+.monitor-wall-heading .section-kicker { color: #9eacb7; }
|
|
|
+.monitor-wall-summary { display: flex; align-items: center; gap: 8px; color: #b8c5cd; font-size: 12px; }
|
|
|
+.summary-dot { width: 7px; height: 7px; border-radius: 50%; background: #75818a; }
|
|
|
+.summary-dot.online { background: #3fc18a; box-shadow: 0 0 0 3px rgba(63, 193, 138, .15); }
|
|
|
+.monitor-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px; padding: 12px; }
|
|
|
+.monitor-card { min-width: 0; border: 1px solid #34434e; background: #202c35; transition: border-color .18s ease, transform .18s ease; }
|
|
|
+.monitor-card:hover, .monitor-card.selected { border-color: #55b893; }
|
|
|
+.monitor-card.selected { box-shadow: inset 3px 0 0 #55b893; }
|
|
|
+.monitor-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 11px 12px 9px; }
|
|
|
+.monitor-card-title { min-width: 0; }
|
|
|
+.monitor-card-title strong { display: block; overflow: hidden; color: #fff; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
+.monitor-card-title span { display: block; margin-top: 3px; overflow: hidden; color: #9eacb7; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
+.monitor-stage { position: relative; aspect-ratio: 16 / 9; overflow: hidden; background: #080d11; }
|
|
|
+.monitor-video { display: block; width: 100%; height: 100%; object-fit: contain; background: #080d11; }
|
|
|
+.monitor-empty { display: grid; place-items: center; align-content: center; height: 100%; gap: 6px; color: #8797a3; font-size: 12px; }
|
|
|
+.monitor-empty .el-icon { font-size: 27px; }
|
|
|
+.monitor-status { position: absolute; left: 9px; bottom: 8px; padding: 3px 7px; color: #d4dde2; background: rgba(10, 16, 20, .78); font-size: 11px; }
|
|
|
+.monitor-status.playing { color: #8ce0b8; }
|
|
|
+.monitor-status.error { color: #ffb0b0; }
|
|
|
+.monitor-card-footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border-top: 1px solid #34434e; }
|
|
|
+.monitor-device-state { min-width: 0; overflow: hidden; color: #aab8c0; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
+.monitor-controls { display: flex; flex: 0 0 auto; gap: 6px; }
|
|
|
+.monitor-controls :deep(.el-button + .el-button) { margin-left: 0; }
|
|
|
+.monitor-wall > .el-empty { margin: 0; padding: 36px 0; color: #aab8c0; }
|
|
|
+
|
|
|
.workbench-grid {
|
|
|
display: grid;
|
|
|
grid-template-columns: minmax(270px, 31%) minmax(0, 1fr);
|
|
|
@@ -926,6 +1214,18 @@ function formatDuration(minutes) {
|
|
|
.detail-list dt { color: var(--muted); font-size: 12px; }
|
|
|
.detail-list dd { margin: 4px 0 0; overflow: hidden; font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
.entry-image { width: 180px; aspect-ratio: 16 / 9; border-radius: 4px; background: var(--soft); }
|
|
|
+.camera-monitor-panel { padding: 14px 22px; border-bottom: 1px solid var(--line); background: #111820; color: #fff; }
|
|
|
+.camera-monitor-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
|
|
+.camera-monitor-heading h3 { margin: 4px 0 0; color: #fff; font-size: 15px; }
|
|
|
+.camera-monitor-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; flex-wrap: wrap; }
|
|
|
+.camera-gate-picker { display: flex; align-items: center; gap: 7px; padding: 4px 6px 4px 9px; border: 1px solid #3a4652; border-radius: 4px; background: #1b2630; }
|
|
|
+.camera-gate-label { color: #d9e2ea; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
|
|
+.camera-gate-select { width: 245px; }
|
|
|
+.camera-monitor-stage { position: relative; min-height: 190px; overflow: hidden; background: #05080b; }
|
|
|
+.camera-video { display: block; width: 100%; max-height: 360px; aspect-ratio: 16 / 9; object-fit: contain; background: #05080b; }
|
|
|
+.camera-monitor-empty { display: grid; place-items: center; min-height: 190px; gap: 8px; color: #a8b4c0; font-size: 13px; }
|
|
|
+.camera-monitor-empty .el-icon { font-size: 30px; }
|
|
|
+.camera-monitor-error { position: absolute; right: 10px; bottom: 10px; left: 10px; padding: 7px 10px; color: #ffd7d7; background: rgba(115, 20, 20, .86); font-size: 12px; }
|
|
|
|
|
|
.gate-control-strip {
|
|
|
display: flex;
|
|
|
@@ -937,8 +1237,7 @@ function formatDuration(minutes) {
|
|
|
background: #fbfcfc;
|
|
|
}
|
|
|
.gate-control-copy h3 { margin: 4px 0 0; font-size: 15px; letter-spacing: 0; }
|
|
|
-.gate-controls { display: grid; grid-template-columns: minmax(230px, 1fr) auto auto auto; gap: 8px; align-items: center; min-width: 0; }
|
|
|
-.gate-controls :deep(.el-select) { min-width: 0; }
|
|
|
+.gate-controls { display: grid; grid-template-columns: auto auto auto; gap: 8px; align-items: center; min-width: 0; }
|
|
|
.gate-controls :deep(.el-button + .el-button) { margin-left: 0; }
|
|
|
.gate-alert { border-radius: 0; }
|
|
|
|
|
|
@@ -999,6 +1298,7 @@ function formatDuration(minutes) {
|
|
|
.entry-zone { align-items: stretch; flex-direction: column; }
|
|
|
.entry-controls { grid-template-columns: 1fr; }
|
|
|
.gate-controls { grid-template-columns: minmax(0, 1fr) auto auto auto; }
|
|
|
+ .monitor-grid { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
|
|
|
}
|
|
|
|
|
|
@media (max-width: 560px) {
|
|
|
@@ -1018,5 +1318,14 @@ function formatDuration(minutes) {
|
|
|
.confirm-bar .el-button { width: 100%; }
|
|
|
.gate-controls { grid-template-columns: 1fr auto auto; }
|
|
|
.gate-controls > .el-tag { grid-column: 1 / -1; grid-row: 2; justify-self: start; }
|
|
|
+ .camera-monitor-heading { align-items: stretch; flex-direction: column; }
|
|
|
+ .camera-monitor-actions { justify-content: flex-start; }
|
|
|
+ .camera-gate-picker { width: 100%; justify-content: space-between; }
|
|
|
+ .camera-gate-select { flex: 1; width: auto; min-width: 0; }
|
|
|
+ .monitor-wall-heading { align-items: flex-start; flex-direction: column; gap: 4px; padding-top: 12px; padding-bottom: 12px; }
|
|
|
+ .monitor-grid { grid-template-columns: 1fr; padding: 8px; }
|
|
|
+ .monitor-card-footer { align-items: stretch; flex-direction: column; }
|
|
|
+ .monitor-controls { display: grid; grid-template-columns: 1fr 1fr; }
|
|
|
+ .monitor-controls .el-button { width: 100%; }
|
|
|
}
|
|
|
</style>
|