Jelajahi Sumber

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 bulan lalu
induk
melakukan
b0c0745381

+ 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>