先看效果

第一步:先引入 utils/youtube.ts 文件
ts
// utils/youtube.ts
/**
* YouTube 链接解析结果
*/
export interface YouTubeParseResult {
videoId: string | null
type: 'watch' | 'youtu.be' | 'embed' | 'short' | 'invalid'
timestamp?: number // 时间戳(秒)
playlistId?: string | null
}
/**
* 从 YouTube 链接中提取视频 ID
* 支持格式:
* - https://www.youtube.com/watch?v=VIDEO_ID
* - https://youtu.be/VIDEO_ID
* - https://www.youtube.com/embed/VIDEO_ID
* - https://www.youtube.com/shorts/VIDEO_ID
* - https://www.youtube.com/watch?v=VIDEO_ID&t=30s
*
* @param url YouTube 链接
* @returns 视频 ID,如果无法解析则返回 null
*/
export function getYouTubeVideoId(url: string): string | null {
if (!url || typeof url !== 'string') return null
try {
const parsedUrl = new URL(url)
const hostname = parsedUrl.hostname
// 1. 标准 watch 链接: ?v=VIDEO_ID
if (hostname.includes('youtube.com') || hostname.includes('youtu.be')) {
// 尝试从查询参数获取 v
const videoId = parsedUrl.searchParams.get('v')
if (videoId) return videoId
// 2. youtu.be/VIDEO_ID 或 youtube.com/embed/VIDEO_ID
const pathname = parsedUrl.pathname
const pathSegments = pathname.split('/').filter((seg) => seg.length > 0)
// /embed/VIDEO_ID
if (pathname.includes('/embed/') && pathSegments.length >= 2) {
return pathSegments[pathSegments.length - 1]
}
// /shorts/VIDEO_ID
if (pathname.includes('/shorts/') && pathSegments.length >= 2) {
return pathSegments[pathSegments.length - 1]
}
// youtu.be/VIDEO_ID
if (hostname.includes('youtu.be') && pathSegments.length >= 1) {
return pathSegments[0]
}
// /v/VIDEO_ID (旧格式)
if (pathname.includes('/v/') && pathSegments.length >= 2) {
return pathSegments[pathSegments.length - 1]
}
}
return null
} catch {
// URL 格式无效
return null
}
}
/**
* 从 YouTube 链接中提取完整信息(视频 ID、类型、时间戳等)
*/
export function parseYouTubeUrl(url: string): YouTubeParseResult {
const defaultResult: YouTubeParseResult = {
videoId: null,
type: 'invalid',
}
if (!url || typeof url !== 'string') return defaultResult
try {
const parsedUrl = new URL(url)
const hostname = parsedUrl.hostname
let videoId: string | null = null
let type: YouTubeParseResult['type'] = 'invalid'
let timestamp: number | undefined
const playlistId = parsedUrl.searchParams.get('list')
// 解析时间戳
const tParam = parsedUrl.searchParams.get('t')
if (tParam) {
const match = tParam.match(/^(\d+)(?:s|m|h)?$/)
if (match) {
timestamp = parseInt(match[1], 10)
}
}
// 检测链接类型并提取 ID
if (hostname.includes('youtube.com') || hostname.includes('youtu.be')) {
// 标准 watch 链接
const vParam = parsedUrl.searchParams.get('v')
if (vParam) {
videoId = vParam
type = 'watch'
return { videoId, type, timestamp, playlistId }
}
const pathname = parsedUrl.pathname
const pathSegments = pathname.split('/').filter((seg) => seg.length > 0)
// youtu.be/VIDEO_ID
if (hostname.includes('youtu.be') && pathSegments.length >= 1) {
videoId = pathSegments[0]
type = 'youtu.be'
return { videoId, type, timestamp, playlistId }
}
// /embed/VIDEO_ID
if (pathname.includes('/embed/') && pathSegments.length >= 2) {
videoId = pathSegments[pathSegments.length - 1]
type = 'embed'
return { videoId, type, timestamp, playlistId }
}
// /shorts/VIDEO_ID
if (pathname.includes('/shorts/') && pathSegments.length >= 2) {
videoId = pathSegments[pathSegments.length - 1]
type = 'short'
return { videoId, type, timestamp, playlistId }
}
}
return { videoId: null, type: 'invalid' }
} catch {
return defaultResult
}
}
/**
* 验证 YouTube 链接是否有效
*/
export function isValidYouTubeUrl(url: string): boolean {
return getYouTubeVideoId(url) !== null
}
/**
* 生成 YouTube 嵌入链接
* @param videoId 视频 ID
* @param options 配置选项
*/
export function getYouTubeEmbedUrl(
videoId: string,
options?: {
autoplay?: boolean
muted?: boolean
start?: number // 开始时间(秒)
end?: number // 结束时间(秒)
controls?: boolean
rel?: boolean // 是否显示相关视频
},
): string {
const base = `https://www.youtube.com/embed/${videoId}`
const params = new URLSearchParams()
if (options?.autoplay) params.set('autoplay', '1')
if (options?.muted) params.set('mute', '1')
if (options?.start) params.set('start', String(options.start))
if (options?.end) params.set('end', String(options.end))
if (options?.controls === false) params.set('controls', '0')
if (options?.rel === false) params.set('rel', '0')
const queryString = params.toString()
return queryString ? `${base}?${queryString}` : base
}
第二步:编写vue代码
vue
<template>
<MdEditor
theme="dark"
ref="mdEditorRef"
v-model="content"
preview-theme="github"
:toolbars="toolbars"
>
<template #defToolbars>
<div class="md-editor-toolbar-item" title="嵌入YouTube视频" @click="insertYouTubeIframe">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z"
/>
<path d="M9.545 15.568L15.818 12 9.545 8.432v7.136z" fill="#ffffff" />
</svg>
</div>
</template>
</MdEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { ElMessageBox } from 'element-plus'
import { getYouTubeEmbedUrl, parseYouTubeUrl } from '@/utils/youtube.ts'
const content = ref('')
const mdEditorRef = ref<any>()
// md编辑器工具栏
const toolbars: any[] = [
'bold',
'underline',
'italic',
'-',
'title',
'strikeThrough',
'sub',
'sup',
'quote',
'unorderedList',
'orderedList',
'-',
'codeRow',
'code',
'link',
'image',
'table',
0, // 这里的名字要和下面插槽内对应(或者直接按插槽顺序渲染)
'-',
'revoke',
'next',
'save',
'=',
'pageFullscreen',
'fullscreen',
'preview',
'htmlPreview',
'catalog',
]
const insertYouTubeIframe = async () => {
const selectedText = mdEditorRef.value?.getSelectedText().trim()
const { value } = await ElMessageBox.prompt('请填入视频地址:', '嵌入YouTube', {
confirmButtonText: '插入',
cancelButtonText: '取消',
inputValue: selectedText,
inputPattern: /^https?:\/\/.+/,
inputErrorMessage: '请输入有效的地址',
})
const inputValueUrl = value.trim()
if (!inputValueUrl) return ElMessageBox.alert('视频地址不能为空')
const youtube = parseYouTubeUrl(inputValueUrl)
if (youtube.type === 'invalid') return ElMessageBox.alert('invalid')
const videoId = youtube.videoId ? youtube.videoId : ''
const embedUrl = getYouTubeEmbedUrl(videoId, { start: youtube.timestamp })
// 安全地通过 CodeMirror 实例注入文本,保留历史记录(撤销/重做)
mdEditorRef.value?.insert(() => {
const iframeHtml = `<div style="width: 100%;aspect-ratio: 16 / 9;">
<iframe
src="${embedUrl}"
allowfullscreen="allowfullscreen"
style="border: none;border-radius:8px;width: 100%;height: 100%"
></iframe>
</div>`
return {
targetValue: iframeHtml,
select: false,
deviationStart: 0,
deviationEnd: 0,
}
})
}
</script>
下面是个例子
评论
发表评论