Explorar el Código

add cloudflare

yb hace 1 mes
padre
commit
7fe60090ee

+ 7 - 0
package-lock.json

@@ -11,6 +11,7 @@
         "@element-plus/icons-vue": "^2.3.2",
         "axios": "^1.13.2",
         "element-plus": "^2.13.0",
+        "hls.js": "^1.6.15",
         "pinia": "^3.0.4",
         "sass": "^1.97.1",
         "vue": "^3.5.24",
@@ -1988,6 +1989,12 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/hls.js": {
+      "version": "1.6.15",
+      "resolved": "https://registry.npmmirror.com/hls.js/-/hls.js-1.6.15.tgz",
+      "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
+      "license": "Apache-2.0"
+    },
     "node_modules/hookable": {
       "version": "5.5.3",
       "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",

+ 1 - 0
package.json

@@ -12,6 +12,7 @@
     "@element-plus/icons-vue": "^2.3.2",
     "axios": "^1.13.2",
     "element-plus": "^2.13.0",
+    "hls.js": "^1.6.15",
     "pinia": "^3.0.4",
     "sass": "^1.97.1",
     "vue": "^3.5.24",

+ 347 - 0
src/api/cloudflare-stream.ts

@@ -0,0 +1,347 @@
+/**
+ * Cloudflare Stream API 封装
+ *
+ * 使用方式:
+ * 1. 直接调用 Cloudflare API(需要 API Token,适合后端代理)
+ * 2. 通过后端代理调用(推荐,避免暴露 Token)
+ *
+ * API 文档:https://developers.cloudflare.com/stream/
+ */
+
+import axios, { type AxiosInstance } from 'axios'
+import type {
+  CloudflareVideo,
+  CloudflareLiveInput,
+  CloudflareLiveOutput,
+  CloudflareApiResponse,
+  CloudflareListResponse,
+  CreateLiveInputParams,
+  CreateUploadUrlParams,
+  DirectUploadResponse,
+  VideoPlaybackInfo
+} from '@/types/cloudflare'
+
+// Cloudflare Stream 配置
+export interface CloudflareStreamConfig {
+  accountId: string           // Cloudflare Account ID
+  apiToken?: string           // API Token(直接调用时需要)
+  customerSubdomain?: string  // 客户子域名,如 customer-xxx
+  baseUrl?: string            // 自定义 API 基础 URL(用于后端代理)
+}
+
+class CloudflareStreamApi {
+  private client: AxiosInstance
+  private config: CloudflareStreamConfig
+
+  constructor(config: CloudflareStreamConfig) {
+    this.config = config
+
+    // 创建 axios 实例
+    this.client = axios.create({
+      baseURL: config.baseUrl || `https://api.cloudflare.com/client/v4/accounts/${config.accountId}/stream`,
+      timeout: 30000,
+      headers: {
+        'Content-Type': 'application/json',
+        ...(config.apiToken ? { 'Authorization': `Bearer ${config.apiToken}` } : {})
+      }
+    })
+
+    // 响应拦截器
+    this.client.interceptors.response.use(
+      (response) => response.data,
+      (error) => {
+        console.error('Cloudflare Stream API Error:', error.response?.data || error.message)
+        return Promise.reject(error)
+      }
+    )
+  }
+
+  /**
+   * 获取客户播放域名
+   */
+  getCustomerDomain(): string {
+    return this.config.customerSubdomain
+      ? `customer-${this.config.customerSubdomain}.cloudflarestream.com`
+      : `customer-${this.config.accountId}.cloudflarestream.com`
+  }
+
+  // ==================== 视频管理 ====================
+
+  /**
+   * 获取视频列表
+   */
+  async listVideos(params?: {
+    after?: string
+    before?: string
+    include_counts?: boolean
+    search?: string
+    limit?: number
+    asc?: boolean
+    status?: string
+  }): Promise<CloudflareListResponse<CloudflareVideo>> {
+    return this.client.get('', { params })
+  }
+
+  /**
+   * 获取视频详情
+   */
+  async getVideo(videoId: string): Promise<CloudflareApiResponse<CloudflareVideo>> {
+    return this.client.get(`/${videoId}`)
+  }
+
+  /**
+   * 删除视频
+   */
+  async deleteVideo(videoId: string): Promise<CloudflareApiResponse<null>> {
+    return this.client.delete(`/${videoId}`)
+  }
+
+  /**
+   * 更新视频元数据
+   */
+  async updateVideo(videoId: string, data: {
+    meta?: Record<string, any>
+    allowedOrigins?: string[]
+    requireSignedURLs?: boolean
+    scheduledDeletion?: string
+    uploadExpiry?: string
+  }): Promise<CloudflareApiResponse<CloudflareVideo>> {
+    return this.client.post(`/${videoId}`, data)
+  }
+
+  /**
+   * 从 URL 复制视频
+   */
+  async copyFromUrl(url: string, meta?: Record<string, any>): Promise<CloudflareApiResponse<CloudflareVideo>> {
+    return this.client.post('/copy', { url, meta })
+  }
+
+  /**
+   * 裁剪视频
+   */
+  async clipVideo(videoId: string, params: {
+    clippedFromVideoUID: string
+    startTimeSeconds: number
+    endTimeSeconds: number
+    meta?: Record<string, any>
+  }): Promise<CloudflareApiResponse<CloudflareVideo>> {
+    return this.client.post('/clip', params)
+  }
+
+  // ==================== 上传管理 ====================
+
+  /**
+   * 创建直接上传 URL(TUS 协议)
+   */
+  async createDirectUploadUrl(params?: CreateUploadUrlParams): Promise<CloudflareApiResponse<DirectUploadResponse['result']>> {
+    return this.client.post('/direct_upload', params || {})
+  }
+
+  /**
+   * 使用 TUS 协议上传视频
+   * 注意:大文件上传建议使用 tus-js-client 库
+   */
+  async uploadWithTus(file: File, uploadUrl: string, onProgress?: (progress: number) => void): Promise<void> {
+    // 动态导入 tus-js-client
+    const { Upload } = await import('tus-js-client')
+
+    return new Promise((resolve, reject) => {
+      const upload = new Upload(file, {
+        endpoint: uploadUrl,
+        retryDelays: [0, 3000, 5000, 10000, 20000],
+        metadata: {
+          filename: file.name,
+          filetype: file.type
+        },
+        onError: (error) => {
+          console.error('Upload failed:', error)
+          reject(error)
+        },
+        onProgress: (bytesUploaded, bytesTotal) => {
+          const percentage = Math.round((bytesUploaded / bytesTotal) * 100)
+          onProgress?.(percentage)
+        },
+        onSuccess: () => {
+          resolve()
+        }
+      })
+
+      upload.start()
+    })
+  }
+
+  // ==================== 播放相关 ====================
+
+  /**
+   * 获取视频播放信息
+   */
+  getPlaybackInfo(videoId: string): VideoPlaybackInfo {
+    const domain = this.getCustomerDomain()
+    return {
+      uid: videoId,
+      preview: `https://${domain}/${videoId}/watch`,
+      thumbnail: `https://${domain}/${videoId}/thumbnails/thumbnail.jpg`,
+      playback: {
+        hls: `https://${domain}/${videoId}/manifest/video.m3u8`,
+        dash: `https://${domain}/${videoId}/manifest/video.mpd`
+      },
+      iframe: `https://${domain}/${videoId}/iframe`
+    }
+  }
+
+  /**
+   * 生成带时间戳的缩略图 URL
+   */
+  getThumbnailUrl(videoId: string, options?: {
+    time?: string      // 时间,如 "1s", "5m30s"
+    height?: number    // 高度
+    width?: number     // 宽度
+    fit?: 'crop' | 'clip' | 'scale' | 'fill'
+  }): string {
+    const domain = this.getCustomerDomain()
+    const params = new URLSearchParams()
+    if (options?.time) params.set('time', options.time)
+    if (options?.height) params.set('height', options.height.toString())
+    if (options?.width) params.set('width', options.width.toString())
+    if (options?.fit) params.set('fit', options.fit)
+    const queryString = params.toString()
+    return `https://${domain}/${videoId}/thumbnails/thumbnail.jpg${queryString ? '?' + queryString : ''}`
+  }
+
+  /**
+   * 生成动图预览 URL
+   */
+  getAnimatedThumbnailUrl(videoId: string, options?: {
+    start?: string     // 开始时间
+    end?: string       // 结束时间
+    height?: number
+    width?: number
+    fit?: 'crop' | 'clip' | 'scale' | 'fill'
+    fps?: number
+  }): string {
+    const domain = this.getCustomerDomain()
+    const params = new URLSearchParams()
+    if (options?.start) params.set('start', options.start)
+    if (options?.end) params.set('end', options.end)
+    if (options?.height) params.set('height', options.height.toString())
+    if (options?.width) params.set('width', options.width.toString())
+    if (options?.fit) params.set('fit', options.fit)
+    if (options?.fps) params.set('fps', options.fps.toString())
+    const queryString = params.toString()
+    return `https://${domain}/${videoId}/thumbnails/thumbnail.gif${queryString ? '?' + queryString : ''}`
+  }
+
+  // ==================== 直播管理 ====================
+
+  /**
+   * 获取直播输入列表
+   */
+  async listLiveInputs(): Promise<CloudflareListResponse<CloudflareLiveInput>> {
+    return this.client.get('/live_inputs')
+  }
+
+  /**
+   * 创建直播输入
+   */
+  async createLiveInput(params?: CreateLiveInputParams): Promise<CloudflareApiResponse<CloudflareLiveInput>> {
+    return this.client.post('/live_inputs', params || {})
+  }
+
+  /**
+   * 获取直播输入详情
+   */
+  async getLiveInput(liveInputId: string): Promise<CloudflareApiResponse<CloudflareLiveInput>> {
+    return this.client.get(`/live_inputs/${liveInputId}`)
+  }
+
+  /**
+   * 更新直播输入
+   */
+  async updateLiveInput(liveInputId: string, params: CreateLiveInputParams): Promise<CloudflareApiResponse<CloudflareLiveInput>> {
+    return this.client.put(`/live_inputs/${liveInputId}`, params)
+  }
+
+  /**
+   * 删除直播输入
+   */
+  async deleteLiveInput(liveInputId: string): Promise<CloudflareApiResponse<null>> {
+    return this.client.delete(`/live_inputs/${liveInputId}`)
+  }
+
+  /**
+   * 获取直播输入的播放信息
+   */
+  getLivePlaybackInfo(liveInputId: string): VideoPlaybackInfo {
+    const domain = this.getCustomerDomain()
+    return {
+      uid: liveInputId,
+      preview: `https://${domain}/${liveInputId}/watch`,
+      thumbnail: `https://${domain}/${liveInputId}/thumbnails/thumbnail.jpg`,
+      playback: {
+        hls: `https://${domain}/${liveInputId}/manifest/video.m3u8`,
+        dash: `https://${domain}/${liveInputId}/manifest/video.mpd`
+      },
+      iframe: `https://${domain}/${liveInputId}/iframe`
+    }
+  }
+
+  // ==================== 直播输出(转推) ====================
+
+  /**
+   * 获取直播输出列表
+   */
+  async listLiveOutputs(liveInputId: string): Promise<CloudflareListResponse<CloudflareLiveOutput>> {
+    return this.client.get(`/live_inputs/${liveInputId}/outputs`)
+  }
+
+  /**
+   * 创建直播输出(转推到其他平台)
+   */
+  async createLiveOutput(liveInputId: string, params: {
+    url: string
+    streamKey: string
+    enabled?: boolean
+  }): Promise<CloudflareApiResponse<CloudflareLiveOutput>> {
+    return this.client.post(`/live_inputs/${liveInputId}/outputs`, params)
+  }
+
+  /**
+   * 删除直播输出
+   */
+  async deleteLiveOutput(liveInputId: string, outputId: string): Promise<CloudflareApiResponse<null>> {
+    return this.client.delete(`/live_inputs/${liveInputId}/outputs/${outputId}`)
+  }
+
+  // ==================== 录像管理 ====================
+
+  /**
+   * 获取直播录像列表
+   */
+  async listLiveRecordings(liveInputId: string): Promise<CloudflareListResponse<CloudflareVideo>> {
+    return this.client.get(`/live_inputs/${liveInputId}/videos`)
+  }
+}
+
+// 创建默认实例(需要在使用前配置)
+let streamApiInstance: CloudflareStreamApi | null = null
+
+/**
+ * 初始化 Cloudflare Stream API
+ */
+export function initCloudflareStream(config: CloudflareStreamConfig): CloudflareStreamApi {
+  streamApiInstance = new CloudflareStreamApi(config)
+  return streamApiInstance
+}
+
+/**
+ * 获取 Cloudflare Stream API 实例
+ */
+export function useCloudflareStream(): CloudflareStreamApi {
+  if (!streamApiInstance) {
+    throw new Error('Cloudflare Stream API not initialized. Call initCloudflareStream first.')
+  }
+  return streamApiInstance
+}
+
+export { CloudflareStreamApi }
+export default CloudflareStreamApi

+ 156 - 0
src/api/stream.ts

@@ -0,0 +1,156 @@
+/**
+ * 视频流管理 API(通过后端代理)
+ *
+ * 推荐使用这个封装,通过后端代理调用 Cloudflare API
+ * 避免在前端暴露 API Token
+ */
+
+import { get, post, put, del } from '@/utils/request'
+import type { ApiResponse } from '@/types'
+import type {
+  CloudflareVideo,
+  CloudflareLiveInput,
+  CreateLiveInputParams,
+  CreateUploadUrlParams,
+  VideoPlaybackInfo
+} from '@/types/cloudflare'
+
+// ==================== 视频管理 ====================
+
+/**
+ * 获取视频列表
+ */
+export function listVideos(params?: {
+  pageNum?: number
+  pageSize?: number
+  search?: string
+  status?: string
+}): Promise<ApiResponse<{ rows: CloudflareVideo[]; total: number }>> {
+  return get('/stream/video/list', params)
+}
+
+/**
+ * 获取视频详情
+ */
+export function getVideo(videoId: string): Promise<ApiResponse<CloudflareVideo>> {
+  return get(`/stream/video/${videoId}`)
+}
+
+/**
+ * 删除视频
+ */
+export function deleteVideo(videoId: string): Promise<ApiResponse<null>> {
+  return del(`/stream/video/${videoId}`)
+}
+
+/**
+ * 从 URL 导入视频
+ */
+export function importVideoFromUrl(data: {
+  url: string
+  name?: string
+  meta?: Record<string, any>
+}): Promise<ApiResponse<CloudflareVideo>> {
+  return post('/stream/video/import', data)
+}
+
+/**
+ * 获取上传 URL
+ */
+export function getUploadUrl(params?: CreateUploadUrlParams): Promise<ApiResponse<{
+  uploadURL: string
+  uid: string
+}>> {
+  return post('/stream/video/upload-url', params)
+}
+
+/**
+ * 获取视频播放信息
+ */
+export function getVideoPlayback(videoId: string): Promise<ApiResponse<VideoPlaybackInfo>> {
+  return get(`/stream/video/${videoId}/playback`)
+}
+
+// ==================== 直播管理 ====================
+
+/**
+ * 获取直播输入列表
+ */
+export function listLiveInputs(params?: {
+  pageNum?: number
+  pageSize?: number
+}): Promise<ApiResponse<{ rows: CloudflareLiveInput[]; total: number }>> {
+  return get('/stream/live/list', params)
+}
+
+/**
+ * 创建直播输入
+ */
+export function createLiveInput(data?: CreateLiveInputParams): Promise<ApiResponse<CloudflareLiveInput>> {
+  return post('/stream/live', data)
+}
+
+/**
+ * 获取直播输入详情
+ */
+export function getLiveInput(liveInputId: string): Promise<ApiResponse<CloudflareLiveInput>> {
+  return get(`/stream/live/${liveInputId}`)
+}
+
+/**
+ * 更新直播输入
+ */
+export function updateLiveInput(liveInputId: string, data: CreateLiveInputParams): Promise<ApiResponse<CloudflareLiveInput>> {
+  return put(`/stream/live/${liveInputId}`, data)
+}
+
+/**
+ * 删除直播输入
+ */
+export function deleteLiveInput(liveInputId: string): Promise<ApiResponse<null>> {
+  return del(`/stream/live/${liveInputId}`)
+}
+
+/**
+ * 获取直播播放信息
+ */
+export function getLivePlayback(liveInputId: string): Promise<ApiResponse<VideoPlaybackInfo>> {
+  return get(`/stream/live/${liveInputId}/playback`)
+}
+
+/**
+ * 获取直播录像列表
+ */
+export function listLiveRecordings(liveInputId: string, params?: {
+  pageNum?: number
+  pageSize?: number
+}): Promise<ApiResponse<{ rows: CloudflareVideo[]; total: number }>> {
+  return get(`/stream/live/${liveInputId}/recordings`, params)
+}
+
+// ==================== 转推管理 ====================
+
+/**
+ * 获取转推输出列表
+ */
+export function listLiveOutputs(liveInputId: string): Promise<ApiResponse<any[]>> {
+  return get(`/stream/live/${liveInputId}/outputs`)
+}
+
+/**
+ * 创建转推输出
+ */
+export function createLiveOutput(liveInputId: string, data: {
+  url: string
+  streamKey: string
+  enabled?: boolean
+}): Promise<ApiResponse<any>> {
+  return post(`/stream/live/${liveInputId}/outputs`, data)
+}
+
+/**
+ * 删除转推输出
+ */
+export function deleteLiveOutput(liveInputId: string, outputId: string): Promise<ApiResponse<null>> {
+  return del(`/stream/live/${liveInputId}/outputs/${outputId}`)
+}

+ 340 - 0
src/components/VideoPlayer.vue

@@ -0,0 +1,340 @@
+<template>
+  <div class="video-player-wrapper" ref="wrapperRef">
+    <!-- Cloudflare Stream Player (iframe 模式) -->
+    <template v-if="playerType === 'cloudflare'">
+      <iframe
+        :src="cloudflareIframeSrc"
+        class="cloudflare-iframe"
+        allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture"
+        allowfullscreen
+      />
+    </template>
+
+    <!-- HLS.js Player -->
+    <template v-else-if="playerType === 'hls'">
+      <video
+        ref="videoRef"
+        class="video-element"
+        :controls="controls"
+        :autoplay="autoplay"
+        :muted="muted"
+        :loop="loop"
+        :poster="poster"
+        playsinline
+      />
+    </template>
+
+    <!-- Jessibuca Player (FLV/GB28181) -->
+    <template v-else-if="playerType === 'jessibuca'">
+      <div ref="jessibucaContainer" class="jessibuca-container" />
+    </template>
+
+    <!-- Native Video (MP4/WebM) -->
+    <template v-else>
+      <video
+        ref="videoRef"
+        class="video-element"
+        :src="src"
+        :controls="controls"
+        :autoplay="autoplay"
+        :muted="muted"
+        :loop="loop"
+        :poster="poster"
+        playsinline
+      />
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
+
+interface Props {
+  src?: string                    // 视频源地址
+  videoId?: string                // Cloudflare Stream video ID
+  customerDomain?: string         // Cloudflare 自定义域名
+  playerType?: 'cloudflare' | 'hls' | 'jessibuca' | 'native'
+  useIframe?: boolean             // Cloudflare 是否使用 iframe
+  controls?: boolean
+  autoplay?: boolean
+  muted?: boolean
+  loop?: boolean
+  poster?: string
+  preload?: 'auto' | 'metadata' | 'none'
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  playerType: 'native',
+  useIframe: true,
+  controls: true,
+  autoplay: false,
+  muted: true,
+  loop: false,
+  preload: 'metadata'
+})
+
+const emit = defineEmits<{
+  play: []
+  pause: []
+  ended: []
+  error: [error: any]
+  timeupdate: [currentTime: number]
+}>()
+
+const wrapperRef = ref<HTMLElement>()
+const videoRef = ref<HTMLVideoElement>()
+const jessibucaContainer = ref<HTMLElement>()
+
+let hlsInstance: any = null
+let jessibucaInstance: any = null
+
+// Cloudflare Stream iframe URL
+const cloudflareIframeSrc = computed(() => {
+  if (!props.videoId) return ''
+  const domain = props.customerDomain || 'customer-xxx.cloudflarestream.com'
+  const params = new URLSearchParams()
+  if (props.autoplay) params.set('autoplay', 'true')
+  if (props.muted) params.set('muted', 'true')
+  if (props.loop) params.set('loop', 'true')
+  if (props.preload) params.set('preload', props.preload)
+  const queryString = params.toString()
+  return `https://${domain}/${props.videoId}/iframe${queryString ? '?' + queryString : ''}`
+})
+
+// 初始化 HLS.js
+async function initHls() {
+  if (!videoRef.value || !props.src) return
+
+  // 动态导入 hls.js
+  const { default: Hls } = await import('hls.js')
+
+  if (Hls.isSupported()) {
+    hlsInstance = new Hls({
+      debug: false,
+      enableWorker: true,
+      lowLatencyMode: true
+    })
+
+    hlsInstance.loadSource(props.src)
+    hlsInstance.attachMedia(videoRef.value)
+
+    hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
+      if (props.autoplay) {
+        videoRef.value?.play()
+      }
+    })
+
+    hlsInstance.on(Hls.Events.ERROR, (_event: any, data: any) => {
+      if (data.fatal) {
+        emit('error', data)
+        switch (data.type) {
+          case Hls.ErrorTypes.NETWORK_ERROR:
+            hlsInstance.startLoad()
+            break
+          case Hls.ErrorTypes.MEDIA_ERROR:
+            hlsInstance.recoverMediaError()
+            break
+          default:
+            destroyHls()
+            break
+        }
+      }
+    })
+  } else if (videoRef.value.canPlayType('application/vnd.apple.mpegurl')) {
+    // Safari 原生支持 HLS
+    videoRef.value.src = props.src
+    if (props.autoplay) {
+      videoRef.value.play()
+    }
+  }
+
+  // 绑定事件
+  bindVideoEvents()
+}
+
+// 初始化 Jessibuca
+function initJessibuca() {
+  if (!jessibucaContainer.value || !window.Jessibuca) return
+
+  jessibucaInstance = new window.Jessibuca({
+    container: jessibucaContainer.value,
+    videoBuffer: 0.2,
+    decoder: '/js/jessibuca/decoder.js',
+    timeout: 20,
+    debug: false,
+    isResize: false,
+    loadingText: '加载中...',
+    isFlv: true,
+    showBandwidth: true,
+    supportDblclickFullscreen: true,
+    operateBtns: {
+      fullscreen: true,
+      screenshot: true,
+      play: true,
+      audio: true
+    },
+    forceNoOffscreen: true,
+    isNotMute: !props.muted
+  })
+
+  jessibucaInstance.on('error', (error: any) => {
+    emit('error', error)
+  })
+
+  jessibucaInstance.on('play', () => {
+    emit('play')
+  })
+
+  jessibucaInstance.on('pause', () => {
+    emit('pause')
+  })
+}
+
+function bindVideoEvents() {
+  if (!videoRef.value) return
+
+  videoRef.value.addEventListener('play', () => emit('play'))
+  videoRef.value.addEventListener('pause', () => emit('pause'))
+  videoRef.value.addEventListener('ended', () => emit('ended'))
+  videoRef.value.addEventListener('error', (e) => emit('error', e))
+  videoRef.value.addEventListener('timeupdate', () => {
+    emit('timeupdate', videoRef.value?.currentTime || 0)
+  })
+}
+
+function destroyHls() {
+  if (hlsInstance) {
+    hlsInstance.destroy()
+    hlsInstance = null
+  }
+}
+
+function destroyJessibuca() {
+  if (jessibucaInstance) {
+    jessibucaInstance.destroy()
+    jessibucaInstance = null
+  }
+}
+
+// 公开方法
+function play(url?: string) {
+  if (props.playerType === 'jessibuca' && jessibucaInstance) {
+    jessibucaInstance.play(url || props.src)
+  } else if (videoRef.value) {
+    videoRef.value.play()
+  }
+}
+
+function pause() {
+  if (props.playerType === 'jessibuca' && jessibucaInstance) {
+    jessibucaInstance.pause()
+  } else if (videoRef.value) {
+    videoRef.value.pause()
+  }
+}
+
+function stop() {
+  if (props.playerType === 'jessibuca' && jessibucaInstance) {
+    jessibucaInstance.destroy()
+    initJessibuca()
+  } else if (videoRef.value) {
+    videoRef.value.pause()
+    videoRef.value.currentTime = 0
+  }
+}
+
+function setVolume(volume: number) {
+  if (props.playerType === 'jessibuca' && jessibucaInstance) {
+    jessibucaInstance.setVolume(volume)
+  } else if (videoRef.value) {
+    videoRef.value.volume = volume
+  }
+}
+
+function setMuted(muted: boolean) {
+  if (props.playerType === 'jessibuca' && jessibucaInstance) {
+    muted ? jessibucaInstance.mute() : jessibucaInstance.cancelMute()
+  } else if (videoRef.value) {
+    videoRef.value.muted = muted
+  }
+}
+
+function screenshot() {
+  if (props.playerType === 'jessibuca' && jessibucaInstance) {
+    jessibucaInstance.screenshot()
+  } else if (videoRef.value) {
+    const canvas = document.createElement('canvas')
+    canvas.width = videoRef.value.videoWidth
+    canvas.height = videoRef.value.videoHeight
+    const ctx = canvas.getContext('2d')
+    ctx?.drawImage(videoRef.value, 0, 0)
+    const link = document.createElement('a')
+    link.download = `screenshot-${Date.now()}.png`
+    link.href = canvas.toDataURL('image/png')
+    link.click()
+  }
+}
+
+function fullscreen() {
+  if (props.playerType === 'jessibuca' && jessibucaInstance) {
+    jessibucaInstance.setFullscreen(true)
+  } else if (wrapperRef.value) {
+    wrapperRef.value.requestFullscreen?.()
+  }
+}
+
+defineExpose({
+  play,
+  pause,
+  stop,
+  setVolume,
+  setMuted,
+  screenshot,
+  fullscreen
+})
+
+watch(() => props.src, (newSrc) => {
+  if (props.playerType === 'hls' && newSrc) {
+    destroyHls()
+    initHls()
+  }
+})
+
+onMounted(() => {
+  if (props.playerType === 'hls') {
+    initHls()
+  } else if (props.playerType === 'jessibuca') {
+    initJessibuca()
+  } else if (props.playerType === 'native') {
+    bindVideoEvents()
+  }
+})
+
+onBeforeUnmount(() => {
+  destroyHls()
+  destroyJessibuca()
+})
+</script>
+
+<style lang="scss" scoped>
+.video-player-wrapper {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  background-color: #000;
+  overflow: hidden;
+}
+
+.cloudflare-iframe,
+.video-element,
+.jessibuca-container,
+.cloudflare-container {
+  width: 100%;
+  height: 100%;
+  border: none;
+}
+
+.video-element {
+  object-fit: contain;
+}
+</style>

