修复bug

This commit is contained in:
Your Name
2026-05-09 17:04:50 +08:00
parent 3330613254
commit 985916dea7
9 changed files with 582 additions and 54 deletions
+17 -2
View File
@@ -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>