Преглед изворни кода

Enhance Cloudflare Stream integration with dynamic loading of Jessibuca player, improved login UI, and added video management features including video list, live stream management, and configuration settings.

yb пре 1 месец
родитељ
комит
b0c0745381
7 измењених фајлова са 1444 додато и 68 уклоњено
  1. 12 2
      index.html
  2. 24 5
      src/layout/index.vue
  3. 18 0
      src/router/index.ts
  4. 138 61
      src/views/login/index.vue
  5. 316 0
      src/views/stream/config.vue
  6. 412 0
      src/views/stream/live-list.vue
  7. 524 0
      src/views/stream/video-list.vue

+ 12 - 2
index.html

@@ -5,8 +5,18 @@
     <link rel="icon" type="image/svg+xml" href="/vite.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>摄像头管理系统</title>
-    <!-- Jessibuca 播放器 -->
-    <script src="/js/jessibuca/jessibuca.js"></script>
+    <!-- Jessibuca 播放器(用于 FLV/GB28181 流,可选) -->
+    <script>
+      // 动态加载 Jessibuca,避免文件不存在时报错
+      (function() {
+        var script = document.createElement('script');
+        script.src = '/js/jessibuca/jessibuca.js';
+        script.onerror = function() {
+          console.warn('Jessibuca player not found. FLV playback will be unavailable.');
+        };
+        document.head.appendChild(script);
+      })();
+    </script>
   </head>
   <body>
     <div id="app"></div>

+ 24 - 5
src/layout/index.vue

@@ -18,10 +18,29 @@
           <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-sub-menu index="/stream">
+          <template #title>
+            <el-icon><Film /></el-icon>
+            <span>Cloudflare Stream</span>
+          </template>
+          <el-menu-item index="/stream/videos">
+            <el-icon><Film /></el-icon>
+            <template #title>视频管理</template>
+          </el-menu-item>
+          <el-menu-item index="/stream/live">
+            <el-icon><VideoCameraFilled /></el-icon>
+            <template #title>直播管理</template>
+          </el-menu-item>
+          <el-menu-item index="/stream/config">
+            <el-icon><Setting /></el-icon>
+            <template #title>Stream 配置</template>
+          </el-menu-item>
+          <el-menu-item index="/stream-test">
+            <el-icon><Monitor /></el-icon>
+            <template #title>快速测试</template>
+          </el-menu-item>
+        </el-sub-menu>
       </el-menu>
     </el-aside>
 
@@ -68,7 +87,7 @@
 <script setup lang="ts">
 import { computed, onMounted } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
-import { VideoCamera, Fold, Expand, ArrowDown, Monitor } from '@element-plus/icons-vue'
+import { VideoCamera, Fold, Expand, ArrowDown, Monitor, Film, VideoCameraFilled, Setting } from '@element-plus/icons-vue'
 import { useAppStore } from '@/store/app'
 import { useUserStore } from '@/store/user'
 

+ 18 - 0
src/router/index.ts

@@ -38,6 +38,24 @@ const routes: RouteRecordRaw[] = [
         name: 'StreamTest',
         component: () => import('@/views/camera/stream-test.vue'),
         meta: { title: 'Stream 测试', icon: 'Monitor' }
+      },
+      {
+        path: 'stream/videos',
+        name: 'StreamVideos',
+        component: () => import('@/views/stream/video-list.vue'),
+        meta: { title: '视频管理', icon: 'Film' }
+      },
+      {
+        path: 'stream/live',
+        name: 'StreamLive',
+        component: () => import('@/views/stream/live-list.vue'),
+        meta: { title: '直播管理', icon: 'VideoCameraFilled' }
+      },
+      {
+        path: 'stream/config',
+        name: 'StreamConfig',
+        component: () => import('@/views/stream/config.vue'),
+        meta: { title: 'Stream 配置', icon: 'Setting' }
       }
     ]
   },

+ 138 - 61
src/views/login/index.vue

@@ -1,14 +1,22 @@
 <template>
-  <div class="login-container">
-    <div class="login-box">
-      <h2 class="title">摄像头管理系统</h2>
-      <el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="login-form">
+  <div class="login">
+    <div class="login__card">
+      <div class="login__brand">
+        <img class="login__logo" src="@/assets/logo.svg" alt="logo" />
+        <div class="login__titles">
+          <h1 class="login__title">摄像头管理系统</h1>
+          <p class="login__subtitle">Sign in to your console</p>
+        </div>
+      </div>
+
+      <el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="login__form" label-position="top">
         <el-form-item prop="username">
           <el-input
             v-model="loginForm.username"
             placeholder="请输入用户名"
             size="large"
             :prefix-icon="User"
+            autocomplete="username"
           />
         </el-form-item>
         <el-form-item prop="password">
@@ -20,10 +28,11 @@
             :prefix-icon="Lock"
             show-password
             @keyup.enter="handleLogin"
+            autocomplete="current-password"
           />
         </el-form-item>
         <el-form-item v-if="captchaEnabled" prop="code">
-          <div class="captcha-wrapper">
+          <div class="login__captcha">
             <el-input
               v-model="loginForm.code"
               placeholder="请输入验证码"
@@ -31,27 +40,31 @@
               :prefix-icon="Key"
               @keyup.enter="handleLogin"
             />
-            <img :src="codeUrl" class="captcha-img" @click="getCode" />
+            <img :src="codeUrl" class="login__captcha-img" @click="getCode" alt="captcha" />
           </div>
         </el-form-item>
-        <el-form-item>
-          <el-button
-            type="primary"
-            size="large"
-            :loading="loading"
-            class="login-btn"
-            @click="handleLogin"
-          >
-            登 录
-          </el-button>
-        </el-form-item>
+        <div class="login__actions">
+          <el-checkbox v-model="rememberMe">记住我</el-checkbox>
+          <span class="login__forgot" @click="goHelp">忘记密码?</span>
+        </div>
+
+        <el-button
+          type="primary"
+          size="large"
+          :loading="loading"
+          class="login__submit"
+          @click="handleLogin"
+        >
+          登 录
+        </el-button>
       </el-form>
     </div>
