|
|
|
|
@ -0,0 +1,710 @@
|
|
|
|
|
<template>
|
|
|
|
|
<div class="home-page">
|
|
|
|
|
<!-- 左侧:视频区 -->
|
|
|
|
|
<div class="left-section">
|
|
|
|
|
<!-- 顶部工具栏 -->
|
|
|
|
|
<div class="video-toolbar">
|
|
|
|
|
<div class="stream-info">
|
|
|
|
|
<span class="label">实时视频流:</span>
|
|
|
|
|
<el-input
|
|
|
|
|
v-model="streamAddress"
|
|
|
|
|
size="small"
|
|
|
|
|
placeholder="输入地址如: 10.23.22.xx"
|
|
|
|
|
class="stream-input"
|
|
|
|
|
/>
|
|
|
|
|
<el-button type="primary" size="small" @click="switchStream">切换</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="upload-area">
|
|
|
|
|
<el-upload
|
|
|
|
|
:auto-upload="false"
|
|
|
|
|
:show-file-list="false"
|
|
|
|
|
accept="video/*"
|
|
|
|
|
:on-change="handleVideoUpload"
|
|
|
|
|
>
|
|
|
|
|
<el-button size="small">
|
|
|
|
|
<el-icon :size="14"><Upload /></el-icon>
|
|
|
|
|
离线视频
|
|
|
|
|
</el-button>
|
|
|
|
|
</el-upload>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 视频画面 -->
|
|
|
|
|
<div class="video-container">
|
|
|
|
|
<!-- 实时流模式 -->
|
|
|
|
|
<WebRtcPlayer
|
|
|
|
|
v-if="videoMode === 'live'"
|
|
|
|
|
ref="webrtcPlayerRef"
|
|
|
|
|
:src="webrtcUrl"
|
|
|
|
|
@connection-status="onConnectionChange"
|
|
|
|
|
/>
|
|
|
|
|
<!-- 离线视频模式 -->
|
|
|
|
|
<div v-else-if="videoMode === 'offline'" class="offline-player">
|
|
|
|
|
<video
|
|
|
|
|
ref="offlineVideoRef"
|
|
|
|
|
:src="offlineVideoUrl"
|
|
|
|
|
class="video-element"
|
|
|
|
|
@loadedmetadata="onVideoLoaded"
|
|
|
|
|
@timeupdate="onTimeUpdate"
|
|
|
|
|
@ended="onVideoEnded"
|
|
|
|
|
></video>
|
|
|
|
|
<!-- 进度条 -->
|
|
|
|
|
<div class="video-controls" v-if="videoDuration > 0">
|
|
|
|
|
<div class="progress-wrapper">
|
|
|
|
|
<div class="progress-track" @click="seekVideo">
|
|
|
|
|
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="time-display">{{ formatTime(currentVideoTime) }} / {{ formatTime(videoDuration) }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 空状态(默认展示) -->
|
|
|
|
|
<div v-else class="empty-video">
|
|
|
|
|
<el-icon :size="48"><VideoCamera /></el-icon>
|
|
|
|
|
<p class="empty-title">暂无视频画面</p>
|
|
|
|
|
<p class="empty-hint">点击上方「切换」加载实时流,或上传离线视频</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 底部控制按钮(离线视频时显示) -->
|
|
|
|
|
<div class="video-bottom-controls" v-if="videoMode === 'offline' && offlineVideoUrl">
|
|
|
|
|
<el-button type="primary" size="small" @click="togglePlay">
|
|
|
|
|
<el-icon :size="14"><component :is="isPlaying ? 'VideoPause' : 'VideoPlay'" /></el-icon>
|
|
|
|
|
{{ isPlaying ? '暂停' : '播放' }}
|
|
|
|
|
</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 右侧:控制面板 -->
|
|
|
|
|
<div class="right-section">
|
|
|
|
|
<!-- 顶部功能按钮 -->
|
|
|
|
|
<div class="control-buttons">
|
|
|
|
|
<el-button
|
|
|
|
|
:type="streamRunning ? 'danger' : 'primary'"
|
|
|
|
|
size="default"
|
|
|
|
|
@click="toggleStream"
|
|
|
|
|
>
|
|
|
|
|
{{ streamRunning ? '关闭视频流' : '启动视频流' }}
|
|
|
|
|
</el-button>
|
|
|
|
|
<el-button
|
|
|
|
|
:type="algorithmRunning ? 'danger' : 'success'"
|
|
|
|
|
size="default"
|
|
|
|
|
@click="toggleAlgorithm"
|
|
|
|
|
>
|
|
|
|
|
{{ algorithmRunning ? '关闭算法' : '启动算法' }}
|
|
|
|
|
</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 配件信息卡片 -->
|
|
|
|
|
<div class="parts-card">
|
|
|
|
|
<div class="parts-row">
|
|
|
|
|
<span class="parts-label">配件名称:</span>
|
|
|
|
|
<span class="parts-value">充电线、充电头、隔板、信封...</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="parts-row">
|
|
|
|
|
<span class="parts-label">已检查配件数量:</span>
|
|
|
|
|
<span class="parts-value">
|
|
|
|
|
充电线{{ partsCount.chargerLine }}个、充电头{{ partsCount.chargerHead }}个、隔板{{ partsCount.divider }}个、信封{{ partsCount.envelope }}个
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 统计面板 -->
|
|
|
|
|
<div class="stats-panel">
|
|
|
|
|
<div class="stat-box">
|
|
|
|
|
<div class="stat-label">已完成的流程数量</div>
|
|
|
|
|
<div class="stat-value success">{{ completedCount }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-box">
|
|
|
|
|
<div class="stat-label">未完成的流程数量</div>
|
|
|
|
|
<div class="stat-value warning">{{ uncompletedCount }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-box detail-box" @click="showCompletedDetail">
|
|
|
|
|
<div class="stat-label">查看流程详情</div>
|
|
|
|
|
<div class="stat-value detail-arrow">
|
|
|
|
|
<el-icon :size="22"><ArrowRight /></el-icon>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<!-- 流程日志区域 -->
|
|
|
|
|
<div class="log-section">
|
|
|
|
|
<div class="log-header">流程日志</div>
|
|
|
|
|
<div class="log-body" ref="logBodyRef">
|
|
|
|
|
<div
|
|
|
|
|
v-for="(log, index) in flowLogs"
|
|
|
|
|
:key="index"
|
|
|
|
|
class="log-item"
|
|
|
|
|
:class="'log-' + log.type"
|
|
|
|
|
>
|
|
|
|
|
<span class="log-time">{{ log.time }}</span>
|
|
|
|
|
<span class="log-msg">{{ log.message }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="flowLogs.length === 0" class="log-empty">暂无日志</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
import WebRtcPlayer from '@/components/WebRtcPlayer.vue'
|
|
|
|
|
import WebSocketClient from '@/utils/websocket'
|
|
|
|
|
|
|
|
|
|
// ============ 视频流相关 ============
|
|
|
|
|
const videoMode = ref('idle') // 'idle' | 'live' | 'offline'
|
|
|
|
|
const streamAddress = ref('10.23.22.xx')
|
|
|
|
|
const webrtcUrl = computed(() => {
|
|
|
|
|
return import.meta.env.VITE_WEBRTC_URL || `http://${streamAddress.value}/stream`
|
|
|
|
|
})
|
|
|
|
|
const streamRunning = ref(false)
|
|
|
|
|
const webrtcPlayerRef = ref(null)
|
|
|
|
|
|
|
|
|
|
function switchStream() {
|
|
|
|
|
if (!streamAddress.value) {
|
|
|
|
|
ElMessage.warning('请输入视频流地址')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
videoMode.value = 'live'
|
|
|
|
|
ElMessage.success('已切换到实时视频流')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleStream() {
|
|
|
|
|
streamRunning.value = !streamRunning.value
|
|
|
|
|
if (streamRunning.value) {
|
|
|
|
|
videoMode.value = 'live'
|
|
|
|
|
ElMessage.success('视频流已启动')
|
|
|
|
|
} else {
|
|
|
|
|
videoMode.value = 'idle'
|
|
|
|
|
ElMessage.info('视频流已关闭')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onConnectionChange(status) {
|
|
|
|
|
streamRunning.value = status
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============ 离线视频相关 ============
|
|
|
|
|
const offlineVideoRef = ref(null)
|
|
|
|
|
const offlineVideoUrl = ref('')
|
|
|
|
|
const videoDuration = ref(0)
|
|
|
|
|
const currentVideoTime = ref(0)
|
|
|
|
|
const isPlaying = ref(false)
|
|
|
|
|
|
|
|
|
|
const progressPercent = computed(() => {
|
|
|
|
|
if (videoDuration.value === 0) return 0
|
|
|
|
|
return (currentVideoTime.value / videoDuration.value) * 100
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function handleVideoUpload(file) {
|
|
|
|
|
if (offlineVideoUrl.value) {
|
|
|
|
|
URL.revokeObjectURL(offlineVideoUrl.value)
|
|
|
|
|
}
|
|
|
|
|
offlineVideoUrl.value = URL.createObjectURL(file.raw)
|
|
|
|
|
videoMode.value = 'offline'
|
|
|
|
|
isPlaying.value = false
|
|
|
|
|
ElMessage.success('离线视频已加载')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onVideoLoaded() {
|
|
|
|
|
if (offlineVideoRef.value) {
|
|
|
|
|
videoDuration.value = offlineVideoRef.value.duration
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onTimeUpdate() {
|
|
|
|
|
if (offlineVideoRef.value) {
|
|
|
|
|
currentVideoTime.value = offlineVideoRef.value.currentTime
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onVideoEnded() {
|
|
|
|
|
isPlaying.value = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function togglePlay() {
|
|
|
|
|
if (!offlineVideoRef.value) return
|
|
|
|
|
if (isPlaying.value) {
|
|
|
|
|
offlineVideoRef.value.pause()
|
|
|
|
|
isPlaying.value = false
|
|
|
|
|
} else {
|
|
|
|
|
offlineVideoRef.value.play()
|
|
|
|
|
isPlaying.value = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function seekVideo(e) {
|
|
|
|
|
if (!offlineVideoRef.value || videoDuration.value === 0) return
|
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
|
|
|
const ratio = (e.clientX - rect.left) / rect.width
|
|
|
|
|
offlineVideoRef.value.currentTime = ratio * videoDuration.value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatTime(seconds) {
|
|
|
|
|
const m = Math.floor(seconds / 60)
|
|
|
|
|
const s = Math.floor(seconds % 60)
|
|
|
|
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============ 算法控制 ============
|
|
|
|
|
const algorithmRunning = ref(false)
|
|
|
|
|
let wsClient = null
|
|
|
|
|
|
|
|
|
|
function toggleAlgorithm() {
|
|
|
|
|
algorithmRunning.value = !algorithmRunning.value
|
|
|
|
|
if (algorithmRunning.value) {
|
|
|
|
|
// 连接 WebSocket 算法检测服务
|
|
|
|
|
wsClient = new WebSocketClient({
|
|
|
|
|
url: 'ws://10.21.221.41:8000/ws/detect',
|
|
|
|
|
debug: true,
|
|
|
|
|
autoReconnect: true
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
wsClient.on('open', () => {
|
|
|
|
|
ElMessage.success('算法已启动')
|
|
|
|
|
addLog('WebSocket 算法连接成功', 'info')
|
|
|
|
|
console.log('[WebSocket] 算法连接成功')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
wsClient.on('message', (data) => {
|
|
|
|
|
console.log('[WebSocket] 收到算法检测数据:', data)
|
|
|
|
|
addLog('收到检测数据: ' + JSON.stringify(data), 'info')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
wsClient.on('close', (event) => {
|
|
|
|
|
console.log('[WebSocket] 连接关闭:', event.code, event.reason)
|
|
|
|
|
addLog('算法连接已关闭', 'warning')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
wsClient.on('error', (err) => {
|
|
|
|
|
console.error('[WebSocket] 连接出错:', err)
|
|
|
|
|
addLog('算法连接出错', 'error')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
wsClient.on('reconnect', ({ times, maxTimes }) => {
|
|
|
|
|
console.log(`[WebSocket] 第 ${times}/${maxTimes} 次重连中...`)
|
|
|
|
|
addLog(`第 ${times}/${maxTimes} 次重连中...`, 'warning')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
wsClient.on('heartbeat', () => {
|
|
|
|
|
console.warn('[WebSocket] 心跳超时,即将重连')
|
|
|
|
|
addLog('算法连接心跳超时', 'error')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
wsClient.connect()
|
|
|
|
|
} else {
|
|
|
|
|
// 关闭 WebSocket 连接
|
|
|
|
|
if (wsClient) {
|
|
|
|
|
wsClient.destroy()
|
|
|
|
|
wsClient = null
|
|
|
|
|
}
|
|
|
|
|
ElMessage.info('算法已关闭')
|
|
|
|
|
addLog('算法已关闭', 'info')
|
|
|
|
|
console.log('[WebSocket] 算法连接已主动关闭')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============ 配件统计 ============
|
|
|
|
|
const partsCount = reactive({
|
|
|
|
|
chargerLine: 30,
|
|
|
|
|
chargerHead: 15,
|
|
|
|
|
divider: 0,
|
|
|
|
|
envelope: 0
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============ 流程日志 ============
|
|
|
|
|
const flowLogs = ref([
|
|
|
|
|
{ time: '10:05:12', message: '开始检测流程', type: 'info' },
|
|
|
|
|
{ time: '10:05:15', message: '检测到产品A', type: 'success' },
|
|
|
|
|
{ time: '10:05:18', message: '检测到产品B', type: 'success' },
|
|
|
|
|
{ time: '10:05:22', message: '检测隔板...', type: 'warning' },
|
|
|
|
|
{ time: '10:05:25', message: '隔板未检测到', type: 'error' },
|
|
|
|
|
])
|
|
|
|
|
const logBodyRef = ref(null)
|
|
|
|
|
|
|
|
|
|
function addLog(message, type = 'info') {
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
|
|
|
|
|
flowLogs.value.push({ time, message, type })
|
|
|
|
|
// 限制日志数量
|
|
|
|
|
if (flowLogs.value.length > 100) {
|
|
|
|
|
flowLogs.value.shift()
|
|
|
|
|
}
|
|
|
|
|
// 自动滚动到底部
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
if (logBodyRef.value) {
|
|
|
|
|
logBodyRef.value.scrollTop = logBodyRef.value.scrollHeight
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============ 统计 ============
|
|
|
|
|
const completedCount = ref(12)
|
|
|
|
|
const uncompletedCount = ref(3)
|
|
|
|
|
|
|
|
|
|
function showCompletedDetail() {
|
|
|
|
|
ElMessage.info('查看已完成流程详情')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============ 定时模拟日志 ============
|
|
|
|
|
let logTimer = null
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
// 模拟实时日志
|
|
|
|
|
logTimer = setInterval(() => {
|
|
|
|
|
if (algorithmRunning.value && Math.random() > 0.7) {
|
|
|
|
|
const msgs = [
|
|
|
|
|
{ msg: '检测到充电线', type: 'success' },
|
|
|
|
|
{ msg: '检测到充电头', type: 'success' },
|
|
|
|
|
{ msg: '正在识别产品...', type: 'info' },
|
|
|
|
|
{ msg: '流程完成', type: 'success' }
|
|
|
|
|
]
|
|
|
|
|
const item = msgs[Math.floor(Math.random() * msgs.length)]
|
|
|
|
|
addLog(item.msg, item.type)
|
|
|
|
|
}
|
|
|
|
|
}, 3000)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
if (logTimer) clearInterval(logTimer)
|
|
|
|
|
if (offlineVideoUrl.value) {
|
|
|
|
|
URL.revokeObjectURL(offlineVideoUrl.value)
|
|
|
|
|
}
|
|
|
|
|
if (wsClient) {
|
|
|
|
|
wsClient.destroy()
|
|
|
|
|
wsClient = null
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 自动滚动日志
|
|
|
|
|
watch(flowLogs, () => {
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
if (logBodyRef.value) {
|
|
|
|
|
logBodyRef.value.scrollTop = logBodyRef.value.scrollHeight
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}, { deep: true })
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
.home-page {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 16px;
|
|
|
|
|
height: calc(100vh - #{$header-height} - 32px);
|
|
|
|
|
padding: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 左侧视频区
|
|
|
|
|
.left-section {
|
|
|
|
|
flex: 1;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.video-toolbar {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
background: $bg-white;
|
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
border-radius: $radius-md;
|
|
|
|
|
box-shadow: $shadow-sm;
|
|
|
|
|
|
|
|
|
|
.stream-info {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
|
|
|
|
.label {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: $text-secondary;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stream-input {
|
|
|
|
|
width: 180px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.upload-area {
|
|
|
|
|
:deep(.el-upload) {
|
|
|
|
|
display: block;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.video-container {
|
|
|
|
|
flex: 1;
|
|
|
|
|
background: #1a1a2e;
|
|
|
|
|
border-radius: $radius-md;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
position: relative;
|
|
|
|
|
min-height: 300px;
|
|
|
|
|
|
|
|
|
|
:deep(.webrtc-wrapper) {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.offline-player {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
position: relative;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
|
|
|
|
.video-element {
|
|
|
|
|
flex: 1;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
background: #000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.video-controls {
|
|
|
|
|
padding: 8px 16px;
|
|
|
|
|
background: rgba(0, 0, 0, 0.8);
|
|
|
|
|
|
|
|
|
|
.progress-wrapper {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
|
|
|
|
.progress-track {
|
|
|
|
|
flex: 1;
|
|
|
|
|
height: 4px;
|
|
|
|
|
background: rgba(255, 255, 255, 0.3);
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
height: 6px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.progress-fill {
|
|
|
|
|
height: 100%;
|
|
|
|
|
background: $primary-color;
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
transition: width 0.1s linear;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.time-display {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: rgba(255, 255, 255, 0.8);
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.empty-video {
|
|
|
|
|
position: absolute;
|
|
|
|
|
inset: 0;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
color: rgba(255, 255, 255, 0.5);
|
|
|
|
|
|
|
|
|
|
.empty-title {
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
color: rgba(255, 255, 255, 0.45);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.empty-hint {
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: rgba(255, 255, 255, 0.3);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.video-bottom-controls {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
gap: 16px;
|
|
|
|
|
padding: 8px 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 右侧控制面板
|
|
|
|
|
.right-section {
|
|
|
|
|
width: 400px;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.control-buttons {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
background: $bg-white;
|
|
|
|
|
padding: 16px;
|
|
|
|
|
border-radius: $radius-md;
|
|
|
|
|
box-shadow: $shadow-sm;
|
|
|
|
|
|
|
|
|
|
.el-button {
|
|
|
|
|
flex: 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.parts-card {
|
|
|
|
|
background: $bg-white;
|
|
|
|
|
padding: 16px;
|
|
|
|
|
border-radius: $radius-md;
|
|
|
|
|
box-shadow: $shadow-sm;
|
|
|
|
|
|
|
|
|
|
.parts-row {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
|
|
|
|
&:not(:last-child) {
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.parts-label {
|
|
|
|
|
color: $text-secondary;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.parts-value {
|
|
|
|
|
color: $text-primary;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-section {
|
|
|
|
|
flex: 1;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
background: #fffacd;
|
|
|
|
|
border-radius: $radius-md;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
box-shadow: $shadow-sm;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
|
|
|
|
.log-header {
|
|
|
|
|
padding: 10px 16px;
|
|
|
|
|
background: #fff176;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: $text-primary;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-body {
|
|
|
|
|
flex: 1;
|
|
|
|
|
padding: 10px 16px;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
font-family: 'Consolas', 'Monaco', monospace;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
|
|
|
|
.log-item {
|
|
|
|
|
padding: 4px 0;
|
|
|
|
|
border-bottom: 1px dashed rgba(0, 0, 0, 0.1);
|
|
|
|
|
|
|
|
|
|
&:last-child {
|
|
|
|
|
border-bottom: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-time {
|
|
|
|
|
color: #666;
|
|
|
|
|
margin-right: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-msg {
|
|
|
|
|
color: $text-primary;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.log-success .log-msg {
|
|
|
|
|
color: $success-color;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.log-error .log-msg {
|
|
|
|
|
color: $danger-color;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.log-warning .log-msg {
|
|
|
|
|
color: $warning-color;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.log-empty {
|
|
|
|
|
text-align: center;
|
|
|
|
|
color: #999;
|
|
|
|
|
padding: 20px 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stats-panel {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
|
|
|
|
.stat-box {
|
|
|
|
|
background: $bg-white;
|
|
|
|
|
padding: 10px 16px;
|
|
|
|
|
border-radius: $radius-md;
|
|
|
|
|
box-shadow: $shadow-sm;
|
|
|
|
|
text-align: center;
|
|
|
|
|
min-width: 120px;
|
|
|
|
|
|
|
|
|
|
.stat-label {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: $text-secondary;
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-value {
|
|
|
|
|
font-size: 22px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
|
|
|
|
&.success {
|
|
|
|
|
color: $success-color;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.warning {
|
|
|
|
|
color: $warning-color;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.detail-box {
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: $transition;
|
|
|
|
|
|
|
|
|
|
.detail-arrow {
|
|
|
|
|
color: $primary-color;
|
|
|
|
|
transition: transform 0.2s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
background: #f8f9ff;
|
|
|
|
|
box-shadow: 0 2px 8px rgba(45, 90, 240, 0.1);
|
|
|
|
|
|
|
|
|
|
.detail-arrow {
|
|
|
|
|
transform: translateX(4px);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&:active {
|
|
|
|
|
background: #f0f2ff;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|