file列表页预览窗口支持预览常见在线视频服务商的视频,比如youtube,vimeo,哔哩哔哩,优酷,搜狐,爱奇艺等

This commit is contained in:
jingrow 2026-06-09 14:26:50 +08:00
parent d4b80a334c
commit fa00f39550
3 changed files with 133 additions and 30 deletions

View File

@ -10,6 +10,7 @@ import { computed } from 'vue'
import { NodeViewWrapper } from '@tiptap/vue-3'
import type { NodeViewProps } from '@tiptap/vue-3'
import VideoPreview from '@/views/pagetype/file/VideoPreview.vue'
import { detectVideoProvider } from '@/shared/utils/videoProviders'
const props = defineProps<NodeViewProps>()
@ -18,34 +19,8 @@ const hasSrc = computed(() => !!src.value)
// URL
function getYouTubeId(url: string): string | null {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
/^([a-zA-Z0-9_-]{11})$/,
]
for (const p of patterns) {
const m = url.match(p)
if (m?.[1]) return m[1]
}
return null
}
function getVimeoId(url: string): string | null {
const m = url.match(/(?:vimeo\.com\/|player\.vimeo\.com\/video\/)(\d+)/)
return m?.[1] || null
}
const isYouTube = computed(() => !!getYouTubeId(src.value))
const isVimeo = computed(() => !!getVimeoId(src.value))
const isEmbedUrl = computed(() => isYouTube.value || isVimeo.value)
const embedSrc = computed(() => {
const ytId = getYouTubeId(src.value)
if (ytId) return `https://www.youtube-nocookie.com/embed/${ytId}`
const vId = getVimeoId(src.value)
if (vId) return `https://player.vimeo.com/video/${vId}`
return ''
})
const videoProvider = computed(() => detectVideoProvider(src.value))
const isEmbedUrl = computed(() => !!videoProvider.value)
function getFileName(url: string): string {
const parts = url.split('/')
@ -59,13 +34,13 @@ function getFileName(url: string): string {
data-type="videoBlock"
class="video-block-nodeview"
>
<!-- YouTube / Vimeo 嵌入 -->
<!-- 在线视频嵌入YouTube / Vimeo / Bilibili -->
<div
v-if="hasSrc && isEmbedUrl"
class="video-block-embed"
>
<iframe
:src="embedSrc"
:src="videoProvider!.embedUrl"
class="video-block-iframe"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen

View File

@ -0,0 +1,97 @@
/**
* videoProviders.ts 线 URL
*
* File.is_remote_file file_url http/https
* URL
* iframe embed URL Block Editor 使
*/
export interface VideoProvider {
name: string
embedUrl: string
}
// ── 各平台 ID 提取与 embed URL 构建 ──
const YOUTUBE_PATTERNS = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
/^([a-zA-Z0-9_-]{11})$/,
]
function detectYouTube(url: string): VideoProvider | null {
for (const p of YOUTUBE_PATTERNS) {
const m = url.match(p)
if (m?.[1]) {
return { name: 'YouTube', embedUrl: `https://www.youtube-nocookie.com/embed/${m[1]}` }
}
}
return null
}
function detectVimeo(url: string): VideoProvider | null {
const m = url.match(/(?:vimeo\.com\/|player\.vimeo\.com\/video\/)(\d+)/)
if (m?.[1]) {
return { name: 'Vimeo', embedUrl: `https://player.vimeo.com/video/${m[1]}` }
}
return null
}
function detectBilibili(url: string): VideoProvider | null {
// bilibili.com/video/BVxxx/ 或 b23.tv/BVxxx
const m = url.match(/(?:bilibili\.com\/video\/|b23\.tv\/)(BV[a-zA-Z0-9]+)/)
if (m?.[1]) {
return { name: 'Bilibili', embedUrl: `https://player.bilibili.com/player.html?bvid=${m[1]}` }
}
return null
}
function detectYouku(url: string): VideoProvider | null {
// v.youku.com/v_show/id_Xxxxxxxxxx.html
const m = url.match(/v\.youku\.com\/v_show\/id_([a-zA-Z0-9=]+)\.html/)
if (m?.[1]) {
return { name: 'Youku', embedUrl: `https://player.youku.com/embed/${m[1]}` }
}
return null
}
function detectIQiyi(url: string): VideoProvider | null {
// www.iqiyi.com/v_videoid.html
const m = url.match(/iqiyi\.com\/(?:v_|)([a-zA-Z0-9]+)\.html/)
if (m?.[1]) {
return { name: 'iQiyi', embedUrl: `https://player.iqiyi.com/player/player.html?vid=${m[1]}` }
}
return null
}
function detectSohu(url: string): VideoProvider | null {
// tv.sohu.com/v/...html 或 my.tv.sohu.com/...
if (/tv\.sohu\.com/.test(url)) {
// Sohu embed URL 格式不固定,返回原始 URL 直接 iframe
return { name: 'Sohu', embedUrl: url }
}
return null
}
// ── 统一检测入口 ──
const DETECTORS: Array<(url: string) => VideoProvider | null> = [
detectYouTube,
detectVimeo,
detectBilibili,
detectYouku,
detectIQiyi,
detectSohu,
]
/**
* URL 线
* { name, embedUrl } null
*/
export function detectVideoProvider(url: string): VideoProvider | null {
if (!url || !/^https?:\/\//.test(url)) return null
for (const detect of DETECTORS) {
const result = detect(url)
if (result) return result
}
return null
}

View File

@ -72,6 +72,15 @@
@error="onPresentationError"
/>
</div>
<!-- 在线视频嵌入 -->
<div v-else-if="onlineVideo" class="visual-online-video-wrapper">
<iframe
:src="onlineVideo.embedUrl"
class="visual-online-video-iframe"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
/>
</div>
<!-- 视频预览 -->
<div v-else-if="isVideoFile" class="visual-video-wrapper">
<VideoPreview
@ -199,6 +208,7 @@
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue'
import { Icon } from '@iconify/vue'
import { t } from '@/shared/i18n'
import { detectVideoProvider } from '@/shared/utils/videoProviders'
import axios from 'axios'
const SpreadsheetPreview = defineAsyncComponent(() => import('./SpreadsheetPreview.vue'))
@ -237,6 +247,11 @@ const isPresentationFile = computed(() => PRESENTATION_EXTENSIONS.includes((prop
const isVideoFile = computed(() => VIDEO_EXTENSIONS.includes((props.row?.file_type || '').toLowerCase()))
const isAudioFile = computed(() => AUDIO_EXTENSIONS.includes((props.row?.file_type || '').toLowerCase()))
const onlineVideo = computed(() => {
const url = props.row?.file_url
return url ? detectVideoProvider(url) : null
})
const canPreview = computed(() => isImageFile.value || isPdfFile.value || isDocxFile.value || isSpreadsheetFile.value || isTextFile.value || isPresentationFile.value || isVideoFile.value || isAudioFile.value)
const EDITABLE_EXTENSIONS = ['docx', 'xls', 'xlsx', 'csv', 'txt']
@ -530,6 +545,7 @@ onUnmounted(() => {
.preview-visual:has(.visual-pdf-wrapper),
.preview-visual:has(.visual-document-wrapper),
.preview-visual:has(.visual-presentation-wrapper),
.preview-visual:has(.visual-online-video-wrapper),
.preview-visual:has(.visual-video-wrapper),
.preview-visual:has(.visual-audio-wrapper) {
min-height: 400px;
@ -539,6 +555,7 @@ onUnmounted(() => {
.preview-visual:has(.visual-spreadsheet-wrapper),
.preview-visual:has(.visual-text-wrapper),
.preview-visual:has(.visual-presentation-wrapper),
.preview-visual:has(.visual-online-video-wrapper),
.preview-visual:has(.visual-video-wrapper),
.preview-visual:has(.visual-audio-wrapper) {
align-items: stretch;
@ -632,6 +649,20 @@ onUnmounted(() => {
overflow: hidden;
}
/* 在线视频嵌入 */
.visual-online-video-wrapper {
width: 100%;
flex: 1;
position: relative;
background: #000;
}
.visual-online-video-iframe {
width: 100%;
height: 100%;
border: none;
}
/* 音频预览 — 允许音量弹窗向上溢出 */
.visual-audio-wrapper {
width: 100%;