+ 5 - 1
src/layout/index.vue

@@ -18,6 +18,10 @@
           <el-icon><VideoCamera /></el-icon>
           <template #title>摄像头管理</template>
         </el-menu-item>
+        <el-menu-item index="/stream-test">
+          <el-icon><Monitor /></el-icon>
+          <template #title>Stream 测试</template>
+        </el-menu-item>
       </el-menu>
     </el-aside>
 
@@ -64,7 +68,7 @@
 <script setup lang="ts">
 import { computed, onMounted } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
-import { VideoCamera, Fold, Expand, ArrowDown } from '@element-plus/icons-vue'
+import { VideoCamera, Fold, Expand, ArrowDown, Monitor } from '@element-plus/icons-vue'
 import { useAppStore } from '@/store/app'
 import { useUserStore } from '@/store/user'
 

+ 8 - 2
src/router/index.ts

@@ -32,6 +32,12 @@ const routes: RouteRecordRaw[] = [
         name: 'CameraVideo',
         component: () => import('@/views/camera/video.vue'),
         meta: { title: '视频播放', hidden: true }
+      },
+      {
+        path: 'stream-test',
+        name: 'StreamTest',
+        component: () => import('@/views/camera/stream-test.vue'),
+        meta: { title: 'Stream 测试', icon: 'Monitor' }
       }
     ]
   },
