file列表页预览窗口支持预览常见在线视频服务商的视频,比如youtube,vimeo,哔哩哔哩,优酷,搜狐,爱奇艺等
This commit is contained in:
parent
d4b80a334c
commit
fa00f39550
@ -10,6 +10,7 @@ import { computed } from 'vue'
|
|||||||
import { NodeViewWrapper } from '@tiptap/vue-3'
|
import { NodeViewWrapper } from '@tiptap/vue-3'
|
||||||
import type { NodeViewProps } from '@tiptap/vue-3'
|
import type { NodeViewProps } from '@tiptap/vue-3'
|
||||||
import VideoPreview from '@/views/pagetype/file/VideoPreview.vue'
|
import VideoPreview from '@/views/pagetype/file/VideoPreview.vue'
|
||||||
|
import { detectVideoProvider } from '@/shared/utils/videoProviders'
|
||||||
|
|
||||||
const props = defineProps<NodeViewProps>()
|
const props = defineProps<NodeViewProps>()
|
||||||
|
|
||||||
@ -18,34 +19,8 @@ const hasSrc = computed(() => !!src.value)
|
|||||||
|
|
||||||
// ── URL 检测 ──
|
// ── URL 检测 ──
|
||||||
|
|
||||||
function getYouTubeId(url: string): string | null {
|
const videoProvider = computed(() => detectVideoProvider(src.value))
|
||||||
const patterns = [
|
const isEmbedUrl = computed(() => !!videoProvider.value)
|
||||||
/(?: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 ''
|
|
||||||
})
|
|
||||||
|
|
||||||
function getFileName(url: string): string {
|
function getFileName(url: string): string {
|
||||||
const parts = url.split('/')
|
const parts = url.split('/')
|
||||||
@ -59,13 +34,13 @@ function getFileName(url: string): string {
|
|||||||
data-type="videoBlock"
|
data-type="videoBlock"
|
||||||
class="video-block-nodeview"
|
class="video-block-nodeview"
|
||||||
>
|
>
|
||||||
<!-- YouTube / Vimeo 嵌入 -->
|
<!-- 在线视频嵌入(YouTube / Vimeo / Bilibili 等) -->
|
||||||
<div
|
<div
|
||||||
v-if="hasSrc && isEmbedUrl"
|
v-if="hasSrc && isEmbedUrl"
|
||||||
class="video-block-embed"
|
class="video-block-embed"
|
||||||
>
|
>
|
||||||
<iframe
|
<iframe
|
||||||
:src="embedSrc"
|
:src="videoProvider!.embedUrl"
|
||||||
class="video-block-iframe"
|
class="video-block-iframe"
|
||||||
allow="autoplay; fullscreen; picture-in-picture"
|
allow="autoplay; fullscreen; picture-in-picture"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
|
|||||||
97
frontend/src/shared/utils/videoProviders.ts
Normal file
97
frontend/src/shared/utils/videoProviders.ts
Normal 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
|
||||||
|
}
|
||||||
@ -72,6 +72,15 @@
|
|||||||
@error="onPresentationError"
|
@error="onPresentationError"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div v-else-if="isVideoFile" class="visual-video-wrapper">
|
||||||
<VideoPreview
|
<VideoPreview
|
||||||
@ -199,6 +208,7 @@
|
|||||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import { t } from '@/shared/i18n'
|
import { t } from '@/shared/i18n'
|
||||||
|
import { detectVideoProvider } from '@/shared/utils/videoProviders'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const SpreadsheetPreview = defineAsyncComponent(() => import('./SpreadsheetPreview.vue'))
|
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 isVideoFile = computed(() => VIDEO_EXTENSIONS.includes((props.row?.file_type || '').toLowerCase()))
|
||||||
const isAudioFile = computed(() => AUDIO_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 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']
|
const EDITABLE_EXTENSIONS = ['docx', 'xls', 'xlsx', 'csv', 'txt']
|
||||||
@ -530,6 +545,7 @@ onUnmounted(() => {
|
|||||||
.preview-visual:has(.visual-pdf-wrapper),
|
.preview-visual:has(.visual-pdf-wrapper),
|
||||||
.preview-visual:has(.visual-document-wrapper),
|
.preview-visual:has(.visual-document-wrapper),
|
||||||
.preview-visual:has(.visual-presentation-wrapper),
|
.preview-visual:has(.visual-presentation-wrapper),
|
||||||
|
.preview-visual:has(.visual-online-video-wrapper),
|
||||||
.preview-visual:has(.visual-video-wrapper),
|
.preview-visual:has(.visual-video-wrapper),
|
||||||
.preview-visual:has(.visual-audio-wrapper) {
|
.preview-visual:has(.visual-audio-wrapper) {
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
@ -539,6 +555,7 @@ onUnmounted(() => {
|
|||||||
.preview-visual:has(.visual-spreadsheet-wrapper),
|
.preview-visual:has(.visual-spreadsheet-wrapper),
|
||||||
.preview-visual:has(.visual-text-wrapper),
|
.preview-visual:has(.visual-text-wrapper),
|
||||||
.preview-visual:has(.visual-presentation-wrapper),
|
.preview-visual:has(.visual-presentation-wrapper),
|
||||||
|
.preview-visual:has(.visual-online-video-wrapper),
|
||||||
.preview-visual:has(.visual-video-wrapper),
|
.preview-visual:has(.visual-video-wrapper),
|
||||||
.preview-visual:has(.visual-audio-wrapper) {
|
.preview-visual:has(.visual-audio-wrapper) {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@ -632,6 +649,20 @@ onUnmounted(() => {
|
|||||||
overflow: hidden;
|
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 {
|
.visual-audio-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user