修复bug
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
NODE_ENV = 'development'
|
||||
|
||||
# Base API
|
||||
# 后端接口根地址(含协议与端口),例如 http://127.0.0.1:8080;
|
||||
# 相对路径 /uploads/… 若此项为完整 URL,会与后端对齐;未填时由 vite 将 /uploads 代理到默认 http://127.0.0.1:8080。
|
||||
VITE_APP_BASE_URL=''
|
||||
Generated
+7
@@ -24,6 +24,7 @@
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.9.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hls.js": "^1.6.16",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -8264,6 +8265,12 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/html-void-elements": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.9.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hls.js": "^1.6.16",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { getConfig } from '@/api/app'
|
||||
import configs from '@/config'
|
||||
|
||||
interface AppSate {
|
||||
config: Record<string, any>
|
||||
@@ -26,8 +27,22 @@ const useAppStore = defineStore({
|
||||
getImageUrl(url: string) {
|
||||
if (!url || typeof url !== 'string') return ''
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
||||
const domain = this.config.oss_domain || (typeof window !== 'undefined' ? window.location.origin + '/' : '')
|
||||
return domain ? `${domain.replace(/\/$/, '')}${url.startsWith('/') ? url : '/' + url}` : url
|
||||
const path = url.startsWith('/') ? url : '/' + url
|
||||
const oss = String(this.config.oss_domain || '').replace(/\/$/, '')
|
||||
if (oss) {
|
||||
return `${oss}${path}`
|
||||
}
|
||||
// 开发环境:后台接口与 Vite 不同端口时,相对路径不能拼到 location.origin(应对接到 php 静态域名)
|
||||
const apiRoot = String(configs.baseUrl || '/').replace(/\/+$/, '')
|
||||
if (apiRoot.startsWith('http://') || apiRoot.startsWith('https://')) {
|
||||
try {
|
||||
return new URL(path, `${apiRoot}/`).href
|
||||
} catch {
|
||||
return `${apiRoot}${path}`
|
||||
}
|
||||
}
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
return origin ? `${origin}${path}` : path
|
||||
},
|
||||
getConfig() {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table-column label="录制回放" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<RecordingPlaybackBlock :record-id="row.id" :urls="row.recording_urls_list" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开始时间" width="170" prop="start_time_text" />
|
||||
<el-table-column label="结束时间" width="170" prop="end_time_text" />
|
||||
<el-table-column label="通话类型" width="100">
|
||||
@@ -65,38 +70,16 @@
|
||||
</upload>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="录制回放" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div v-if="(row.recording_urls_list || []).length" class="recording-list">
|
||||
<div
|
||||
v-for="(url, idx) in row.recording_urls_list"
|
||||
:key="`${row.id}-${idx}`"
|
||||
class="recording-item"
|
||||
>
|
||||
<video
|
||||
v-if="isPlayableVideo(url)"
|
||||
:src="url"
|
||||
controls
|
||||
preload="metadata"
|
||||
class="recording-video"
|
||||
/>
|
||||
<el-link v-else :href="url" target="_blank" type="primary">
|
||||
打开链接 {{ idx + 1 }}
|
||||
</el-link>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">暂无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RecordingPlaybackBlock from './RecordingPlaybackBlock.vue'
|
||||
import Upload from '@/components/upload/index.vue'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { attachLocalCallRecording, createManualCallRecord, getCallRecords } from '@/api/tcm'
|
||||
import { ref, useTemplateRef, watch, withDefaults } from 'vue'
|
||||
import { ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
type CallRecordRow = {
|
||||
id: number
|
||||
@@ -156,11 +139,6 @@ function statusText(status: number) {
|
||||
return m[status] ?? '—'
|
||||
}
|
||||
|
||||
function isPlayableVideo(url: string) {
|
||||
if (!url || typeof url !== 'string') return false
|
||||
return /\.(mp4|webm|ogg|mov|mkv)(\?|$)/i.test(url)
|
||||
}
|
||||
|
||||
async function handleToolbarUploadSuccess(response: any) {
|
||||
const fileUrl = normalizeUploadUrl(response)
|
||||
if (!fileUrl) {
|
||||
@@ -232,24 +210,5 @@ function normalizeUploadUrl(response: any): string {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.recording-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recording-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.recording-video {
|
||||
max-width: 100%;
|
||||
max-height: 180px;
|
||||
border-radius: 4px;
|
||||
background: #000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div v-if="normalized.length" class="recording-list">
|
||||
<div class="recording-item">
|
||||
<template v-if="preferred">
|
||||
<RecordingVideoPlayer
|
||||
v-if="shouldInlineRecording(preferred)"
|
||||
:key="`${recordId}-${preferred}`"
|
||||
:src="preferred"
|
||||
/>
|
||||
<el-link v-else :href="resolveRecordingLink(preferred)" target="_blank" type="primary">
|
||||
打开回放
|
||||
</el-link>
|
||||
</template>
|
||||
<div v-if="alternates.length" class="recording-alternates">
|
||||
<span class="recording-alternates__label">备用地址</span>
|
||||
<div class="recording-alternates__links">
|
||||
<el-link
|
||||
v-for="(url, linkIndex) in alternates"
|
||||
:key="`${recordId}-alt-${linkIndex}-${url.slice(-32)}`"
|
||||
:href="resolveRecordingLink(url)"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
class="recording-alternates__link"
|
||||
>
|
||||
{{ alternateLinkLabel(url, linkIndex) }}
|
||||
</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">暂无</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RecordingVideoPlayer from './RecordingVideoPlayer.vue'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
recordId: number
|
||||
/** getCallRecords 返回的 recording_urls_list(可多来源:COS/VOD、m3u8/mp4) */
|
||||
urls?: string[]
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
/** 去重、trim */
|
||||
const normalized = computed(() => {
|
||||
const raw = props.urls || []
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const u of raw) {
|
||||
const s = String(u ?? '').trim()
|
||||
if (!s || seen.has(s)) continue
|
||||
seen.add(s)
|
||||
out.push(s)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
/** 优选一条内嵌:mp4 > 点播 m3u8 > COS m3u8 > 其它 m3u8 > 首个 */
|
||||
const preferred = computed((): string | null => {
|
||||
const list = normalized.value
|
||||
if (!list.length) return null
|
||||
|
||||
const mp4 = list.find((u) => /\.mp4(\?|#|$)/i.test(u))
|
||||
if (mp4) return mp4
|
||||
|
||||
const vodHls = list.find(
|
||||
(u) => /\.m3u8(\?|#|$)/i.test(u) && /vod-qcloud\.com/i.test(u)
|
||||
)
|
||||
if (vodHls) return vodHls
|
||||
|
||||
const cosHls = list.find(
|
||||
(u) =>
|
||||
/\.m3u8(\?|#|$)/i.test(u) && /\.cos\.[^/]+\.myqcloud\.com/i.test(u)
|
||||
)
|
||||
if (cosHls) return cosHls
|
||||
|
||||
const anyHls = list.find((u) => /\.m3u8(\?|#|$)/i.test(u))
|
||||
if (anyHls) return anyHls
|
||||
|
||||
return list[0]
|
||||
})
|
||||
|
||||
const alternates = computed(() => {
|
||||
const list = normalized.value
|
||||
const p = preferred.value
|
||||
if (!p) return list.slice(1)
|
||||
return list.filter((u) => u !== p)
|
||||
})
|
||||
|
||||
function shouldInlineRecording(url: string) {
|
||||
if (!url || typeof url !== 'string') return false
|
||||
const u = url.trim()
|
||||
if (/^https?:\/\//i.test(u)) {
|
||||
return /\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(u)
|
||||
}
|
||||
return /\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(u) || u.startsWith('/uploads/')
|
||||
}
|
||||
|
||||
function resolveRecordingLink(url: string) {
|
||||
return appStore.getImageUrl(String(url || '').trim())
|
||||
}
|
||||
|
||||
function alternateLinkLabel(url: string, index: number): string {
|
||||
const u = url.trim()
|
||||
if (/\.mp4(\?|#|$)/i.test(u)) return `MP4 ${index + 1}`
|
||||
if (/vod-qcloud\.com/i.test(u) && /\.m3u8/i.test(u)) return `点播 ${index + 1}`
|
||||
if (/\.cos\.[^/]+\.myqcloud\.com/i.test(u) && /\.m3u8/i.test(u)) return `COS HLS ${index + 1}`
|
||||
if (/\.m3u8/i.test(u)) return `HLS ${index + 1}`
|
||||
return `链接 ${index + 1}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.recording-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recording-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.recording-alternates {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.recording-alternates__label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.recording-alternates__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.recording-alternates__link {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,379 @@
|
||||
<template>
|
||||
<div ref="wrapRef" class="recording-playback">
|
||||
<div class="recording-playback__shell">
|
||||
<video
|
||||
v-show="!failed"
|
||||
ref="videoRef"
|
||||
class="recording-video"
|
||||
controls
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
:preload="progressivePreload"
|
||||
@error="onVideoError"
|
||||
/>
|
||||
<div
|
||||
v-if="!failed && showBusyOverlay"
|
||||
class="recording-playback__overlay"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="forceEnable"
|
||||
@keydown.enter.prevent="forceEnable"
|
||||
>
|
||||
<template v-if="!loadEnabled">
|
||||
<span class="recording-playback__overlay-title">预览待加载</span>
|
||||
<span class="recording-playback__overlay-desc">滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-icon class="recording-playback__spin"><Loading /></el-icon>
|
||||
<span>正在加载…</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="failed">
|
||||
<el-link :href="playbackUrl || '#'" target="_blank" type="primary">在新窗口打开</el-link>
|
||||
<div class="recording-playback__hint">
|
||||
内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const wrapRef = ref<HTMLElement | null>(null)
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
const failed = ref(false)
|
||||
/** 已进入可视区域或用户点击,允许发起请求 */
|
||||
const loadEnabled = ref(false)
|
||||
/** 已开始拉流但未就绪 */
|
||||
const loadingMedia = ref(false)
|
||||
|
||||
let hlsInstance: import('hls.js').default | null = null
|
||||
let intersectionObserver: IntersectionObserver | null = null
|
||||
/** 防止 tearDown / 上一次 attach 的异步事件误结束 loading */
|
||||
let attachGeneration = 0
|
||||
let loadingSlowTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearLoadingSlowTimer(): void {
|
||||
if (loadingSlowTimer !== null) {
|
||||
clearTimeout(loadingSlowTimer)
|
||||
loadingSlowTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const playbackUrl = computed(() => {
|
||||
const raw = String(props.src || '').trim()
|
||||
if (!raw) return ''
|
||||
return normalizeTencentRecordingUrl(appStore.getImageUrl(raw))
|
||||
})
|
||||
|
||||
/** COS/VOD 直连 mp4:允许浏览器优先拉元数据;HLS 仍 none,交给 hls.js */
|
||||
const progressivePreload = computed(() => {
|
||||
if (!loadEnabled.value) return 'none'
|
||||
const u = playbackUrl.value
|
||||
return u && !isHlsUrl(u) ? 'metadata' : 'none'
|
||||
})
|
||||
|
||||
const showBusyOverlay = computed(() => !loadEnabled.value || loadingMedia.value)
|
||||
|
||||
/** 腾讯云录制路径里常见未编码的 “+”,部分环境下会导致视频长时间拿不到元数据 */
|
||||
function normalizeTencentRecordingUrl(url: string): string {
|
||||
if (!url || !/^https?:\/\//i.test(url)) return url
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const h = u.hostname.toLowerCase()
|
||||
if (
|
||||
h.includes('myqcloud.com') ||
|
||||
h.includes('vod-qcloud.com') ||
|
||||
h.includes('vod-qcloud')
|
||||
) {
|
||||
if (u.pathname.includes('+')) {
|
||||
u.pathname = u.pathname.replace(/\+/g, '%2B')
|
||||
}
|
||||
return u.href
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function isHlsUrl(url: string): boolean {
|
||||
return /\.m3u8(\?|#|$)/i.test(url)
|
||||
}
|
||||
|
||||
function onVideoError(): void {
|
||||
loadingMedia.value = false
|
||||
clearLoadingSlowTimer()
|
||||
const el = videoRef.value
|
||||
const code = el?.error?.code
|
||||
if (code === MediaError.MEDIA_ERR_ABORTED || code === 1) {
|
||||
return
|
||||
}
|
||||
failed.value = true
|
||||
}
|
||||
|
||||
function tearDown(): void {
|
||||
if (hlsInstance) {
|
||||
hlsInstance.destroy()
|
||||
hlsInstance = null
|
||||
}
|
||||
const el = videoRef.value
|
||||
if (el) {
|
||||
el.pause()
|
||||
el.removeAttribute('src')
|
||||
el.load()
|
||||
}
|
||||
}
|
||||
|
||||
function bindProgressiveLoadingEnd(el: HTMLVideoElement, gen: number): void {
|
||||
let settled = false
|
||||
const done = () => {
|
||||
if (settled || gen !== attachGeneration) return
|
||||
settled = true
|
||||
clearLoadingSlowTimer()
|
||||
loadingMedia.value = false
|
||||
}
|
||||
el.addEventListener('loadedmetadata', () => done('loadedmetadata'), { once: true })
|
||||
el.addEventListener('loadeddata', () => done('loadeddata'), { once: true })
|
||||
el.addEventListener('canplay', () => done('canplay'), { once: true })
|
||||
el.addEventListener('playing', () => done('playing'), { once: true })
|
||||
el.addEventListener(
|
||||
'progress',
|
||||
() => {
|
||||
try {
|
||||
if (el.buffered.length > 0 && el.buffered.end(0) > 0) {
|
||||
done('progress-buffered')
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
}
|
||||
|
||||
async function attachSource(): Promise<void> {
|
||||
if (!loadEnabled.value) return
|
||||
|
||||
await nextTick()
|
||||
const el = videoRef.value
|
||||
const url = playbackUrl.value
|
||||
if (!el) return
|
||||
|
||||
if (!url) {
|
||||
loadingMedia.value = false
|
||||
clearLoadingSlowTimer()
|
||||
return
|
||||
}
|
||||
|
||||
const gen = ++attachGeneration
|
||||
loadingMedia.value = true
|
||||
clearLoadingSlowTimer()
|
||||
tearDown()
|
||||
failed.value = false
|
||||
|
||||
if (isHlsUrl(url)) {
|
||||
try {
|
||||
const mod = await import('hls.js')
|
||||
const Hls = mod.default
|
||||
if (Hls.isSupported()) {
|
||||
hlsInstance = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
// 列表里多条回放时减小初始缓冲压力,首帧稍快露出
|
||||
maxBufferLength: 20,
|
||||
maxMaxBufferLength: 120
|
||||
})
|
||||
hlsInstance.on(mod.Events.MANIFEST_PARSED, () => {
|
||||
if (gen !== attachGeneration) return
|
||||
loadingMedia.value = false
|
||||
clearLoadingSlowTimer()
|
||||
})
|
||||
hlsInstance.on(mod.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
if (gen !== attachGeneration) return
|
||||
loadingMedia.value = false
|
||||
clearLoadingSlowTimer()
|
||||
failed.value = true
|
||||
tearDown()
|
||||
}
|
||||
})
|
||||
hlsInstance.loadSource(url)
|
||||
hlsInstance.attachMedia(el)
|
||||
loadingSlowTimer = setTimeout(() => {
|
||||
loadingSlowTimer = null
|
||||
if (gen === attachGeneration && loadingMedia.value) {
|
||||
loadingMedia.value = false
|
||||
}
|
||||
}, 45000)
|
||||
return
|
||||
}
|
||||
if (el.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
bindProgressiveLoadingEnd(el, gen)
|
||||
el.src = url
|
||||
loadingSlowTimer = setTimeout(() => {
|
||||
loadingSlowTimer = null
|
||||
if (gen === attachGeneration && loadingMedia.value) {
|
||||
loadingMedia.value = false
|
||||
}
|
||||
}, 45000)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
if (gen !== attachGeneration) return
|
||||
loadingMedia.value = false
|
||||
clearLoadingSlowTimer()
|
||||
failed.value = true
|
||||
return
|
||||
}
|
||||
if (gen !== attachGeneration) return
|
||||
loadingMedia.value = false
|
||||
clearLoadingSlowTimer()
|
||||
failed.value = true
|
||||
return
|
||||
}
|
||||
|
||||
bindProgressiveLoadingEnd(el, gen)
|
||||
el.src = url
|
||||
// moov 在尾部的大 mp4 可能迟迟不触发 loadedmetadata;超时后收起遮罩以免挡住原生控件
|
||||
loadingSlowTimer = setTimeout(() => {
|
||||
loadingSlowTimer = null
|
||||
if (gen === attachGeneration && loadingMedia.value) {
|
||||
loadingMedia.value = false
|
||||
}
|
||||
}, 25000)
|
||||
}
|
||||
|
||||
function setupLazyLoad(): void {
|
||||
const root = wrapRef.value
|
||||
if (!root || typeof IntersectionObserver === 'undefined') {
|
||||
loadEnabled.value = true
|
||||
void attachSource()
|
||||
return
|
||||
}
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const en of entries) {
|
||||
if (en.isIntersecting) {
|
||||
intersectionObserver?.disconnect()
|
||||
intersectionObserver = null
|
||||
loadEnabled.value = true
|
||||
void attachSource()
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: null, rootMargin: '240px 0px', threshold: 0 }
|
||||
)
|
||||
intersectionObserver.observe(root)
|
||||
}
|
||||
|
||||
function forceEnable(): void {
|
||||
intersectionObserver?.disconnect()
|
||||
intersectionObserver = null
|
||||
if (!loadEnabled.value) {
|
||||
loadEnabled.value = true
|
||||
}
|
||||
void attachSource()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void nextTick(() => {
|
||||
setupLazyLoad()
|
||||
})
|
||||
})
|
||||
|
||||
watch(playbackUrl, () => {
|
||||
if (!loadEnabled.value) return
|
||||
void attachSource()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
intersectionObserver?.disconnect()
|
||||
intersectionObserver = null
|
||||
clearLoadingSlowTimer()
|
||||
attachGeneration++
|
||||
tearDown()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.recording-playback {
|
||||
width: 100%;
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.recording-playback__shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recording-video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 180px;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
background: #000;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.recording-playback__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.recording-playback__overlay-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recording-playback__overlay-desc {
|
||||
opacity: 0.92;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.recording-playback__spin {
|
||||
font-size: 24px;
|
||||
animation: recording-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes recording-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.recording-playback__hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because one or more lines are too long
+16
-2
@@ -6,7 +6,7 @@ import AutoImport from 'unplugin-auto-import/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { fileURLToPath, URL } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import { createStyleImportPlugin, ElementPlusResolve } from 'vite-plugin-style-import'
|
||||
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
||||
import vueSetupExtend from 'vite-plugin-vue-setup-extend'
|
||||
@@ -36,13 +36,26 @@ function getElementPlusStylePaths() {
|
||||
return stylePaths
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const rawBase = (env.VITE_APP_BASE_URL || '').trim().replace(/\/+$/, '')
|
||||
const uploadsProxyTarget =
|
||||
rawBase.startsWith('http://') || rawBase.startsWith('https://')
|
||||
? rawBase
|
||||
: 'http://127.0.0.1:8080'
|
||||
|
||||
return {
|
||||
base: '/admin/',
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
hmr: true,
|
||||
open: true,
|
||||
proxy: {
|
||||
// 开发环境:相对路径 /uploads/* 由 PHP 提供,需转发到后端否则 Vite 端口下 404(录制回放黑屏)
|
||||
'/uploads': {
|
||||
target: uploadsProxyTarget,
|
||||
changeOrigin: true
|
||||
},
|
||||
'/api-proxy': {
|
||||
target: 'https://admin.zhenyangtang.com.cn',
|
||||
changeOrigin: true,
|
||||
@@ -112,4 +125,5 @@ export default defineConfig({
|
||||
__VUE_PROD_HYDRATION__: true,
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user