You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

356 lines
25 KiB

<template>
<div class="playback-console">
<div class="playback-body">
<nvr-device-tree
:config="config"
:channels="channels"
:selected-code="selectedCode"
:loading="loading"
open-hint="单击通道选择历史回放设备"
@select="selectChannel"
@open="selectChannel"
/>
<main class="playback-workspace">
<div class="query-bar">
<label><span>日期</span><input v-model="queryDate" type="date"></label>
<label><span>开始时间</span><input v-model="startTime" type="time" step="1"></label>
<em>至</em>
<label><span>结束时间</span><input v-model="endTime" type="time" step="1"></label>
<label><span>下载时刻</span><input v-model="clipTime" type="time" step="1" title="留空时使用当前回放时刻"></label>
<div class="query-actions">
<button type="button" class="primary" :disabled="busy" @click="queryPlayback"><i class="el-icon-search" />查询录像</button>
<button type="button" :disabled="clipDownloading" @click="downloadClip"><i class="el-icon-download" />{{ clipDownloading ? '正在生成' : '下载1分钟' }}</button>
<button type="button" @click="reset"><i class="el-icon-refresh-left" />重置</button>
</div>
</div>
<section ref="videoStage" class="video-stage">
<video ref="video" playsinline muted />
<div class="stage-head">
<span><i :class="{ playing: sessionStatus === 'PLAYING' }" />{{ statusLabel }}</span>
<small>{{ selectedChannelName }}</small>
<em>HLS / BEIJING</em>
</div>
<div v-if="overlayVisible" class="stage-overlay">
<i :class="busy ? 'el-icon-loading' : 'el-icon-video-camera'" />
<strong>{{ overlayTitle }}</strong>
<span>{{ overlayText }}</span>
</div>
<div class="stage-clock">{{ currentClock }}</div>
</section>
<section class="timeline-panel">
<div class="timeline-head">
<span><i />录像轨道</span>
<small>{{ timelineRangeText }}</small>
</div>
<div class="timeline" @click="seekByTimeline">
<div class="timeline-track">
<div class="actual-range" :style="actualRangeStyle" />
<div class="timeline-marker" :style="markerStyle"><span>{{ currentClock }}</span></div>
</div>
<div v-if="!hasActualRange" class="timeline-empty">查询后显示实际录像区间</div>
<div class="timeline-labels"><span v-for="label in timelineLabels" :key="label">{{ label }}</span></div>
</div>
</section>
<section class="playback-controls">
<div class="transport">
<button type="button" title="跳到录像起点" @click="seekBoundary('start')"><i class="el-icon-d-arrow-left" /></button>
<button type="button" class="play" title="播放或暂停" @click="togglePlay"><i :class="sessionStatus === 'PLAYING' ? 'el-icon-video-pause' : 'el-icon-video-play'" /></button>
<button type="button" title="跳到录像末尾" @click="seekBoundary('end')"><i class="el-icon-d-arrow-right" /></button>
<div class="time-readout"><strong>{{ currentClock }}</strong><span>{{ timelineRangeText }}</span></div>
</div>
<div class="control-center">
<div class="volume"><i :class="volume ? 'el-icon-microphone' : 'el-icon-turn-off-microphone'" /><input v-model.number="volume" type="range" min="0" max="1" step="0.05" @input="syncVolume"></div>
<div class="speed-group">
<button v-for="speed in speeds" :key="speed" type="button" :class="{ active: playbackSpeed === speed }" @click="setSpeed(speed)">{{ speed }}x</button>
</div>
<div class="jump-control"><span>跳转</span><input v-model="jumpTime" type="time" step="1"><button type="button" @click="jumpToTime">定位</button></div>
</div>
<button type="button" class="fullscreen" title="全屏" @click="fullscreen"><i class="el-icon-full-screen" /></button>
</section>
<div class="status-line" :class="messageType"><i /><span>{{ statusMessage }}</span></div>
</main>
</div>
</div>
</template>
<script>
import Hls from 'hls.js'
import NvrDeviceTree from './components/NvrDeviceTree.vue'
import {
getNvrConfig, createPlaybackSession, getPlaybackSession,
controlPlayback, deletePlaybackSession, downloadPlaybackClip
} from '@/api/nvrVideo'
const pad = value => String(value).padStart(2, '0')
const compactToDate = value => {
if (!value || !/^\d{14}$/.test(value)) return null
return new Date(Date.UTC(+value.slice(0, 4), +value.slice(4, 6) - 1, +value.slice(6, 8), +value.slice(8, 10), +value.slice(10, 12), +value.slice(12, 14)))
}
const dateToCompact = date => `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`
const compactToInput = value => {
const date = compactToDate(value)
return date ? `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}` : ''
}
const compactToClock = value => {
const date = compactToDate(value)
return date ? `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}` : '--:--:--'
}
export default {
name: 'VideoHistoryPlayback',
components: { NvrDeviceTree },
data() {
const now = new Date(Date.now() + 8 * 60 * 60 * 1000)
return {
config: {}, channels: [], selectedCode: '', loading: false,
connectionText: '读取配置', connectionState: '',
queryDate: now.toISOString().slice(0, 10), startTime: '00:00:00', endTime: '23:59:59',
clipTime: '', jumpTime: '', volume: 0.7, speeds: [1, 2, 4, 8], playbackSpeed: 1,
session: null, pollTimer: null, hls: null, hlsRetryTimer: null, loadedUrl: '', busy: false, clipDownloading: false,
queryStart: null, queryEnd: null, mediaReady: false,
overlayTitle: '选择时间范围开始回放', overlayText: '页面会自动创建 HLS 会话并等待首个可播放切片', overlayVisible: true,
statusMessage: '正在读取服务端设备配置', messageType: 'info'
}
},
computed: {
selectedChannel() { return this.channels.find(item => item.cameraCode === this.selectedCode) || null },
selectedChannelName() { return this.selectedChannel ? (this.selectedChannel.name || `通道 ${this.selectedChannel.channel}`) : '未选择通道' },
sessionStatus() { return (this.session && this.session.status) || 'IDLE' },
statusLabel() {
return { IDLE: '待查询', STARTING: '等待首帧', PLAYING: '回放中', PAUSED: '已暂停', STOPPED: '已停止', FAILED: '播放失败' }[this.sessionStatus] || this.sessionStatus
},
currentClock() { return compactToClock(this.session && this.session.currentTime) },
actualRange() { return this.session && Array.isArray(this.session.actualTimeRange) ? this.session.actualTimeRange : null },
hasActualRange() { return Boolean(this.actualRange && this.actualRange.length === 2) },
timelineRangeText() { return this.hasActualRange ? `${compactToClock(this.actualRange[0])}${compactToClock(this.actualRange[1])}` : '等待查询范围' },
rangeMetrics() {
if (!this.queryStart || !this.queryEnd || !this.hasActualRange) return { left: 0, width: 0, marker: 0 }
const start = compactToDate(this.queryStart); const end = compactToDate(this.queryEnd)
const actualStart = compactToDate(this.actualRange[0]); const actualEnd = compactToDate(this.actualRange[1])
if (!start || !end || !actualStart || !actualEnd || end <= start) return { left: 0, width: 0, marker: 0 }
const total = end - start
const left = Math.max(0, Math.min(100, ((actualStart - start) / total) * 100))
const width = Math.max(1, Math.min(100 - left, ((actualEnd - actualStart) / total) * 100))
const current = compactToDate(this.session.currentTime) || actualStart
return { left, width, marker: Math.max(0, Math.min(100, ((current - start) / total) * 100)) }
},
actualRangeStyle() { return { left: `${this.rangeMetrics.left}%`, width: `${this.rangeMetrics.width}%` } },
markerStyle() { return { left: `${this.rangeMetrics.marker}%` } },
timelineLabels() {
if (!this.queryStart || !this.queryEnd) return ['00:00', '06:00', '12:00', '18:00', '24:00']
const start = compactToDate(this.queryStart); const end = compactToDate(this.queryEnd)
if (!start || !end) return []
return [0, 0.25, 0.5, 0.75, 1].map(value => {
const date = new Date(start.getTime() + (end - start) * value)
return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`
})
}
},
created() { this.loadConfig() },
mounted() {
const video = this.$refs.video
video.volume = this.volume
video.addEventListener('playing', () => { this.mediaReady = true; this.overlayVisible = false })
video.addEventListener('ratechange', () => { if (video.playbackRate !== 1) video.playbackRate = 1 })
},
beforeDestroy() { this.stopSession() },
methods: {
setStatus(message, type = 'info') { this.statusMessage = message; this.messageType = type },
async loadConfig() {
this.loading = true
try {
const config = await getNvrConfig()
this.config = config || {}
this.channels = Array.isArray(config.channels) ? config.channels : []
const routeCode = this.$route.query.cameraCode
const selected = this.channels.find(item => item.cameraCode === routeCode) || this.channels.find(item => item.cameraCode === config.defaultCameraCode) || this.channels[0]
if (selected) this.selectChannel(selected, false)
this.connectionText = '设备服务在线'; this.connectionState = 'online'
this.setStatus('选择通道和时间范围后查询录像')
} catch (error) {
this.connectionText = '服务不可达'; this.connectionState = 'error'
this.overlayTitle = '无法读取设备配置'; this.overlayText = error.message
this.setStatus(error.message, 'error')
} finally { this.loading = false }
},
async selectChannel(channel, stop = true) {
if (!channel || channel.cameraCode === this.selectedCode) return
if (stop) await this.stopSession()
if (!this.channels.some(item => item.cameraCode === channel.cameraCode)) this.channels.push(channel)
this.selectedCode = channel.cameraCode
this.$router.replace({ query: { ...this.$route.query, cameraCode: channel.cameraCode }}).catch(() => undefined)
this.overlayVisible = true; this.overlayTitle = '选择时间范围开始回放'; this.overlayText = '已切换通道,请查询录像'
},
values() {
if (!this.queryDate || !this.startTime || !this.endTime) throw new Error('请选择完整的日期、开始时间和结束时间')
const start = `${this.queryDate} ${this.startTime}`
const end = `${this.queryDate} ${this.endTime}`
const startCompact = start.replace(/[- :]/g, ''); const endCompact = end.replace(/[- :]/g, '')
if (startCompact.length !== 14 || endCompact.length !== 14 || endCompact <= startCompact) throw new Error('结束时间必须晚于开始时间')
return { start, end, startCompact, endCompact }
},
async queryPlayback() {
if (this.busy) return
if (!this.selectedCode) { this.setStatus('请先选择摄像机', 'warn'); return }
let values
try { values = this.values() } catch (error) { this.setStatus(error.message, 'error'); return }
await this.stopSession()
this.busy = true; this.queryStart = values.startCompact; this.queryEnd = values.endCompact
this.overlayVisible = true; this.overlayTitle = '正在检索录像'; this.overlayText = 'NVR 正在返回实际连续录像区间'
this.setStatus(`正在查询 ${values.start}${values.end}`)
try {
this.session = await createPlaybackSession({ cameraCode: this.selectedCode, streamType: 1, startTime: values.start, endTime: values.end })
this.applySession(this.session)
this.schedulePoll(0)
} catch (error) {
this.overlayTitle = error.status === 404 ? '这个时间段没有录像' : '回放创建失败'; this.overlayText = error.message
this.setStatus(error.message, error.status === 404 ? 'warn' : 'error')
} finally { this.busy = false }
},
schedulePoll(delay = 1000) {
clearTimeout(this.pollTimer)
if (!this.session || ['STOPPED', 'FAILED'].includes(this.session.status)) return
this.pollTimer = setTimeout(this.pollSession, delay)
},
async pollSession() {
if (!this.session) return
try {
this.session = await getPlaybackSession(this.session.sessionId)
this.applySession(this.session)
this.schedulePoll(this.session.status === 'STARTING' ? 650 : 1200)
} catch (error) {
this.setStatus(error.message, 'error'); this.overlayVisible = true; this.overlayTitle = '回放流启动失败'; this.overlayText = error.message
this.destroyHls(); this.session = null
}
},
applySession(session) {
this.playbackSpeed = Number(session.speed || 1)
if (session.hlsUrl && ['STARTING', 'PLAYING'].includes(session.status)) this.attachHls(session.hlsUrl)
if (session.status === 'PLAYING') {
if (this.mediaReady) this.overlayVisible = false
this.setStatus(`正在播放 ${this.timelineRangeText} · ${this.playbackSpeed}x`, 'success')
this.$refs.video.play().catch(() => undefined)
} else if (session.status === 'PAUSED') {
this.$refs.video.pause(); this.overlayVisible = true; this.overlayTitle = '回放已暂停'; this.overlayText = '点击播放按钮继续'
} else if (session.status === 'STARTING') {
this.overlayVisible = true; this.overlayTitle = '正在等待回放流'; this.overlayText = 'HLS 正在等待首个可播放切片'
}
},
attachHls(url) {
if (this.loadedUrl === url && this.hls) return
this.destroyHls(); this.loadedUrl = url; this.mediaReady = false
const video = this.$refs.video
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 30,
maxBufferLength: 16,
manifestLoadingMaxRetry: 8,
levelLoadingMaxRetry: 8,
fragLoadingMaxRetry: 8
})
this.hls = hls
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
if (this.hls === hls) hls.loadSource(`${url}${url.includes('?') ? '&' : '?'}uiGeneration=${this.session ? this.session.generation : Date.now()}`)
})
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => undefined))
hls.on(Hls.Events.ERROR, (_, detail) => {
if (this.hls !== hls || !detail || !detail.fatal) return
this.setStatus('HLS 播放异常,正在自动恢复', 'warn')
if (detail.type === Hls.ErrorTypes.NETWORK_ERROR) {
clearTimeout(this.hlsRetryTimer)
this.hlsRetryTimer = setTimeout(() => {
if (this.hls === hls) hls.startLoad()
}, 800)
} else if (detail.type === Hls.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError()
}
})
hls.attachMedia(video)
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = url; video.play().catch(() => undefined) }
},
destroyHls() {
clearTimeout(this.hlsRetryTimer); this.hlsRetryTimer = null
if (this.hls) { this.hls.destroy(); this.hls = null }
this.loadedUrl = ''; this.mediaReady = false
const video = this.$refs.video
if (video) { video.pause(); video.removeAttribute('src'); video.load() }
},
async sendControl(action, payload = {}) {
if (!this.session || this.busy) return
this.busy = true
try {
this.session = await controlPlayback(this.session.sessionId, { cameraCode: this.selectedCode, action, ...payload })
this.applySession(this.session); this.schedulePoll(this.session.status === 'STARTING' ? 650 : 1200)
} catch (error) { this.setStatus(error.message, 'error') } finally { this.busy = false }
},
togglePlay() { if (!this.session) this.setStatus('请先查询录像', 'warn'); else this.sendControl(this.sessionStatus === 'PLAYING' ? 'PAUSE' : 'RESUME') },
setSpeed(speed) { this.sendControl('SET_SPEED', { speed }) },
seek(value) { if (value) this.sendControl('SEEK', { seekTime: compactToInput(value) }) },
seekBoundary(side) {
if (!this.hasActualRange) return
if (side === 'start') this.seek(this.actualRange[0])
else {
const end = compactToDate(this.actualRange[1]); end.setUTCSeconds(end.getUTCSeconds() - 1); this.seek(dateToCompact(end))
}
},
seekByTimeline(event) {
if (!this.session || !this.queryStart || !this.queryEnd) return
const rect = event.currentTarget.getBoundingClientRect(); const fraction = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))
const start = compactToDate(this.queryStart); const end = compactToDate(this.queryEnd)
this.seek(dateToCompact(new Date(start.getTime() + (end - start) * fraction)))
},
jumpToTime() {
if (!this.jumpTime) { this.setStatus('请输入跳转时间', 'warn'); return }
const time = this.jumpTime.length === 5 ? `${this.jumpTime}:00` : this.jumpTime
this.seek(`${this.queryDate.replace(/-/g, '')}${time.replace(/:/g, '')}`)
},
syncVolume() { const video = this.$refs.video; video.volume = this.volume; video.muted = this.volume === 0 },
fullscreen() { const stage = this.$refs.videoStage; if (stage && stage.requestFullscreen) stage.requestFullscreen() },
async stopSession() {
clearTimeout(this.pollTimer)
const session = this.session
this.session = null
if (session && session.sessionId) {
try { await controlPlayback(session.sessionId, { cameraCode: session.cameraCode || this.selectedCode, action: 'STOP' }) } catch (_) { /* cleanup */ }
try { await deletePlaybackSession(session.sessionId) } catch (_) { /* cleanup */ }
}
this.destroyHls()
},
reset() {
this.stopSession(); this.startTime = '00:00:00'; this.endTime = '23:59:59'; this.clipTime = ''; this.jumpTime = ''
this.queryStart = null; this.queryEnd = null; this.overlayVisible = true; this.overlayTitle = '选择时间范围开始回放'; this.overlayText = '页面会自动创建 HLS 会话并等待首个可播放切片'; this.setStatus('选择通道和时间范围后查询录像')
},
async downloadClip() {
if (!this.selectedCode) { this.$message.warning('请先选择摄像机'); return }
const centerTime = this.clipTime ? `${this.queryDate} ${this.clipTime}` : compactToInput(this.session && this.session.currentTime)
if (!centerTime) { this.$message.warning('请输入下载时刻,或先开始回放'); return }
this.clipDownloading = true
try {
const blob = await downloadPlaybackClip({ cameraCode: this.selectedCode, streamType: 1, centerTime, beforeSeconds: 30, afterSeconds: 30 })
const filename = `playback-${centerTime.replace(/[- :]/g, '')}.mp4`
const url = URL.createObjectURL(blob); const link = document.createElement('a')
link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 2000)
this.$message.success('录像片段已下载')
} catch (error) { this.$message.error(error.message) } finally { this.clipDownloading = false }
}
}
}
</script>
<style lang="scss" scoped>
.playback-console { height: 100%; min-height: 0; display: flex; flex-direction: column; color: #dcecff; background: #0b1248; }
.playback-body { flex: 1; min-height: 0; display: flex; }.playback-workspace { flex: 1; min-width: 0; min-height: 0; padding: 10px 14px; display: flex; flex-direction: column; gap: 9px; }
.query-bar { min-height: 58px; display: flex; align-items: flex-end; gap: 9px; padding: 7px 10px; border: 1px solid rgba(42,133,230,.38); background: rgba(7,30,84,.68); }.query-bar label { display: flex; flex-direction: column; gap: 5px; color: #7fa8d1; font-size: 11px; }.query-bar input { width: 126px; height: 29px; padding: 0 8px; border: 1px solid rgba(55,151,247,.48); color: #d4eaff; background: #09265e; outline: none; }.query-bar label:first-child input { width: 140px; }.query-bar em { height: 29px; line-height: 29px; color: #6c97c2; font-style: normal; }.query-actions { margin-left: auto; display: flex; gap: 6px; }.query-actions button { height: 30px; padding: 0 11px; border: 1px solid rgba(50,147,244,.52); color: #a9d3ff; background: #0a2b67; cursor: pointer; }.query-actions button.primary { color: #fff; background: #176bc2; }.query-actions button i { margin-right: 5px; }.query-actions button:disabled { opacity: .55; cursor: not-allowed; }
.video-stage { position: relative; flex: 1; min-height: 260px; overflow: hidden; border: 1px solid #176bc2; background: #03081d; }.video-stage video { width: 100%; height: 100%; display: block; object-fit: contain; }.stage-head { position: absolute; z-index: 2; top: 0; left: 0; right: 0; height: 34px; padding: 0 10px; display: flex; align-items: center; gap: 12px; color: #c9e3ff; background: linear-gradient(180deg,rgba(2,9,31,.94),transparent); font-size: 11px; }.stage-head span { display: flex; align-items: center; gap: 6px; }.stage-head span i { width: 7px; height: 7px; border-radius: 50%; background: #d09231; }.stage-head span i.playing { background: #25dfa2; box-shadow: 0 0 8px #25dfa2; }.stage-head small { color: #7da8d4; }.stage-head em { margin-left: auto; color: #5e8dbb; font-style: normal; }.stage-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; color: #6d9bc8; background: radial-gradient(circle at center,rgba(10,45,96,.65),rgba(3,8,29,.92)); }.stage-overlay i { font-size: 36px; }.stage-overlay strong { color: #b9d8f7; font-size: 14px; }.stage-overlay span { font-size: 11px; }.stage-clock { position: absolute; right: 10px; bottom: 8px; color: #a7caec; font-family: Consolas,monospace; font-size: 12px; }
.timeline-panel { padding: 8px 10px 4px; border: 1px solid rgba(43,133,226,.38); background: rgba(7,29,80,.72); }.timeline-head { display: flex; align-items: center; justify-content: space-between; color: #bcd9f7; font-size: 11px; }.timeline-head span { display: flex; align-items: center; gap: 6px; }.timeline-head i { width: 6px; height: 6px; border-radius: 50%; background: #1a8cff; }.timeline-head small { color: #6d98c2; }.timeline { position: relative; height: 46px; margin-top: 4px; cursor: pointer; }.timeline-track { position: absolute; left: 0; right: 0; top: 12px; height: 7px; background: #071b44; border: 1px solid #183f73; }.actual-range { position: absolute; top: -1px; height: 7px; background: #1989ee; box-shadow: 0 0 7px rgba(25,137,238,.6); }.timeline-marker { position: absolute; z-index: 2; top: -5px; width: 1px; height: 17px; background: #54ffff; }.timeline-marker span { position: absolute; left: 50%; top: -17px; transform: translateX(-50%); color: #54ffff; font-size: 9px; white-space: nowrap; }.timeline-empty { position: absolute; top: 3px; left: 50%; transform: translateX(-50%); color: #587fa8; font-size: 10px; }.timeline-labels { position: absolute; left: 0; right: 0; bottom: 0; display: flex; justify-content: space-between; color: #557da7; font-size: 9px; }
.playback-controls { height: 48px; min-height: 48px; padding: 0 9px; display: flex; align-items: center; border: 1px solid rgba(43,133,226,.38); background: #081e53; }.transport { display: flex; align-items: center; gap: 5px; }.playback-controls button { border: 1px solid rgba(53,146,238,.5); color: #a8d2fa; background: #0a2b67; cursor: pointer; }.transport>button { width: 29px; height: 29px; }.transport button.play { width: 35px; height: 35px; border-radius: 50%; color: #fff; background: #176bc2; }.time-readout { width: 170px; display: flex; flex-direction: column; margin-left: 6px; }.time-readout strong { color: #54ffff; font: 13px Consolas,monospace; }.time-readout span { color: #638fb9; font-size: 9px; }.control-center { flex: 1; display: flex; align-items: center; justify-content: center; gap: 18px; }.volume { display: flex; align-items: center; gap: 5px; color: #71a7d7; }.volume input { width: 76px; accent-color: #228ef1; }.speed-group { display: flex; }.speed-group button { width: 34px; height: 27px; border-right: 0; }.speed-group button:last-child { border-right: 1px solid rgba(53,146,238,.5); }.speed-group button.active { color: #fff; background: #176bc2; }.jump-control { display: flex; align-items: center; gap: 5px; color: #729dcc; font-size: 10px; }.jump-control input { width: 100px; height: 27px; padding: 0 5px; border: 1px solid rgba(53,146,238,.5); color: #d1e7ff; background: #081f53; }.jump-control button { height: 27px; }.fullscreen { width: 30px; height: 30px; }
.status-line { min-height: 29px; padding: 0 10px; display: flex; align-items: center; gap: 7px; color: #7fa8d0; border: 1px solid rgba(43,133,226,.25); background: rgba(6,25,70,.6); font-size: 11px; }.status-line i { width: 6px; height: 6px; border-radius: 50%; background: #428fda; }.status-line.success i { background: #2bdb9f; }.status-line.warn i { background: #e5a83d; }.status-line.error i { background: #ff5c72; }
@media (max-width: 1250px) { .query-bar { flex-wrap: wrap; }.query-actions { margin-left: 0; }.control-center { gap: 8px; }.time-readout { width: 130px; } }
</style>