修复bug
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
NODE_ENV = 'development'
|
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=''
|
VITE_APP_BASE_URL=''
|
||||||
Generated
+7
@@ -24,6 +24,7 @@
|
|||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
"element-plus": "^2.9.4",
|
"element-plus": "^2.9.4",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"hls.js": "^1.6.16",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^2.5.2",
|
"jspdf": "^2.5.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
@@ -8264,6 +8265,12 @@
|
|||||||
"node": ">=12.0.0"
|
"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": {
|
"node_modules/html-void-elements": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
"element-plus": "^2.9.4",
|
"element-plus": "^2.9.4",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"hls.js": "^1.6.16",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^2.5.2",
|
"jspdf": "^2.5.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
import { getConfig } from '@/api/app'
|
import { getConfig } from '@/api/app'
|
||||||
|
import configs from '@/config'
|
||||||
|
|
||||||
interface AppSate {
|
interface AppSate {
|
||||||
config: Record<string, any>
|
config: Record<string, any>
|
||||||
@@ -26,8 +27,22 @@ const useAppStore = defineStore({
|
|||||||
getImageUrl(url: string) {
|
getImageUrl(url: string) {
|
||||||
if (!url || typeof url !== 'string') return ''
|
if (!url || typeof url !== 'string') return ''
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
||||||
const domain = this.config.oss_domain || (typeof window !== 'undefined' ? window.location.origin + '/' : '')
|
const path = url.startsWith('/') ? url : '/' + url
|
||||||
return domain ? `${domain.replace(/\/$/, '')}${url.startsWith('/') ? url : '/' + 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() {
|
getConfig() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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="start_time_text" />
|
||||||
<el-table-column label="结束时间" width="170" prop="end_time_text" />
|
<el-table-column label="结束时间" width="170" prop="end_time_text" />
|
||||||
<el-table-column label="通话类型" width="100">
|
<el-table-column label="通话类型" width="100">
|
||||||
@@ -65,38 +70,16 @@
|
|||||||
</upload>
|
</upload>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import RecordingPlaybackBlock from './RecordingPlaybackBlock.vue'
|
||||||
import Upload from '@/components/upload/index.vue'
|
import Upload from '@/components/upload/index.vue'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import { attachLocalCallRecording, createManualCallRecord, getCallRecords } from '@/api/tcm'
|
import { attachLocalCallRecording, createManualCallRecord, getCallRecords } from '@/api/tcm'
|
||||||
import { ref, useTemplateRef, watch, withDefaults } from 'vue'
|
import { ref, useTemplateRef, watch } from 'vue'
|
||||||
|
|
||||||
type CallRecordRow = {
|
type CallRecordRow = {
|
||||||
id: number
|
id: number
|
||||||
@@ -156,11 +139,6 @@ function statusText(status: number) {
|
|||||||
return m[status] ?? '—'
|
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) {
|
async function handleToolbarUploadSuccess(response: any) {
|
||||||
const fileUrl = normalizeUploadUrl(response)
|
const fileUrl = normalizeUploadUrl(response)
|
||||||
if (!fileUrl) {
|
if (!fileUrl) {
|
||||||
@@ -232,24 +210,5 @@ function normalizeUploadUrl(response: any): string {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
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>
|
</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 { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
import { fileURLToPath, URL } from 'url'
|
import { fileURLToPath, URL } from 'url'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import { createStyleImportPlugin, ElementPlusResolve } from 'vite-plugin-style-import'
|
import { createStyleImportPlugin, ElementPlusResolve } from 'vite-plugin-style-import'
|
||||||
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
||||||
import vueSetupExtend from 'vite-plugin-vue-setup-extend'
|
import vueSetupExtend from 'vite-plugin-vue-setup-extend'
|
||||||
@@ -36,13 +36,26 @@ function getElementPlusStylePaths() {
|
|||||||
return stylePaths
|
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/',
|
base: '/admin/',
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
hmr: true,
|
hmr: true,
|
||||||
open: true,
|
open: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
// 开发环境:相对路径 /uploads/* 由 PHP 提供,需转发到后端否则 Vite 端口下 404(录制回放黑屏)
|
||||||
|
'/uploads': {
|
||||||
|
target: uploadsProxyTarget,
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
'/api-proxy': {
|
'/api-proxy': {
|
||||||
target: 'https://admin.zhenyangtang.com.cn',
|
target: 'https://admin.zhenyangtang.com.cn',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
@@ -112,4 +125,5 @@ export default defineConfig({
|
|||||||
__VUE_PROD_HYDRATION__: true,
|
__VUE_PROD_HYDRATION__: true,
|
||||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
|
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user