+    <div class="login__bg" aria-hidden="true"></div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue'
+import { ref, reactive, onMounted, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
 import { User, Lock, Key } from '@element-plus/icons-vue'
@@ -66,6 +79,7 @@ const loginFormRef = ref<FormInstance>()
 const loading = ref(false)
 const captchaEnabled = ref(false)
 const codeUrl = ref('')
+const rememberMe = ref(true)
 
 const loginForm = reactive({
   username: '',
@@ -126,61 +140,124 @@ async function handleLogin() {
 
 onMounted(() => {
   getCode()
+  // 基础的“记住我”体验:仅记住用户名
+  const savedUser = localStorage.getItem('login_username')
+  if (savedUser) loginForm.username = savedUser
 })
+
+watch(
+  () => loginForm.username,
+  (val) => {
+    if (rememberMe.value) {
+      localStorage.setItem('login_username', val || '')
+    }
+  }
+)
+
+function goHelp() {
+  ElMessage.info('请联系管理员重置密码')
+}
 </script>
 
 <style lang="scss" scoped>
-.login-container {
-  display: flex;
-  align-items: center;
-  justify-content: center;
+.login {
+  position: relative;
   min-height: 100vh;
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  display: grid;
+  grid-template-columns: 1fr;
+  place-items: center;
+  background: radial-gradient(1200px 600px at 100% 0%, rgba(102, 126, 234, 0.25), transparent 60%),
+    radial-gradient(1000px 500px at 0% 100%, rgba(118, 75, 162, 0.25), transparent 60%),
+    linear-gradient(135deg, #0f172a 0%, #111827 60%, #0b1220 100%);
+  padding: 24px;
+  overflow: hidden;
 }
 
-.login-box {
-  width: 400px;
-  padding: 40px;
-  background-color: #fff;
-  border-radius: 8px;
-  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
-
-  .title {
-    text-align: center;
-    font-size: 24px;
-    font-weight: 600;
-    color: #303133;
-    margin-bottom: 30px;
-  }
+.login__bg {
+  position: absolute;
+  inset: -10% -10% -10% -10%;
+  background: radial-gradient(800px 400px at 50% -10%, rgba(59, 130, 246, 0.15), transparent 60%),
+    radial-gradient(700px 350px at 100% 100%, rgba(236, 72, 153, 0.12), transparent 60%);
+  filter: blur(40px);
+  pointer-events: none;
 }
 
-.login-form {
-  .el-input {
-    height: 44px;
-  }
+.login__card {
+  width: 100%;
+  max-width: 420px;
+  padding: 36px 32px 28px;
+  border-radius: 16px;
+  background: rgba(255, 255, 255, 0.06);
+  backdrop-filter: saturate(180%) blur(14px);
+  border: 1px solid rgba(255, 255, 255, 0.12);
+  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
+  color: #e5e7eb;
+}
 
-  .captcha-wrapper {
-    display: flex;
-    width: 100%;
+.login__brand {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  margin-bottom: 18px;
+}
+.login__logo {
+  width: 36px;
+  height: 36px;
+}
+.login__titles { display: grid; gap: 2px; }
+.login__title {
+  margin: 0;
+  font-size: 22px;
+  font-weight: 700;
+  color: #f9fafb;
+}
+.login__subtitle {
+  margin: 0;
+  font-size: 13px;
+  color: #9ca3af;
+}
 
-    .el-input {
-      flex: 1;
-    }
+.login__form {
+  margin-top: 8px;
+  .el-input__wrapper { background: rgba(255,255,255,0.92); }
+  .el-input { height: 44px; }
+}
 
-    .captcha-img {
-      width: 100px;
-      height: 44px;
-      margin-left: 10px;
-      cursor: pointer;
-      border: 1px solid #dcdfe6;
-      border-radius: 4px;
-    }
-  }
+.login__captcha {
+  display: flex;
+  width: 100%;
+  gap: 10px;
+  .el-input { flex: 1; }
+}
+.login__captcha-img {
+  width: 108px;
+  height: 44px;
+  border-radius: 8px;
+  border: 1px solid #e5e7eb;
+  background: #fff;
+  cursor: pointer;
+}
 
-  .login-btn {
-    width: 100%;
-    height: 44px;
-    font-size: 16px;
-  }
+.login__actions {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin: 2px 0 10px;
+  color: #cbd5e1;
+}
+.login__forgot {
+  font-size: 12px;
+  color: #93c5fd;
+  cursor: pointer;
+}
+
+.login__submit {
+  width: 100%;
+  height: 44px;
+  font-size: 15px;
+}
+
+@media (max-width: 480px) {
+  .login__card { padding: 28px 20px 22px; }
 }
 </style>

+ 316 - 0
src/views/stream/config.vue

@@ -0,0 +1,316 @@
+<template>
+  <div class="page-container">
+    <el-card header="Cloudflare Stream 配置">
+      <el-form :model="config" label-width="160px" style="max-width: 600px;">
+        <el-form-item label="Account ID" required>
+          <el-input v-model="config.accountId" placeholder="Cloudflare Account ID" />
+          <div class="form-tip">在 Cloudflare Dashboard 右侧可以找到</div>
+        </el-form-item>
+
+        <el-form-item label="Customer Subdomain" required>
+          <el-input v-model="config.customerSubdomain" placeholder="xxx(不含 customer- 前缀)">
+            <template #prepend>customer-</template>
+            <template #append>.cloudflarestream.com</template>
+          </el-input>
+          <div class="form-tip">播放域名的子域名部分</div>
+        </el-form-item>
+
+        <el-form-item label="API Token">
+          <el-input
+            v-model="config.apiToken"
+            type="password"
+            placeholder="Cloudflare API Token(可选)"
+            show-password
+          />
+          <div class="form-tip">
+            仅在前端直接调用 API 时需要(不推荐)<br />
+            推荐通过后端代理调用,避免暴露 Token
+          </div>
+        </el-form-item>
+
+        <el-form-item>
+          <el-button type="primary" @click="saveConfig">保存配置</el-button>
+          <el-button @click="testConnection">测试连接</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card header="配置说明" style="margin-top: 20px;">
+      <el-collapse>
+        <el-collapse-item title="如何获取 Account ID" name="1">
+          <ol>
+            <li>登录 <a href="https://dash.cloudflare.com" target="_blank">Cloudflare Dashboard</a></li>
+            <li>选择 Stream 产品</li>
+            <li>在页面右侧可以看到 Account ID</li>
+          </ol>
+        </el-collapse-item>
+
+        <el-collapse-item title="如何获取 Customer Subdomain" name="2">
+          <ol>
+            <li>进入 Stream 产品页面</li>
+            <li>上传或选择任意视频</li>
+            <li>查看播放地址,格式为 <code>https://customer-xxx.cloudflarestream.com/{video_id}/...</code></li>
+            <li><code>xxx</code> 部分就是 Customer Subdomain</li>
+          </ol>
+        </el-collapse-item>
+
+        <el-collapse-item title="如何创建 API Token" name="3">
+          <ol>
+            <li>访问 <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank">API Tokens 页面</a></li>
+            <li>点击 "Create Token"</li>
+            <li>选择 "Stream - Edit" 模板或自定义权限</li>
+            <li>创建后复制 Token</li>
+          </ol>
+          <el-alert type="warning" :closable="false" style="margin-top: 10px;">
+            <strong>安全提示:</strong>不要在前端代码中暴露 API Token。推荐在后端服务器中存储 Token,前端通过后端代理调用 API。
+          </el-alert>
+        </el-collapse-item>
+
+        <el-collapse-item title="后端 API 代理示例" name="4">
+          <pre class="code-block">// Node.js / Express 示例
+const express = require('express');
+const axios = require('axios');
+
+const CLOUDFLARE_ACCOUNT_ID = process.env.CF_ACCOUNT_ID;
+const CLOUDFLARE_API_TOKEN = process.env.CF_API_TOKEN;
+
+const cfApi = axios.create({
+  baseURL: `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/stream`,
+  headers: {
+    'Authorization': `Bearer ${CLOUDFLARE_API_TOKEN}`,
+    'Content-Type': 'application/json'
+  }
+});
+
+// 获取视频列表
+app.get('/api/stream/video/list', async (req, res) =&gt; {
+  const response = await cfApi.get('/');
+  res.json({
+    code: 200,
+    data: {
+      rows: response.data.result,
+      total: response.data.result.length
+    }
+  });
+});
+
+// 创建直播输入
+app.post('/api/stream/live', async (req, res) =&gt; {
+  const response = await cfApi.post('/live_inputs', req.body);
+  res.json({
+    code: 200,
+    data: response.data.result
+  });
+});</pre>
+        </el-collapse-item>
+      </el-collapse>
+    </el-card>
+
+    <el-card header="快速测试" style="margin-top: 20px;">
+      <el-form label-width="100px" style="max-width: 800px;">
+        <el-form-item label="Video ID">
+          <el-input v-model="testVideoId" placeholder="输入 Video ID 测试播放" style="width: 400px;" />
+          <el-button type="primary" style="margin-left: 10px;" @click="testPlay" :disabled="!testVideoId">
+            测试播放
+          </el-button>
+        </el-form-item>
+
+        <el-form-item label="生成的地址" v-if="testVideoId && config.customerSubdomain">
+          <div class="url-list">
+            <div class="url-item">
+              <span class="label">HLS:</span>
+              <code>{{ testHlsUrl }}</code>
+              <el-button link type="primary" @click="copyUrl(testHlsUrl)">复制</el-button>
+            </div>
+            <div class="url-item">
+              <span class="label">DASH:</span>
+              <code>{{ testDashUrl }}</code>
+              <el-button link type="primary" @click="copyUrl(testDashUrl)">复制</el-button>
+            </div>
+            <div class="url-item">
+              <span class="label">iframe:</span>
+              <code>{{ testIframeUrl }}</code>
+              <el-button link type="primary" @click="copyUrl(testIframeUrl)">复制</el-button>
+            </div>
+          </div>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 测试播放弹窗 -->
+    <el-dialog v-model="playDialogVisible" title="测试播放" width="900px" destroy-on-close>
+      <div class="player-container">
+        <VideoPlayer
+          v-if="playDialogVisible && testVideoId"
+          :player-type="'hls'"
+          :src="testHlsUrl"
+          :autoplay="true"
+          :controls="true"
+        />
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import VideoPlayer from '@/components/VideoPlayer.vue'
+import { useStreamStore } from '@/store/stream'
+
+const streamStore = useStreamStore()
+
+const config = reactive({
+  accountId: '',
+  customerSubdomain: '',
+  apiToken: ''
+})
+
+const testVideoId = ref('')
+const playDialogVisible = ref(false)
+
+const testHlsUrl = computed(() => {
+  if (!config.customerSubdomain || !testVideoId.value) return ''
+  return `https://customer-${config.customerSubdomain}.cloudflarestream.com/${testVideoId.value}/manifest/video.m3u8`
+})
+
+const testDashUrl = computed(() => {
+  if (!config.customerSubdomain || !testVideoId.value) return ''
+  return `https://customer-${config.customerSubdomain}.cloudflarestream.com/${testVideoId.value}/manifest/video.mpd`
+})
+
+const testIframeUrl = computed(() => {
+  if (!config.customerSubdomain || !testVideoId.value) return ''
+  return `https://customer-${config.customerSubdomain}.cloudflarestream.com/${testVideoId.value}/iframe`
+})
+
+function saveConfig() {
+  streamStore.initConfig({
+    accountId: config.accountId,
+    customerSubdomain: config.customerSubdomain,
+    apiToken: config.apiToken
+  })
+  ElMessage.success('配置已保存')
+}
+
+function testConnection() {
+  if (!config.customerSubdomain) {
+    ElMessage.warning('请先填写 Customer Subdomain')
+    return
+  }
+
+  // 简单测试:尝试访问域名
+  const testUrl = `https://customer-${config.customerSubdomain}.cloudflarestream.com`
+  window.open(testUrl, '_blank')
+  ElMessage.info('已打开测试页面,请在新窗口中确认域名是否正确')
+}
+
+function testPlay() {
+  if (!testVideoId.value) {
+    ElMessage.warning('请输入 Video ID')
+    return
+  }
+  if (!config.customerSubdomain) {
+    ElMessage.warning('请先配置 Customer Subdomain')
+    return
+  }
+  playDialogVisible.value = true
+}
+
+async function copyUrl(url: string) {
+  if (!url) return
+  try {
+    await navigator.clipboard.writeText(url)
+    ElMessage.success('已复制')
+  } catch {
+    ElMessage.error('复制失败')
+  }
+}
+
+onMounted(() => {
+  streamStore.loadConfig()
+  const savedConfig = streamStore.config
+  config.accountId = savedConfig.accountId || ''
+  config.customerSubdomain = savedConfig.customerSubdomain || ''
+  config.apiToken = savedConfig.apiToken || ''
+})
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  padding: 20px;
+}
+
+.form-tip {
+  margin-top: 5px;
+  font-size: 12px;
+  color: #909399;
+  line-height: 1.6;
+}
+
+.code-block {
+  background-color: #f5f7fa;
+  padding: 15px;
+  border-radius: 4px;
+  font-size: 12px;
+  line-height: 1.6;
+  overflow-x: auto;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+
+.url-list {
+  .url-item {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    margin-bottom: 8px;
+
+    .label {
+      width: 50px;
+      font-weight: 500;
+    }
+
+    code {
+      flex: 1;
+      background-color: #f5f7fa;
+      padding: 4px 8px;
+      border-radius: 4px;
+      font-size: 12px;
+      word-break: break-all;
+    }
+  }
+}
+
+.player-container {
+  width: 100%;
+  height: 480px;
+  background-color: #000;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+:deep(.el-collapse-item__header) {
+  font-weight: 500;
+}
+
+:deep(.el-collapse-item__content) {
+  padding-top: 10px;
+
+  ol {
+    padding-left: 20px;
+    line-height: 2;
+  }
+
+  a {
+    color: #409eff;
+  }
+
+  code {
+    background-color: #f5f7fa;
+    padding: 2px 6px;
+    border-radius: 4px;
+    font-size: 12px;
+  }
+}
+</style>

+ 412 - 0
src/views/stream/live-list.vue

@@ -0,0 +1,412 @@
+<template>
+  <div class="page-container">
+    <!-- 操作按钮 -->
+    <div class="table-actions">
+      <el-button type="primary" :icon="Plus" @click="showCreateDialog">创建直播</el-button>
+      <el-button :icon="Refresh" @click="getList">刷新列表</el-button>
+    </div>
+
+    <!-- 直播列表 -->
+    <el-table v-loading="loading" :data="liveInputList" border>
+      <el-table-column prop="uid" label="Live Input ID" min-width="300" show-overflow-tooltip>
+        <template #default="{ row }">
+          <div class="live-info">
+            <span class="live-id">{{ row.uid }}</span>
+            <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(row.uid)" />
+          </div>
+          <div v-if="row.meta?.name" class="live-name">{{ row.meta.name }}</div>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" width="100" align="center">
+        <template #default="{ row }">
+          <el-tag :type="row.status?.current?.state === 'connected' ? 'success' : 'info'">
+            {{ row.status?.current?.state === 'connected' ? '直播中' : '未连接' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="录制" width="80" align="center">
+        <template #default="{ row }">
+          <el-tag :type="row.recording?.mode === 'automatic' ? 'success' : 'info'" size="small">
+            {{ row.recording?.mode === 'automatic' ? '开启' : '关闭' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="created" label="创建时间" width="170" align="center">
+        <template #default="{ row }">
+          {{ formatTime(row.created) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="320" align="center" fixed="right">
+        <template #default="{ row }">
+          <el-button type="primary" link :icon="VideoPlay" @click="handleWatch(row)">
+            观看
+          </el-button>
+          <el-button type="success" link :icon="Connection" @click="showStreamInfo(row)">
+            推流信息
+          </el-button>
+          <el-button type="info" link :icon="List" @click="showRecordings(row)">
+            录像
+          </el-button>
+          <el-button type="danger" link :icon="Delete" @click="handleDelete(row)">
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 创建直播弹窗 -->
+    <el-dialog v-model="createDialogVisible" title="创建直播" width="500px">
+      <el-form :model="createForm" label-width="100px">
+        <el-form-item label="直播名称">
+          <el-input v-model="createForm.name" placeholder="请输入直播名称" />
+        </el-form-item>
+        <el-form-item label="自动录制">
+          <el-switch v-model="createForm.recording" />
+          <span class="form-tip">开启后自动保存直播录像</span>
+        </el-form-item>
+        <el-form-item label="录像保留" v-if="createForm.recording">
+          <el-input-number v-model="createForm.deleteAfterDays" :min="1" :max="365" />
+          <span class="form-tip">天后自动删除</span>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="createDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="creating" @click="handleCreate">创建</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 推流信息弹窗 -->
+    <el-dialog v-model="streamInfoVisible" title="推流信息" width="700px">
+      <el-tabs v-model="streamInfoTab" v-if="currentLiveInput">
+        <el-tab-pane label="RTMPS 推流" name="rtmps">
+          <el-descriptions :column="1" border>
+            <el-descriptions-item label="推流地址">
+              <div class="url-item">
+                <span class="url-text">{{ currentLiveInput.rtmps?.url }}</span>
+                <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentLiveInput.rtmps?.url)" />
+              </div>
+            </el-descriptions-item>
+            <el-descriptions-item label="推流密钥">
+              <div class="url-item">
+                <span class="url-text">{{ showStreamKey ? currentLiveInput.rtmps?.streamKey : '••••••••••••••••' }}</span>
+                <el-button link type="primary" :icon="View" @click="showStreamKey = !showStreamKey" />
+                <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentLiveInput.rtmps?.streamKey)" />
+              </div>
+            </el-descriptions-item>
+          </el-descriptions>
+          <el-alert type="info" :closable="false" style="margin-top: 15px">
+            <template #title>OBS 推流设置</template>
+            <p>服务器:{{ currentLiveInput.rtmps?.url }}</p>
+            <p>串流密钥:{{ currentLiveInput.rtmps?.streamKey }}</p>
+          </el-alert>
+        </el-tab-pane>
+
+        <el-tab-pane label="SRT 推流" name="srt" v-if="currentLiveInput.srt">
+          <el-descriptions :column="1" border>
+            <el-descriptions-item label="SRT 地址">
+              <div class="url-item">
+                <span class="url-text">{{ currentLiveInput.srt?.url }}</span>
+                <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentLiveInput.srt?.url)" />
+              </div>
+            </el-descriptions-item>
+            <el-descriptions-item label="Stream ID">
+              <div class="url-item">
+                <span class="url-text">{{ currentLiveInput.srt?.streamId }}</span>
+                <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentLiveInput.srt?.streamId)" />
+              </div>
+            </el-descriptions-item>
+            <el-descriptions-item label="Passphrase">
+              <div class="url-item">
+                <span class="url-text">{{ showStreamKey ? currentLiveInput.srt?.passphrase : '••••••••••••' }}</span>
+                <el-button link type="primary" :icon="View" @click="showStreamKey = !showStreamKey" />
+                <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentLiveInput.srt?.passphrase)" />
+              </div>
+            </el-descriptions-item>
+          </el-descriptions>
+        </el-tab-pane>
+
+        <el-tab-pane label="播放地址" name="playback">
+          <el-descriptions :column="1" border>
+            <el-descriptions-item label="HLS 地址">
+              <div class="url-item">
+                <span class="url-text">{{ getHlsUrl(currentLiveInput.uid) }}</span>
+                <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(getHlsUrl(currentLiveInput.uid))" />
+              </div>
+            </el-descriptions-item>
+            <el-descriptions-item label="iframe 嵌入">
+              <div class="url-item">
+                <span class="url-text">{{ getIframeUrl(currentLiveInput.uid) }}</span>
+                <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(getIframeUrl(currentLiveInput.uid))" />
+              </div>
+            </el-descriptions-item>
+          </el-descriptions>
+          <el-alert type="warning" :closable="false" style="margin-top: 15px">
+            播放地址仅在直播中有效
+          </el-alert>
+        </el-tab-pane>
+      </el-tabs>
+    </el-dialog>
+
+    <!-- 录像列表弹窗 -->
+    <el-dialog v-model="recordingsVisible" title="直播录像" width="800px">
+      <el-table v-loading="recordingsLoading" :data="recordings" border max-height="400">
+        <el-table-column prop="uid" label="Video ID" min-width="280" show-overflow-tooltip />
+        <el-table-column prop="duration" label="时长" width="100" align="center">
+          <template #default="{ row }">
+            {{ formatDuration(row.duration) }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="created" label="创建时间" width="170" align="center">
+          <template #default="{ row }">
+            {{ formatTime(row.created) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="120" align="center">
+          <template #default="{ row }">
+            <el-button type="primary" link :icon="VideoPlay" @click="playRecording(row)">
+              播放
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-dialog>
+
+    <!-- 观看直播弹窗 -->
+    <el-dialog v-model="watchDialogVisible" title="观看直播" width="900px" destroy-on-close>
+      <div class="player-container">
+        <VideoPlayer
+          v-if="watchDialogVisible && watchLiveId"
+          :player-type="'hls'"
+          :src="getHlsUrl(watchLiveId)"
+          :autoplay="true"
+          :controls="true"
+        />
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import {
+  Plus, Refresh, VideoPlay, Connection, List, Delete,
+  CopyDocument, View
+} from '@element-plus/icons-vue'
+import VideoPlayer from '@/components/VideoPlayer.vue'
+import { useStreamStore } from '@/store/stream'
+import type { CloudflareLiveInput, CloudflareVideo } from '@/types/cloudflare'
+
+const streamStore = useStreamStore()
+
+const loading = ref(false)
+const liveInputList = ref<CloudflareLiveInput[]>([])
+
+// 创建相关
+const createDialogVisible = ref(false)
+const createForm = reactive({
+  name: '',
+  recording: true,
+  deleteAfterDays: 30
+})
+const creating = ref(false)
+
+// 推流信息相关
+const streamInfoVisible = ref(false)
+const streamInfoTab = ref('rtmps')
+const currentLiveInput = ref<CloudflareLiveInput | null>(null)
+const showStreamKey = ref(false)
+
+// 录像相关
+const recordingsVisible = ref(false)
+const recordingsLoading = ref(false)
+const recordings = ref<CloudflareVideo[]>([])
+
+// 观看相关
+const watchDialogVisible = ref(false)
+const watchLiveId = ref('')
+
+function getHlsUrl(liveInputId: string): string {
+  return streamStore.getHlsUrl(liveInputId)
+}
+
+function getIframeUrl(liveInputId: string): string {
+  return streamStore.getIframeUrl(liveInputId)
+}
+
+function formatTime(time: string): string {
+  if (!time) return '-'
+  return new Date(time).toLocaleString('zh-CN')
+}
+
+function formatDuration(seconds: number): string {
+  if (!seconds) return '-'
+  const h = Math.floor(seconds / 3600)
+  const m = Math.floor((seconds % 3600) / 60)
+  const s = Math.floor(seconds % 60)
+  if (h > 0) {
+    return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
+  }
+  return `${m}:${s.toString().padStart(2, '0')}`
+}
+
+async function copyToClipboard(text?: string) {
+  if (!text) return
+  try {
+    await navigator.clipboard.writeText(text)
+    ElMessage.success('已复制到剪贴板')
+  } catch {
+    ElMessage.error('复制失败')
+  }
+}
+
+async function getList() {
+  loading.value = true
+  try {
+    // const res = await listLiveInputs()
+    // liveInputList.value = res.data.rows
+    liveInputList.value = []
+    ElMessage.info('请配置 Cloudflare Stream 并连接后端 API')
+  } finally {
+    loading.value = false
+  }
+}
+
+function showCreateDialog() {
+  createForm.name = ''
+  createForm.recording = true
+  createForm.deleteAfterDays = 30
+  createDialogVisible.value = true
+}
+
+async function handleCreate() {
+  creating.value = true
+  try {
+    // await createLiveInput({
+    //   meta: { name: createForm.name },
+    //   recording: {
+    //     mode: createForm.recording ? 'automatic' : 'off'
+    //   },
+    //   deleteRecordingAfterDays: createForm.deleteAfterDays
+    // })
+    ElMessage.success('创建成功')
+    createDialogVisible.value = false
+    getList()
+  } catch (error) {
+    console.error('创建失败:', error)
+    ElMessage.error('创建失败')
+  } finally {
+    creating.value = false
+  }
+}
+
+function showStreamInfo(row: CloudflareLiveInput) {
+  currentLiveInput.value = row
+  showStreamKey.value = false
+  streamInfoTab.value = 'rtmps'
+  streamInfoVisible.value = true
+}
+
+function handleWatch(row: CloudflareLiveInput) {
+  if (row.status?.current?.state !== 'connected') {
+    ElMessage.warning('直播未开始')
+    return
+  }
+  watchLiveId.value = row.uid
+  watchDialogVisible.value = true
+}
+
+async function showRecordings(row: CloudflareLiveInput) {
+  currentLiveInput.value = row
+  recordingsVisible.value = true
+  recordingsLoading.value = true
+  try {
+    // const res = await listLiveRecordings(row.uid)
+    // recordings.value = res.data.rows
+    recordings.value = []
+  } finally {
+    recordingsLoading.value = false
+  }
+}
+
+function playRecording(row: CloudflareVideo) {
+  watchLiveId.value = row.uid
+  recordingsVisible.value = false
+  watchDialogVisible.value = true
+}
+
+async function handleDelete(row: CloudflareLiveInput) {
+  try {
+    await ElMessageBox.confirm(`确定要删除直播 "${row.meta?.name || row.uid}" 吗?`, '提示', {
+      type: 'warning'
+    })
+    // await deleteLiveInput(row.uid)
+    ElMessage.success('删除成功')
+    getList()
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error('删除失败:', error)
+    }
+  }
+}
+
+onMounted(() => {
+  streamStore.loadConfig()
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  padding: 20px;
+}
+
+.table-actions {
+  margin-bottom: 15px;
+}
+
+.live-info {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+
+  .live-id {
+    font-family: monospace;
+    font-size: 12px;
+  }
+}
+
+.live-name {
+  margin-top: 4px;
+  font-size: 13px;
+  color: #606266;
+}
+
+.url-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+
+  .url-text {
+    flex: 1;
+    font-size: 12px;
+    word-break: break-all;
+    color: #606266;
+    font-family: monospace;
+  }
+}
+
+.form-tip {
+  margin-left: 10px;
+  font-size: 12px;
+  color: #909399;
+}
+
+.player-container {
+  width: 100%;
+  height: 480px;
+  background-color: #000;
+  border-radius: 4px;
+  overflow: hidden;
+}
+</style>

+ 524 - 0
src/views/stream/video-list.vue

@@ -0,0 +1,524 @@
+<template>
+  <div class="page-container">
+    <!-- 搜索区域 -->
+    <div class="search-form">
+      <el-form :model="queryParams" inline>
+        <el-form-item label="视频名称">
+          <el-input v-model="queryParams.search" placeholder="请输入视频名称" clearable @keyup.enter="handleQuery" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="queryParams.status" placeholder="请选择" clearable style="width: 120px">
+            <el-option label="就绪" value="ready" />
+            <el-option label="处理中" value="inprogress" />
+            <el-option label="排队中" value="queued" />
+            <el-option label="错误" value="error" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="Search" @click="handleQuery">搜索</el-button>
+          <el-button :icon="Refresh" @click="resetQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <!-- 操作按钮 -->
+    <div class="table-actions">
+      <el-button type="primary" :icon="Upload" @click="showUploadDialog">上传视频</el-button>
+      <el-button type="success" :icon="Link" @click="showImportDialog">从URL导入</el-button>
+      <el-button :icon="Refresh" @click="getList">刷新列表</el-button>
+    </div>
+
+    <!-- 视频列表 -->
+    <el-table v-loading="loading" :data="videoList" border>
+      <el-table-column label="缩略图" width="160" align="center">
+        <template #default="{ row }">
+          <el-image
+            :src="getThumbnailUrl(row.uid)"
+            :preview-src-list="[getThumbnailUrl(row.uid)]"
+            fit="cover"
+            style="width: 140px; height: 80px; border-radius: 4px;"
+            :preview-teleported="true"
+          >
+            <template #error>
+              <div class="image-error">
+                <el-icon><Picture /></el-icon>
+              </div>
+            </template>
+          </el-image>
+        </template>
+      </el-table-column>
+      <el-table-column prop="uid" label="Video ID" min-width="280" show-overflow-tooltip>
+        <template #default="{ row }">
+          <div class="video-info">
+            <span class="video-id">{{ row.uid }}</span>
+            <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(row.uid)" />
+          </div>
+          <div v-if="row.meta?.name" class="video-name">{{ row.meta.name }}</div>
+        </template>
+      </el-table-column>
+      <el-table-column prop="duration" label="时长" width="100" align="center">
+        <template #default="{ row }">
+          {{ formatDuration(row.duration) }}
+        </template>
+      </el-table-column>
+      <el-table-column prop="size" label="大小" width="100" align="center">
+        <template #default="{ row }">
+          {{ formatSize(row.size) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="分辨率" width="100" align="center">
+        <template #default="{ row }">
+          <span v-if="row.input">{{ row.input.width }}x{{ row.input.height }}</span>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="status" label="状态" width="100" align="center">
+        <template #default="{ row }">
+          <el-tag :type="getStatusType(row.status?.state)">
+            {{ getStatusText(row.status?.state) }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="created" label="创建时间" width="170" align="center">
+        <template #default="{ row }">
+          {{ formatTime(row.created) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="200" align="center" fixed="right">
+        <template #default="{ row }">
+          <el-button type="primary" link :icon="VideoPlay" @click="handlePlay(row)" :disabled="!row.readyToStream">
+            播放
+          </el-button>
+          <el-button type="info" link :icon="View" @click="handleDetail(row)">
+            详情
+          </el-button>
+          <el-button type="danger" link :icon="Delete" @click="handleDelete(row)">
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 分页 -->
+    <el-pagination
+      v-model:current-page="queryParams.pageNum"
+      v-model:page-size="queryParams.pageSize"
+      :page-sizes="[10, 20, 50, 100]"
+      :total="total"
+      layout="total, sizes, prev, pager, next, jumper"
+      class="pagination"
+      @size-change="getList"
+      @current-change="getList"
+    />
+
+    <!-- 上传弹窗 -->
+    <el-dialog v-model="uploadDialogVisible" title="上传视频" width="500px" destroy-on-close>
+      <el-upload
+        drag
+        :auto-upload="false"
+        :limit="1"
+        accept="video/*"
+        :on-change="handleFileChange"
+      >
+        <el-icon class="el-icon--upload"><UploadFilled /></el-icon>
+        <div class="el-upload__text">拖拽视频文件到此处,或 <em>点击上传</em></div>
+        <template #tip>
+          <div class="el-upload__tip">支持 MP4、MOV、MKV、AVI 等常见视频格式</div>
+        </template>
+      </el-upload>
+      <el-form v-if="uploadFile" :model="uploadForm" label-width="80px" style="margin-top: 20px;">
+        <el-form-item label="视频名称">
+          <el-input v-model="uploadForm.name" placeholder="请输入视频名称" />
+        </el-form-item>
+      </el-form>
+      <el-progress v-if="uploadProgress > 0" :percentage="uploadProgress" :status="uploadProgress === 100 ? 'success' : undefined" />
+      <template #footer>
+        <el-button @click="uploadDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="uploading" :disabled="!uploadFile" @click="handleUpload">
+          上传
+        </el-button>
+      </template>
+    </el-dialog>
+
+    <!-- URL导入弹窗 -->
+    <el-dialog v-model="importDialogVisible" title="从URL导入视频" width="500px">
+      <el-form :model="importForm" label-width="80px">
+        <el-form-item label="视频URL" required>
+          <el-input v-model="importForm.url" placeholder="请输入视频URL" />
+        </el-form-item>
+        <el-form-item label="视频名称">
+          <el-input v-model="importForm.name" placeholder="请输入视频名称(可选)" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="importDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="importing" @click="handleImport">导入</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 详情弹窗 -->
+    <el-dialog v-model="detailDialogVisible" title="视频详情" width="700px">
+      <el-descriptions :column="2" border v-if="currentVideo">
+        <el-descriptions-item label="Video ID" :span="2">
+          {{ currentVideo.uid }}
+          <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentVideo.uid)" />
+        </el-descriptions-item>
+        <el-descriptions-item label="状态">
+          <el-tag :type="getStatusType(currentVideo.status?.state)">
+            {{ getStatusText(currentVideo.status?.state) }}
+          </el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="时长">{{ formatDuration(currentVideo.duration) }}</el-descriptions-item>
+        <el-descriptions-item label="大小">{{ formatSize(currentVideo.size) }}</el-descriptions-item>
+        <el-descriptions-item label="分辨率">
+          {{ currentVideo.input?.width }}x{{ currentVideo.input?.height }}
+        </el-descriptions-item>
+        <el-descriptions-item label="创建时间" :span="2">{{ formatTime(currentVideo.created) }}</el-descriptions-item>
+        <el-descriptions-item label="HLS 地址" :span="2">
+          <div class="url-item">
+            <span class="url-text">{{ currentVideo.playback?.hls }}</span>
+            <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentVideo.playback?.hls)" />
+          </div>
+        </el-descriptions-item>
+        <el-descriptions-item label="DASH 地址" :span="2">
+          <div class="url-item">
+            <span class="url-text">{{ currentVideo.playback?.dash }}</span>
+            <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentVideo.playback?.dash)" />
+          </div>
+        </el-descriptions-item>
+        <el-descriptions-item label="预览地址" :span="2">
+          <div class="url-item">
+            <span class="url-text">{{ currentVideo.preview }}</span>
+            <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentVideo.preview)" />
+          </div>
+        </el-descriptions-item>
+      </el-descriptions>
+    </el-dialog>
+
+    <!-- 播放弹窗 -->
+    <el-dialog v-model="playDialogVisible" title="视频播放" width="900px" destroy-on-close>
+      <div class="player-container">
+        <VideoPlayer
+          v-if="playDialogVisible && playVideoId"
+          :player-type="'hls'"
+          :src="getHlsUrl(playVideoId)"
+          :autoplay="true"
+          :controls="true"
+        />
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
+import {
+  Search, Refresh, Upload, Link, VideoPlay, View, Delete,
+  CopyDocument, Picture, UploadFilled
+} from '@element-plus/icons-vue'
+import VideoPlayer from '@/components/VideoPlayer.vue'
+import { useStreamStore } from '@/store/stream'
+import type { CloudflareVideo } from '@/types/cloudflare'
+
+const streamStore = useStreamStore()
+
+const loading = ref(false)
+const videoList = ref<CloudflareVideo[]>([])
+const total = ref(0)
+
+const queryParams = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  search: '',
+  status: ''
+})
+
+// 上传相关
+const uploadDialogVisible = ref(false)
+const uploadFile = ref<File | null>(null)
+const uploadForm = reactive({ name: '' })
+const uploading = ref(false)
+const uploadProgress = ref(0)
+
+// 导入相关
+const importDialogVisible = ref(false)
+const importForm = reactive({ url: '', name: '' })
+const importing = ref(false)
+
+// 详情相关
+const detailDialogVisible = ref(false)
+const currentVideo = ref<CloudflareVideo | null>(null)
+
+// 播放相关
+const playDialogVisible = ref(false)
+const playVideoId = ref('')
+
+function getThumbnailUrl(videoId: string): string {
+  return streamStore.getThumbnailUrl(videoId, { width: 280, height: 160 })
+}
+
+function getHlsUrl(videoId: string): string {
+  return streamStore.getHlsUrl(videoId)
+}
+
+function formatDuration(seconds: number): string {
+  if (!seconds) return '-'
+  const h = Math.floor(seconds / 3600)
+  const m = Math.floor((seconds % 3600) / 60)
+  const s = Math.floor(seconds % 60)
+  if (h > 0) {
+    return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
+  }
+  return `${m}:${s.toString().padStart(2, '0')}`
+}
+
+function formatSize(bytes: number): string {
+  if (!bytes) return '-'
+  const units = ['B', 'KB', 'MB', 'GB']
+  let i = 0
+  while (bytes >= 1024 && i < units.length - 1) {
+    bytes /= 1024
+    i++
+  }
+  return `${bytes.toFixed(1)} ${units[i]}`
+}
+
+function formatTime(time: string): string {
+  if (!time) return '-'
+  return new Date(time).toLocaleString('zh-CN')
+}
+
+function getStatusType(state: string): string {
+  const map: Record<string, string> = {
+    ready: 'success',
+    inprogress: 'warning',
+    queued: 'info',
+    error: 'danger'
+  }
+  return map[state] || 'info'
+}
+
+function getStatusText(state: string): string {
+  const map: Record<string, string> = {
+    ready: '就绪',
+    inprogress: '处理中',
+    queued: '排队中',
+    downloading: '下载中',
+    pendingupload: '待上传',
+    error: '错误'
+  }
+  return map[state] || state || '-'
+}
+
+async function copyToClipboard(text: string) {
+  if (!text) return
+  try {
+    await navigator.clipboard.writeText(text)
+    ElMessage.success('已复制到剪贴板')
+  } catch {
+    ElMessage.error('复制失败')
+  }
+}
+
+async function getList() {
+  loading.value = true
+  try {
+    // 模拟数据,实际项目中调用 API
+    // const res = await listVideos(queryParams)
+    // videoList.value = res.data.rows
+    // total.value = res.data.total
+
+    // 示例数据
+    videoList.value = []
+    total.value = 0
+    ElMessage.info('请配置 Cloudflare Stream 并连接后端 API')
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleQuery() {
+  queryParams.pageNum = 1
+  getList()
+}
+
+function resetQuery() {
+  queryParams.pageNum = 1
+  queryParams.search = ''
+  queryParams.status = ''
+  getList()
+}
+
+function showUploadDialog() {
+  uploadFile.value = null
+  uploadForm.name = ''
+  uploadProgress.value = 0
+  uploadDialogVisible.value = true
+}
+
+function showImportDialog() {
+  importForm.url = ''
+  importForm.name = ''
+  importDialogVisible.value = true
+}
+
+function handleFileChange(file: UploadFile) {
+  uploadFile.value = file.raw || null
+  if (uploadFile.value) {
+    uploadForm.name = file.name.replace(/\.[^/.]+$/, '')
+  }
+}
+
+async function handleUpload() {
+  if (!uploadFile.value) return
+
+  uploading.value = true
+  uploadProgress.value = 0
+
+  try {
+    // 1. 获取上传 URL
+    // const urlRes = await getUploadUrl({ meta: { name: uploadForm.name } })
+    // const { uploadURL, uid } = urlRes.data
+
+    // 2. 使用 TUS 协议上传
+    // await streamApi.uploadWithTus(uploadFile.value, uploadURL, (progress) => {
+    //   uploadProgress.value = progress
+    // })
+
+    ElMessage.success('上传成功')
+    uploadDialogVisible.value = false
+    getList()
+  } catch (error) {
+    console.error('上传失败:', error)
+    ElMessage.error('上传失败')
+  } finally {
+    uploading.value = false
+  }
+}
+
+async function handleImport() {
+  if (!importForm.url) {
+    ElMessage.warning('请输入视频URL')
+    return
+  }
+
+  importing.value = true
+  try {
+    // await importVideoFromUrl({
+    //   url: importForm.url,
+    //   name: importForm.name,
+    //   meta: { name: importForm.name }
+    // })
+    ElMessage.success('导入成功,视频正在处理中')
+    importDialogVisible.value = false
+    getList()
+  } catch (error) {
+    console.error('导入失败:', error)
+    ElMessage.error('导入失败')
+  } finally {
+    importing.value = false
+  }
+}
+
+function handlePlay(row: CloudflareVideo) {
+  playVideoId.value = row.uid
+  playDialogVisible.value = true
+}
+
+function handleDetail(row: CloudflareVideo) {
+  currentVideo.value = row
+  detailDialogVisible.value = true
+}
+
+async function handleDelete(row: CloudflareVideo) {
+  try {
+    await ElMessageBox.confirm(`确定要删除视频 "${row.meta?.name || row.uid}" 吗?`, '提示', {
+      type: 'warning'
+    })
+    // await deleteVideo(row.uid)
+    ElMessage.success('删除成功')
+    getList()
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error('删除失败:', error)
+    }
+  }
+}
+
+onMounted(() => {
+  streamStore.loadConfig()
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  padding: 20px;
+}
+
+.search-form {
+  margin-bottom: 20px;
+  padding: 20px;
+  background-color: #fff;
+  border-radius: 4px;
+}
+
+.table-actions {
+  margin-bottom: 15px;
+}
+
+.pagination {
+  margin-top: 20px;
+  justify-content: flex-end;
+}
+
+.video-info {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+
+  .video-id {
+    font-family: monospace;
+    font-size: 12px;
+  }
+}
+
+.video-name {
+  margin-top: 4px;
+  font-size: 13px;
+  color: #606266;
+}
+
+.image-error {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 140px;
+  height: 80px;
+  background-color: #f5f7fa;
+  color: #c0c4cc;
+  font-size: 24px;
+}
+
+.url-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+
+  .url-text {
+    flex: 1;
+    font-size: 12px;
+    word-break: break-all;
+    color: #606266;
+  }
+}
+
+.player-container {
+  width: 100%;
+  height: 480px;
+  background-color: #000;
+  border-radius: 4px;
+  overflow: hidden;
+}
+</style>