@@ -46,8 +52,8 @@ const router = createRouter({
   routes
 })
 
-// 白名单
-const whiteList = ['/login']
+// 白名单(无需登录即可访问)
+const whiteList = ['/login', '/stream-test']
 
 router.beforeEach((to, _from, next) => {
   document.title = (to.meta?.title as string) || '摄像头管理系统'

+ 157 - 0
src/store/stream.ts

@@ -0,0 +1,157 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import type { CloudflareVideo, CloudflareLiveInput, VideoPlaybackInfo } from '@/types/cloudflare'
+
+// Cloudflare Stream 配置
+export interface StreamConfig {
+  accountId: string
+  customerSubdomain: string
+  apiToken?: string
+}
+
+export const useStreamStore = defineStore('stream', () => {
+  // 配置
+  const config = ref<StreamConfig>({
+    accountId: '',
+    customerSubdomain: ''
+  })
+
+  // 视频列表
+  const videos = ref<CloudflareVideo[]>([])
+  const videosLoading = ref(false)
+  const videosTotal = ref(0)
+
+  // 直播列表
+  const liveInputs = ref<CloudflareLiveInput[]>([])
+  const liveInputsLoading = ref(false)
+
+  // 当前播放
+  const currentVideo = ref<CloudflareVideo | null>(null)
+  const currentLiveInput = ref<CloudflareLiveInput | null>(null)
+
+  // 计算域名
+  const customerDomain = computed(() => {
+    if (config.value.customerSubdomain) {
+      return `customer-${config.value.customerSubdomain}.cloudflarestream.com`
+    }
+    return ''
+  })
+
+  // 初始化配置
+  function initConfig(newConfig: Partial<StreamConfig>) {
+    config.value = { ...config.value, ...newConfig }
+    // 保存到 localStorage
+    localStorage.setItem('stream-config', JSON.stringify(config.value))
+  }
+
+  // 从 localStorage 加载配置
+  function loadConfig() {
+    const saved = localStorage.getItem('stream-config')
+    if (saved) {
+      try {
+        config.value = JSON.parse(saved)
+      } catch (e) {
+        console.error('Failed to load stream config:', e)
+      }
+    }
+  }
+
+  // 生成播放信息
+  function getPlaybackInfo(videoId: string): VideoPlaybackInfo | null {
+    if (!customerDomain.value) return null
+
+    return {
+      uid: videoId,
+      preview: `https://${customerDomain.value}/${videoId}/watch`,
+      thumbnail: `https://${customerDomain.value}/${videoId}/thumbnails/thumbnail.jpg`,
+      playback: {
+        hls: `https://${customerDomain.value}/${videoId}/manifest/video.m3u8`,
+        dash: `https://${customerDomain.value}/${videoId}/manifest/video.mpd`
+      },
+      iframe: `https://${customerDomain.value}/${videoId}/iframe`
+    }
+  }
+
+  // 生成缩略图 URL
+  function getThumbnailUrl(videoId: string, options?: {
+    time?: string
+    height?: number
+    width?: number
+  }): string {
+    if (!customerDomain.value) return ''
+
+    const params = new URLSearchParams()
+    if (options?.time) params.set('time', options.time)
+    if (options?.height) params.set('height', options.height.toString())
+    if (options?.width) params.set('width', options.width.toString())
+    const queryString = params.toString()
+
+    return `https://${customerDomain.value}/${videoId}/thumbnails/thumbnail.jpg${queryString ? '?' + queryString : ''}`
+  }
+
+  // 生成 iframe URL
+  function getIframeUrl(videoId: string, options?: {
+    autoplay?: boolean
+    muted?: boolean
+    loop?: boolean
+    preload?: string
+    primaryColor?: string
+    letterboxColor?: string
+    startTime?: string
+    defaultTextTrack?: string
+  }): string {
+    if (!customerDomain.value) return ''
+
+    const params = new URLSearchParams()
+    if (options?.autoplay) params.set('autoplay', 'true')
+    if (options?.muted) params.set('muted', 'true')
+    if (options?.loop) params.set('loop', 'true')
+    if (options?.preload) params.set('preload', options.preload)
+    if (options?.primaryColor) params.set('primaryColor', options.primaryColor)
+    if (options?.letterboxColor) params.set('letterboxColor', options.letterboxColor)
+    if (options?.startTime) params.set('startTime', options.startTime)
+    if (options?.defaultTextTrack) params.set('defaultTextTrack', options.defaultTextTrack)
+    const queryString = params.toString()
+
+    return `https://${customerDomain.value}/${videoId}/iframe${queryString ? '?' + queryString : ''}`
+  }
+
+  // 生成 HLS URL
+  function getHlsUrl(videoId: string): string {
+    if (!customerDomain.value) return ''
+    return `https://${customerDomain.value}/${videoId}/manifest/video.m3u8`
+  }
+
+  // 设置当前视频
+  function setCurrentVideo(video: CloudflareVideo | null) {
+    currentVideo.value = video
+  }
+
+  // 设置当前直播
+  function setCurrentLiveInput(liveInput: CloudflareLiveInput | null) {
+    currentLiveInput.value = liveInput
+  }
+
+  return {
+    // 状态
+    config,
+    videos,
+    videosLoading,
+    videosTotal,
+    liveInputs,
+    liveInputsLoading,
+    currentVideo,
+    currentLiveInput,
+    customerDomain,
+
+    // 方法
+    initConfig,
+    loadConfig,
+    getPlaybackInfo,
+    getThumbnailUrl,
+    getIframeUrl,
+    getHlsUrl,
+    setCurrentVideo,
+    setCurrentLiveInput
+  }
+})

+ 203 - 0
src/types/cloudflare.ts

@@ -0,0 +1,203 @@
+// Cloudflare Stream API 类型定义
+
+// 视频状态
+export type VideoState = 'pendingupload' | 'downloading' | 'queued' | 'inprogress' | 'ready' | 'error'
+
+// 视频信息
+export interface CloudflareVideo {
+  uid: string
+  creator?: string
+  thumbnail: string
+  thumbnailTimestampPct?: number
+  readyToStream: boolean
+  readyToStreamAt?: string
+  status: {
+    state: VideoState
+    pctComplete?: string
+    errorReasonCode?: string
+    errorReasonText?: string
+  }
+  meta: Record<string, any>
+  created: string
+  modified: string
+  scheduledDeletion?: string
+  size: number
+  preview: string
+  allowedOrigins: string[]
+  requireSignedURLs: boolean
+  uploaded: string
+  uploadExpiry?: string
+  maxSizeBytes?: number
+  maxDurationSeconds?: number
+  duration: number
+  input: {
+    width: number
+    height: number
+  }
+  playback: {
+    hls: string
+    dash: string
+  }
+  watermark?: {
+    uid: string
+    size: number
+    height: number
+    width: number
+    created: string
+    downloadedFrom: string
+    name: string
+    opacity: number
+    padding: number
+    scale: number
+    position: string
+  }
+  clippedFrom?: string
+  publicDetails?: {
+    title?: string
+    share_link?: string
+    channel_link?: string
+    logo?: string
+  }
+}
+
+// 直播输入(Live Input)
+export interface CloudflareLiveInput {
+  uid: string
+  created: string
+  modified: string
+  meta: Record<string, any>
+  deleteRecordingAfterDays?: number
+  status?: {
+    current: {
+      state: 'connected' | 'disconnected'
+      statusEnteredAt?: string
+      statusLastChangedAt?: string
+    }
+  }
+  rtmps: {
+    url: string
+    streamKey: string
+  }
+  rtmpsPlayback?: {
+    url: string
+    streamKey: string
+  }
+  srt?: {
+    url: string
+    streamId: string
+    passphrase: string
+  }
+  srtPlayback?: {
+    url: string
+    streamId: string
+    passphrase: string
+  }
+  webRTC?: {
+    url: string
+  }
+  webRTCPlayback?: {
+    url: string
+  }
+  recording?: {
+    mode: 'off' | 'automatic'
+    requireSignedURLs?: boolean
+    allowedOrigins?: string[]
+    timeoutSeconds?: number
+  }
+}
+
+// 直播输出
+export interface CloudflareLiveOutput {
+  uid: string
+  url: string
+  streamKey: string
+  enabled: boolean
+}
+
+// 上传 URL 响应
+export interface UploadUrlResponse {
+  uploadURL: string
+  uid: string
+  watermark?: any
+}
+
+// 直接上传响应
+export interface DirectUploadResponse {
+  result: {
+    uploadURL: string
+    uid: string
+  }
+}
+
+// API 响应包装
+export interface CloudflareApiResponse<T> {
+  result: T
+  success: boolean
+  errors: Array<{ code: number; message: string }>
+  messages: Array<{ code: number; message: string }>
+}
+
+// 列表响应
+export interface CloudflareListResponse<T> {
+  result: T[]
+  success: boolean
+  errors: Array<{ code: number; message: string }>
+  messages: Array<{ code: number; message: string }>
+  result_info?: {
+    page: number
+    per_page: number
+    count: number
+    total_count: number
+  }
+}
+
+// 签名 URL 参数
+export interface SignedUrlParams {
+  id: string                    // 视频 ID
+  exp?: number                  // 过期时间戳
+  nbf?: number                  // 生效时间戳
+  downloadable?: boolean        // 是否可下载
+  accessRules?: Array<{
+    type: 'ip.geoip.country' | 'ip.src' | 'any'
+    country?: string[]
+    ip?: string[]
+    action: 'allow' | 'block'
+  }>
+}
+
+// 视频播放信息
+export interface VideoPlaybackInfo {
+  uid: string
+  preview: string
+  thumbnail: string
+  playback: {
+    hls: string
+    dash: string
+  }
+  iframe: string
+}
+
+// 创建直播输入参数
+export interface CreateLiveInputParams {
+  meta?: Record<string, any>
+  recording?: {
+    mode: 'off' | 'automatic'
+    requireSignedURLs?: boolean
+    allowedOrigins?: string[]
+    timeoutSeconds?: number
+  }
+  deleteRecordingAfterDays?: number
+}
+
+// 创建上传 URL 参数
+export interface CreateUploadUrlParams {
+  maxDurationSeconds?: number
+  expiry?: string
+  creator?: string
+  thumbnailTimestampPct?: number
+  allowedOrigins?: string[]
+  requireSignedURLs?: boolean
+  watermark?: { uid: string }
+  meta?: Record<string, any>
+  scheduledDeletion?: string
+}

+ 338 - 0
src/views/camera/stream-test.vue

@@ -0,0 +1,338 @@
+<template>
+  <div class="page-container">
+    <div class="page-header">
+      <el-button :icon="ArrowLeft" @click="goBack">返回</el-button>
+      <span class="title">Cloudflare Stream 测试</span>
+    </div>
+
+    <!-- 配置区域 -->
+    <div class="config-section">
+      <el-form :model="config" label-width="140px" inline>
+        <el-form-item label="播放器类型">
+          <el-select v-model="config.playerType" style="width: 150px">
+            <el-option label="Cloudflare iframe" value="cloudflare-iframe" />
+            <el-option label="HLS.js" value="hls" />
+            <el-option label="原生 Video" value="native" />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="Video ID">
+          <el-input
+            v-model="config.videoId"
+            placeholder="Cloudflare Stream Video ID"
+            style="width: 300px"
+          />
+        </el-form-item>
+
+        <el-form-item label="自定义域名">
+          <el-input
+            v-model="config.customerDomain"
+            placeholder="customer-xxx.cloudflarestream.com"
+            style="width: 300px"
+          />
+        </el-form-item>
+
+        <el-form-item>
+          <el-button type="primary" @click="loadVideo">加载视频</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-divider />
+
+      <el-form label-width="140px" inline>
+        <el-form-item label="或输入 HLS 地址">
+          <el-input
+            v-model="config.hlsUrl"
+            placeholder="https://xxx/manifest/video.m3u8"
+            style="width: 500px"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="success" @click="loadHlsUrl">播放 HLS</el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <!-- 播放器区域 -->
+    <div class="player-section">
+      <VideoPlayer
+        ref="playerRef"
+        :player-type="currentPlayerType"
+        :video-id="currentVideoId"
+        :customer-domain="config.customerDomain"
+        :src="currentSrc"
+        :use-iframe="config.playerType === 'cloudflare-iframe'"
+        :autoplay="config.autoplay"
+        :muted="config.muted"
+        :controls="true"
+        @play="onPlay"
+        @pause="onPause"
+        @error="onError"
+      />
+    </div>
+
+    <!-- 控制按钮 -->
+    <div class="control-section">
+      <el-space wrap>
+        <el-button type="primary" @click="handlePlay">播放</el-button>
+        <el-button @click="handlePause">暂停</el-button>
+        <el-button type="danger" @click="handleStop">停止</el-button>
+        <el-button @click="handleScreenshot">截图</el-button>
+        <el-button @click="handleFullscreen">全屏</el-button>
+
+        <el-divider direction="vertical" />
+
+        <el-switch v-model="config.muted" active-text="静音" inactive-text="有声" />
+        <el-switch v-model="config.autoplay" active-text="自动播放" inactive-text="手动播放" />
+      </el-space>
+    </div>
+
+    <!-- 使用说明 -->
+    <div class="info-section">
+      <el-alert title="Cloudflare Stream 接入说明" type="info" :closable="false">
+        <template #default>
+          <ol>
+            <li>在 Cloudflare Dashboard 中上传视频或创建直播</li>
+            <li>获取 Video ID(格式如:<code>ea95132c15732412d22c1476fa83f27a</code>)</li>
+            <li>获取自定义域名(格式如:<code>customer-xxx.cloudflarestream.com</code>)</li>
+            <li>填入上方表单并点击加载视频</li>
+          </ol>
+          <p style="margin-top: 10px;">
+            <strong>HLS 地址格式:</strong>
+            <code>https://customer-xxx.cloudflarestream.com/{video_id}/manifest/video.m3u8</code>
+          </p>
+          <p>
+            <strong>iframe 地址格式:</strong>
+            <code>https://customer-xxx.cloudflarestream.com/{video_id}/iframe</code>
+          </p>
+        </template>
+      </el-alert>
+    </div>
+
+    <!-- 日志区域 -->
+    <div class="log-section">
+      <h4>事件日志</h4>
+      <div class="log-content">
+        <div v-for="(log, index) in logs" :key="index" class="log-item" :class="log.type">
+          <span class="time">{{ log.time }}</span>
+          <span class="message">{{ log.message }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { ArrowLeft } from '@element-plus/icons-vue'
+import VideoPlayer from '@/components/VideoPlayer.vue'
+
+const router = useRouter()
+const playerRef = ref<InstanceType<typeof VideoPlayer>>()
+
+const config = reactive({
+  playerType: 'cloudflare-iframe' as 'cloudflare-iframe' | 'hls' | 'native',
+  videoId: '',
+  customerDomain: '',
+  hlsUrl: '',
+  autoplay: false,
+  muted: true
+})
+
+const currentVideoId = ref('')
+const currentSrc = ref('')
+
+const currentPlayerType = computed(() => {
+  if (config.playerType === 'cloudflare-iframe') return 'cloudflare'
+  return config.playerType
+})
+
+interface LogItem {
+  time: string
+  type: 'info' | 'success' | 'error'
+  message: string
+}
+
+const logs = ref<LogItem[]>([])
+
+function addLog(message: string, type: LogItem['type'] = 'info') {
+  const time = new Date().toLocaleTimeString()
+  logs.value.unshift({ time, type, message })
+  if (logs.value.length > 50) {
+    logs.value.pop()
+  }
+}
+
+function loadVideo() {
+  if (!config.videoId) {
+    addLog('请输入 Video ID', 'error')
+    return
+  }
+
+  currentVideoId.value = config.videoId
+
+  if (config.playerType === 'hls') {
+    const domain = config.customerDomain || 'customer-xxx.cloudflarestream.com'
+    currentSrc.value = `https://${domain}/${config.videoId}/manifest/video.m3u8`
+  } else {
+    currentSrc.value = ''
+  }
+
+  addLog(`加载视频: ${config.videoId}`, 'success')
+}
+
+function loadHlsUrl() {
+  if (!config.hlsUrl) {
+    addLog('请输入 HLS 地址', 'error')
+    return
+  }
+
+  config.playerType = 'hls'
+  currentSrc.value = config.hlsUrl
+  currentVideoId.value = ''
+
+  addLog(`加载 HLS: ${config.hlsUrl}`, 'success')
+}
+
+function handlePlay() {
+  playerRef.value?.play()
+  addLog('播放', 'info')
+}
+
+function handlePause() {
+  playerRef.value?.pause()
+  addLog('暂停', 'info')
+}
+
+function handleStop() {
+  playerRef.value?.stop()
+  addLog('停止', 'info')
+}
+
+function handleScreenshot() {
+  playerRef.value?.screenshot()
+  addLog('截图', 'info')
+}
+
+function handleFullscreen() {
+  playerRef.value?.fullscreen()
+  addLog('全屏', 'info')
+}
+
+function onPlay() {
+  addLog('视频开始播放', 'success')
+}
+
+function onPause() {
+  addLog('视频已暂停', 'info')
+}
+
+function onError(error: any) {
+  addLog(`播放错误: ${JSON.stringify(error)}`, 'error')
+}
+
+function goBack() {
+  router.push('/camera')
+}
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  padding: 20px;
+}
+
+.page-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+  padding: 15px 20px;
+  background-color: #fff;
+  border-radius: 4px;
+
+  .title {
+    margin-left: 15px;
+    font-size: 16px;
+    font-weight: 600;
+  }
+}
+
+.config-section {
+  padding: 20px;
+  background-color: #fff;
+  border-radius: 4px;
+  margin-bottom: 20px;
+}
+
+.player-section {
+  height: 480px;
+  margin-bottom: 20px;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.control-section {
+  padding: 15px 20px;
+  background-color: #fff;
+  border-radius: 4px;
+  margin-bottom: 20px;
+}
+
+.info-section {
+  margin-bottom: 20px;
+
+  code {
+    background-color: #f5f5f5;
+    padding: 2px 6px;
+    border-radius: 4px;
+    font-size: 12px;
+  }
+
+  ol {
+    padding-left: 20px;
+    margin: 10px 0;
+  }
+}
+
+.log-section {
+  padding: 15px 20px;
+  background-color: #fff;
+  border-radius: 4px;
+
+  h4 {
+    margin-bottom: 10px;
+    font-size: 14px;
+  }
+
+  .log-content {
+    max-height: 200px;
+    overflow-y: auto;
+    background-color: #fafafa;
+    border-radius: 4px;
+    padding: 10px;
+  }
+
+  .log-item {
+    font-size: 12px;
+    padding: 4px 0;
+    border-bottom: 1px solid #eee;
+
+    &:last-child {
+      border-bottom: none;
+    }
+
+    .time {
+      color: #999;
+      margin-right: 10px;
+    }
+
+    &.success .message {
+      color: #67c23a;
+    }
+
+    &.error .message {
+      color: #f56c6c;
+    }
+  }
+}
+</style>