yb 3 dní pred
rodič
commit
625cf1bc93

+ 1 - 0
src/locales/en.json

@@ -138,6 +138,7 @@
   "推流任务已启动": "Task started",
   "推流控制": "Stream Control",
   "推流方式": "Method",
+  "开始推流": "Start Stream",
   "推荐通过后端代理调用,避免暴露 Token": "Recommended to call through the backend proxy to avoid exposing the Token",
   "描述": "Description",
   "提示": "Notice",

+ 1 - 0
src/locales/zh-cn.json

@@ -138,6 +138,7 @@
   "推流任务已启动": "推流任务已启动",
   "推流控制": "推流控制",
   "推流方式": "推流方式",
+  "开始推流": "开始推流",
   "推荐通过后端代理调用,避免暴露 Token": "推荐通过后端代理调用,避免暴露 Token",
   "描述": "描述",
   "提示": "提示",

+ 1 - 0
src/types/index.ts

@@ -711,6 +711,7 @@ export interface StartStreamTaskRequest {
   whipUrl?: string
   playbackUrl?: string
   remark?: string
+  commandTemplate?: string
 }
 
 // 停止推流任务请求

+ 401 - 248
src/views/live-stream/index.vue

@@ -61,6 +61,7 @@
             <el-tag size="small">{{ row.pushMethod || 'ffmpeg' }}</el-tag>
           </template>
         </el-table-column>
+
         <el-table-column prop="commandTemplate" :label="t('命令模板')" align="center">
           <template #default="{ row }">
             <el-link type="primary" @click="openCommandDialog(row)">{{ t('查看') }}</el-link>
@@ -117,68 +118,285 @@
       />
     </div>
 
-    <!-- 新增/编辑抽屉 -->
+    <!-- 合并的编辑/播放抽屉 -->
     <el-drawer
       v-model="drawerVisible"
       direction="rtl"
-      size="550px"
+      :size="activeDrawerTab === 'edit' ? '550px' : '90%'"
       :with-header="false"
       destroy-on-close
-      class="stream-drawer"
+      class="combined-drawer"
     >
       <div class="drawer-content">
-        <div class="drawer-header">{{ drawerTitle }}</div>
-        <div class="drawer-body">
-          <el-form ref="formRef" :model="form" :rules="rules" label-width="auto" class="stream-form">
-            <el-form-item label="名称:" prop="name">
-              <el-input v-model="form.name" placeholder="例如: 测试推流-001" style="width: 300px" />
-            </el-form-item>
-            <el-form-item label="LSS 节点:" prop="lssId">
-              <el-select v-model="form.lssId" placeholder="请选择 LSS 节点" clearable filterable style="width: 300px">
-                <el-option
-                  v-for="lss in lssOptions"
-                  :key="lss.lssId"
-                  :label="`${lss.lssId} - ${lss.lssName}`"
-                  :value="lss.lssId"
-                />
-              </el-select>
-            </el-form-item>
-            <el-form-item label="摄像头:" prop="cameraId">
-              <el-select v-model="form.cameraId" placeholder="请选择摄像头" clearable filterable style="width: 300px">
-                <el-option
-                  v-for="camera in cameraOptions"
-                  :key="camera.cameraId"
-                  :label="`${camera.cameraId} - ${camera.cameraName}`"
-                  :value="camera.cameraId"
+        <!-- 顶部 Tabs -->
+        <el-tabs v-model="activeDrawerTab" class="drawer-tabs">
+          <el-tab-pane :label="t('编辑')" name="edit" />
+          <el-tab-pane :label="t('播放')" name="play" :disabled="!isEdit" />
+        </el-tabs>
+
+        <!-- 编辑 Tab 内容 -->
+        <div v-show="activeDrawerTab === 'edit'" class="tab-content edit-content">
+          <div class="drawer-body">
+            <el-form ref="formRef" :model="form" :rules="rules" label-width="auto" class="stream-form">
+              <el-form-item label="名称:" prop="name">
+                <el-input v-model="form.name" placeholder="例如: 测试推流-001" style="width: 300px" />
+              </el-form-item>
+              <el-form-item label="LSS 节点:" prop="lssId">
+                <el-select v-model="form.lssId" placeholder="请选择 LSS 节点" clearable filterable style="width: 300px">
+                  <el-option
+                    v-for="lss in lssOptions"
+                    :key="lss.lssId"
+                    :label="`${lss.lssId} - ${lss.lssName}`"
+                    :value="lss.lssId"
+                  />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="摄像头:" prop="cameraId">
+                <el-select v-model="form.cameraId" placeholder="请选择摄像头" clearable filterable style="width: 300px">
+                  <el-option
+                    v-for="camera in cameraOptions"
+                    :key="camera.cameraId"
+                    :label="`${camera.cameraId} - ${camera.cameraName}`"
+                    :value="camera.cameraId"
+                  />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="推流方式:" prop="pushMethod">
+                <el-select disabled v-model="form.pushMethod" placeholder="请选择" style="width: 300px">
+                  <el-option label="ffmpeg" value="ffmpeg" />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="命令模板:" prop="commandTemplate">
+                <div class="code-editor-wrapper">
+                  <CodeEditor
+                    v-model="form.commandTemplate"
+                    language="bash"
+                    height="200px"
+                    placeholder="#!/bin/bash&#10;# FFmpeg 命令模板,可使用 {RTSP_URL} 等变量"
+                  />
+                </div>
+              </el-form-item>
+            </el-form>
+          </div>
+          <div class="drawer-footer">
+            <el-button @click="drawerVisible = false">{{ t('取消') }}</el-button>
+            <el-button type="primary" :loading="submitLoading" @click="handleSubmit">
+              {{ isEdit ? t('更新') : t('添加') }}
+            </el-button>
+          </div>
+        </div>
+
+        <!-- 播放 Tab 内容 -->
+        <div v-show="activeDrawerTab === 'play'" class="tab-content play-content">
+          <div class="media-drawer-content">
+            <!-- 左侧:视频播放区域 -->
+            <div class="video-area">
+              <div class="video-header">
+                <div class="header-left">
+                  <span class="title">{{ currentMediaStream?.name || t('流媒体播放') }}</span>
+                  <el-tag v-if="playbackInfo.isLive" type="success" size="small">{{ t('直播中') }}</el-tag>
+                  <el-tag v-else type="info" size="small">{{ t('离线') }}</el-tag>
+                </div>
+                <el-button type="danger" size="small" @click="drawerVisible = false">
+                  <Icon icon="mdi:close" width="16" height="16" />
+                  {{ t('关闭') }}
+                </el-button>
+              </div>
+              <div class="player-container">
+                <div v-if="!playbackInfo.videoId" class="player-placeholder">
+                  <el-icon :size="80" color="#666">
+                    <VideoPlay />
+                  </el-icon>
+                  <p>{{ t('暂无视频流') }}</p>
+                </div>
+                <VideoPlayer
+                  v-else
+                  ref="playerRef"
+                  player-type="cloudflare"
+                  :video-id="playbackInfo.videoId"
+                  :customer-domain="playbackInfo.customerDomain"
+                  :use-iframe="true"
+                  :autoplay="playConfig.autoplay"
+                  :muted="playConfig.muted"
+                  :controls="true"
                 />
-              </el-select>
-            </el-form-item>
-            <el-form-item label="推流方式:" prop="pushMethod">
-              <el-select disabled v-model="form.pushMethod" placeholder="请选择" style="width: 300px">
-                <el-option label="ffmpeg" value="ffmpeg" />
-              </el-select>
-            </el-form-item>
-            <!-- <el-form-item label="超时时间:" prop="timeoutSeconds">
-              <el-input-number v-model="form.timeoutSeconds" :min="1" :max="300" placeholder="秒" style="width: 150px" />
-              <span style="margin-left: 8px; color: #909399">秒</span>
-            </el-form-item> -->
-            <el-form-item label="命令模板:" prop="commandTemplate">
-              <div class="code-editor-wrapper">
-                <CodeEditor
-                  v-model="form.commandTemplate"
-                  language="bash"
-                  height="200px"
-                  placeholder="#!/bin/bash&#10;# FFmpeg 命令模板,可使用 {RTSP_URL} 等变量"
+                <!-- 开始推流按钮 -->
+                <div v-if="currentMediaStream && currentMediaStream.status !== '1'" class="stream-control-overlay">
+                  <el-button type="success" size="large" :loading="streamStarting" @click="handleStartStreamFromPlayer">
+                    {{ t('开始推流') }}
+                  </el-button>
+                </div>
+              </div>
+              <!-- 底部播放控制 -->
+              <div class="player-controls">
+                <el-button type="primary" size="small" @click="handlePlay">{{ t('播放') }}</el-button>
+                <el-button size="small" @click="handlePause">{{ t('暂停') }}</el-button>
+                <el-button type="danger" size="small" @click="handlePlayerStop">{{ t('停止') }}</el-button>
+                <el-button size="small" @click="handleScreenshot">{{ t('截图') }}</el-button>
+                <el-button size="small" @click="handleFullscreen">{{ t('全屏') }}</el-button>
+                <el-switch
+                  v-model="playConfig.muted"
+                  :active-text="t('静音')"
+                  :inactive-text="t('有声')"
+                  style="margin-left: 16px"
                 />
               </div>
-            </el-form-item>
-          </el-form>
-        </div>
-        <div class="drawer-footer">
-          <el-button @click="drawerVisible = false">{{ t('取消') }}</el-button>
-          <el-button type="primary" :loading="submitLoading" @click="handleSubmit">
-            {{ isEdit ? t('更新') : t('添加') }}
-          </el-button>
+            </div>
+
+            <!-- 右侧:PTZ 控制面板 -->
+            <div class="control-panel">
+              <!-- PTZ 方向控制 -->
+              <div class="panel-section">
+                <div class="section-title">{{ t('PTZ') }}</div>
+                <div class="ptz-grid">
+                  <div
+                    class="ptz-btn"
+                    @mousedown="handlePTZ('UP_LEFT')"
+                    @mouseup="handlePTZStop"
+                    @mouseleave="handlePTZStop"
+                  >
+                    <el-icon>
+                      <TopLeft />
+                    </el-icon>
+                  </div>
+                  <div
+                    class="ptz-btn"
+                    @mousedown="handlePTZ('UP')"
+                    @mouseup="handlePTZStop"
+                    @mouseleave="handlePTZStop"
+                  >
+                    <el-icon>
+                      <Top />
+                    </el-icon>
+                  </div>
+                  <div
+                    class="ptz-btn"
+                    @mousedown="handlePTZ('UP_RIGHT')"
+                    @mouseup="handlePTZStop"
+                    @mouseleave="handlePTZStop"
+                  >
+                    <el-icon>
+                      <TopRight />
+                    </el-icon>
+                  </div>
+                  <div
+                    class="ptz-btn"
+                    @mousedown="handlePTZ('LEFT')"
+                    @mouseup="handlePTZStop"
+                    @mouseleave="handlePTZStop"
+                  >
+                    <el-icon>
+                      <Back />
+                    </el-icon>
+                  </div>
+                  <div class="ptz-btn ptz-center" @click="handlePTZStop">
+                    <el-icon>
+                      <Refresh />
+                    </el-icon>
+                  </div>
+                  <div
+                    class="ptz-btn"
+                    @mousedown="handlePTZ('RIGHT')"
+                    @mouseup="handlePTZStop"
+                    @mouseleave="handlePTZStop"
+                  >
+                    <el-icon>
+                      <Right />
+                    </el-icon>
+                  </div>
+                  <div
+                    class="ptz-btn"
+                    @mousedown="handlePTZ('DOWN_LEFT')"
+                    @mouseup="handlePTZStop"
+                    @mouseleave="handlePTZStop"
+                  >
+                    <el-icon>
+                      <BottomLeft />
+                    </el-icon>
+                  </div>
+                  <div
+                    class="ptz-btn"
+                    @mousedown="handlePTZ('DOWN')"
+                    @mouseup="handlePTZStop"
+                    @mouseleave="handlePTZStop"
+                  >
+                    <el-icon>
+                      <Bottom />
+                    </el-icon>
+                  </div>
+                  <div
+                    class="ptz-btn"
+                    @mousedown="handlePTZ('DOWN_RIGHT')"
+                    @mouseup="handlePTZStop"
+                    @mouseleave="handlePTZStop"
+                  >
+                    <el-icon>
+                      <BottomRight />
+                    </el-icon>
+                  </div>
+                </div>
+
+                <!-- 缩放按钮 -->
+                <div class="zoom-buttons">
+                  <el-button
+                    size="small"
+                    @mousedown="handleZoomIn"
+                    @mouseup="handlePTZStop"
+                    @mouseleave="handlePTZStop"
+                  >
+                    <el-icon>
+                      <ZoomIn />
+                    </el-icon>
+                  </el-button>
+                  <el-button
+                    size="small"
+                    @mousedown="handleZoomOut"
+                    @mouseup="handlePTZStop"
+                    @mouseleave="handlePTZStop"
+                  >
+                    <el-icon>
+                      <ZoomOut />
+                    </el-icon>
+                  </el-button>
+                </div>
+
+                <!-- 速度滑块 -->
+                <div class="speed-slider">
+                  <span class="label">{{ t('速度') }}</span>
+                  <el-slider v-model="ptzSpeed" :min="10" :max="100" :step="10" size="small" />
+                  <span class="value">{{ ptzSpeed }}</span>
+                </div>
+              </div>
+
+              <!-- 预置位列表 -->
+              <div class="panel-section preset-section">
+                <div class="section-title">
+                  <span>{{ t('预置位') }}</span>
+                  <el-button type="primary" link size="small" @click="loadPresets" :loading="presetsLoading">
+                    <el-icon>
+                      <Refresh />
+                    </el-icon>
+                  </el-button>
+                </div>
+                <div class="preset-list" v-loading="presetsLoading">
+                  <div
+                    v-for="preset in presetList"
+                    :key="preset.token"
+                    :class="['preset-item', { active: activePresetToken === preset.token }]"
+                    @click="handleGotoPreset(preset)"
+                  >
+                    <span class="preset-index">{{ preset.token }}</span>
+                    <span class="preset-name">{{ preset.name || `Preset ${preset.token}` }}</span>
+                  </div>
+                  <el-empty
+                    v-if="!presetsLoading && presetList.length === 0"
+                    :description="t('暂无预置位')"
+                    :image-size="60"
+                  />
+                </div>
+              </div>
+            </div>
+          </div>
         </div>
       </div>
     </el-drawer>
@@ -198,196 +416,6 @@
         </el-button>
       </template>
     </el-dialog>
-
-    <!-- 流媒体播放抽屉 -->
-    <el-drawer
-      v-model="mediaDrawerVisible"
-      direction="rtl"
-      size="90%"
-      :with-header="false"
-      destroy-on-close
-      class="media-drawer"
-    >
-      <!-- 左上角关闭按钮 -->
-      <div class="drawer-close-btn" @click="mediaDrawerVisible = false">
-        <el-icon :size="20">
-          <Close />
-        </el-icon>
-      </div>
-      <div class="media-drawer-content">
-        <!-- 左侧:视频播放区域 -->
-        <div class="video-area">
-          <div class="video-header">
-            <div class="header-left">
-              <span class="title">{{ currentMediaStream?.name || t('流媒体播放') }}</span>
-              <el-tag v-if="playbackInfo.isLive" type="success" size="small">{{ t('直播中') }}</el-tag>
-              <el-tag v-else type="info" size="small">{{ t('离线') }}</el-tag>
-            </div>
-            <el-button type="danger" size="small" @click="mediaDrawerVisible = false">
-              <Icon icon="mdi:close" width="16" height="16" />
-              {{ t('关闭') }}
-            </el-button>
-          </div>
-          <div class="player-container">
-            <div v-if="!playbackInfo.videoId" class="player-placeholder">
-              <el-icon :size="80" color="#666">
-                <VideoPlay />
-              </el-icon>
-              <p>{{ t('暂无视频流') }}</p>
-            </div>
-            <VideoPlayer
-              v-else
-              ref="playerRef"
-              player-type="cloudflare"
-              :video-id="playbackInfo.videoId"
-              :customer-domain="playbackInfo.customerDomain"
-              :use-iframe="true"
-              :autoplay="playConfig.autoplay"
-              :muted="playConfig.muted"
-              :controls="true"
-            />
-          </div>
-          <!-- 底部播放控制 -->
-          <div class="player-controls">
-            <el-button type="primary" size="small" @click="handlePlay">{{ t('播放') }}</el-button>
-            <el-button size="small" @click="handlePause">{{ t('暂停') }}</el-button>
-            <el-button type="danger" size="small" @click="handlePlayerStop">{{ t('停止') }}</el-button>
-            <el-button size="small" @click="handleScreenshot">{{ t('截图') }}</el-button>
-            <el-button size="small" @click="handleFullscreen">{{ t('全屏') }}</el-button>
-            <el-switch
-              v-model="playConfig.muted"
-              :active-text="t('静音')"
-              :inactive-text="t('有声')"
-              style="margin-left: 16px"
-            />
-          </div>
-        </div>
-
-        <!-- 右侧:PTZ 控制面板 -->
-        <div class="control-panel">
-          <!-- PTZ 方向控制 -->
-          <div class="panel-section">
-            <div class="section-title">{{ t('PTZ') }}</div>
-            <div class="ptz-grid">
-              <div
-                class="ptz-btn"
-                @mousedown="handlePTZ('UP_LEFT')"
-                @mouseup="handlePTZStop"
-                @mouseleave="handlePTZStop"
-              >
-                <el-icon>
-                  <TopLeft />
-                </el-icon>
-              </div>
-              <div class="ptz-btn" @mousedown="handlePTZ('UP')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
-                <el-icon>
-                  <Top />
-                </el-icon>
-              </div>
-              <div
-                class="ptz-btn"
-                @mousedown="handlePTZ('UP_RIGHT')"
-                @mouseup="handlePTZStop"
-                @mouseleave="handlePTZStop"
-              >
-                <el-icon>
-                  <TopRight />
-                </el-icon>
-              </div>
-              <div class="ptz-btn" @mousedown="handlePTZ('LEFT')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
-                <el-icon>
-                  <Back />
-                </el-icon>
-              </div>
-              <div class="ptz-btn ptz-center" @click="handlePTZStop">
-                <el-icon>
-                  <Refresh />
-                </el-icon>
-              </div>
-              <div class="ptz-btn" @mousedown="handlePTZ('RIGHT')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
-                <el-icon>
-                  <Right />
-                </el-icon>
-              </div>
-              <div
-                class="ptz-btn"
-                @mousedown="handlePTZ('DOWN_LEFT')"
-                @mouseup="handlePTZStop"
-                @mouseleave="handlePTZStop"
-              >
-                <el-icon>
-                  <BottomLeft />
-                </el-icon>
-              </div>
-              <div class="ptz-btn" @mousedown="handlePTZ('DOWN')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
-                <el-icon>
-                  <Bottom />
-                </el-icon>
-              </div>
-              <div
-                class="ptz-btn"
-                @mousedown="handlePTZ('DOWN_RIGHT')"
-                @mouseup="handlePTZStop"
-                @mouseleave="handlePTZStop"
-              >
-                <el-icon>
-                  <BottomRight />
-                </el-icon>
-              </div>
-            </div>
-
-            <!-- 缩放按钮 -->
-            <div class="zoom-buttons">
-              <el-button size="small" @mousedown="handleZoomIn" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
-                <el-icon>
-                  <ZoomIn />
-                </el-icon>
-              </el-button>
-              <el-button size="small" @mousedown="handleZoomOut" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
-                <el-icon>
-                  <ZoomOut />
-                </el-icon>
-              </el-button>
-            </div>
-
-            <!-- 速度滑块 -->
-            <div class="speed-slider">
-              <span class="label">{{ t('速度') }}</span>
-              <el-slider v-model="ptzSpeed" :min="10" :max="100" :step="10" size="small" />
-              <span class="value">{{ ptzSpeed }}</span>
-            </div>
-          </div>
-
-          <!-- 预置位列表 -->
-          <div class="panel-section preset-section">
-            <div class="section-title">
-              <span>{{ t('预置位') }}</span>
-              <el-button type="primary" link size="small" @click="loadPresets" :loading="presetsLoading">
-                <el-icon>
-                  <Refresh />
-                </el-icon>
-              </el-button>
-            </div>
-            <div class="preset-list" v-loading="presetsLoading">
-              <div
-                v-for="preset in presetList"
-                :key="preset.token"
-                :class="['preset-item', { active: activePresetToken === preset.token }]"
-                @click="handleGotoPreset(preset)"
-              >
-                <span class="preset-index">{{ preset.token }}</span>
-                <span class="preset-name">{{ preset.name || `Preset ${preset.token}` }}</span>
-              </div>
-              <el-empty
-                v-if="!presetsLoading && presetList.length === 0"
-                :description="t('暂无预置位')"
-                :image-size="60"
-              />
-            </div>
-          </div>
-        </div>
-      </div>
-    </el-drawer>
   </div>
 </template>
 
@@ -447,9 +475,10 @@ const currentCommandTemplate = ref('')
 const currentStreamId = ref<number | null>(null)
 const commandUpdateLoading = ref(false)
 
-// 流媒体播放抽屉
-const mediaDrawerVisible = ref(false)
+// 合并抽屉的 tab 状态
+const activeDrawerTab = ref<'edit' | 'play'>('edit')
 const currentMediaStream = ref<LiveStreamDTO | null>(null)
+const streamStarting = ref(false)
 const playerRef = ref<InstanceType<typeof VideoPlayer>>()
 const playbackInfo = ref<{
   videoId: string
@@ -641,6 +670,8 @@ function handleAdd() {
     remark: '',
     enabled: true
   })
+  currentMediaStream.value = null
+  activeDrawerTab.value = 'edit'
   drawerVisible.value = true
 }
 
@@ -657,6 +688,8 @@ function handleEdit(row: LiveStreamDTO) {
     remark: row.remark || '',
     enabled: row.enabled
   })
+  currentMediaStream.value = row
+  activeDrawerTab.value = 'edit'
   drawerVisible.value = true
 }
 
@@ -813,7 +846,8 @@ async function handleStartStream(row: LiveStreamDTO) {
     const res = await startStreamTask({
       name: row.name,
       lssId: row.lssId,
-      cameraId: row.cameraId
+      cameraId: row.cameraId,
+      commandTemplate: row.commandTemplate
     })
     if (res.success) {
       ElMessage.success(t('推流任务已启动'))
@@ -829,6 +863,55 @@ async function handleStartStream(row: LiveStreamDTO) {
   }
 }
 
+// 从播放器窗口启动推流
+async function handleStartStreamFromPlayer() {
+  if (!currentMediaStream.value) return
+
+  if (!currentMediaStream.value.cameraId) {
+    ElMessage.warning(t('请先配置摄像头'))
+    return
+  }
+
+  streamStarting.value = true
+  try {
+    const res = await startStreamTask({
+      name: currentMediaStream.value.name,
+      lssId: currentMediaStream.value.lssId,
+      cameraId: currentMediaStream.value.cameraId,
+      commandTemplate: currentMediaStream.value.commandTemplate
+    })
+    if (res.success) {
+      ElMessage.success(t('推流任务已启动'))
+      // 更新当前流的状态
+      currentMediaStream.value.status = '1'
+      // 刷新播放信息
+      if (currentMediaStream.value.streamSn) {
+        try {
+          const playbackRes = await getStreamPlayback(currentMediaStream.value.streamSn)
+          if (playbackRes.success && playbackRes.data) {
+            playbackInfo.value = {
+              ...playbackInfo.value,
+              hlsUrl: playbackRes.data.hlsUrl,
+              whepUrl: playbackRes.data.whepUrl,
+              isLive: playbackRes.data.isLive
+            }
+          }
+        } catch (e) {
+          console.error('刷新播放信息失败', e)
+        }
+      }
+      getList()
+    } else {
+      ElMessage.error(res.errMessage || t('启动失败'))
+    }
+  } catch (error) {
+    console.error('启动推流失败', error)
+    ElMessage.error(t('启动推流失败'))
+  } finally {
+    streamStarting.value = false
+  }
+}
+
 // 停止推流
 async function handleStopStream(row: LiveStreamDTO) {
   try {
@@ -876,6 +959,20 @@ function parsePlaybackUrl(playbackUrl: string): { videoId: string; customerDomai
 async function handleViewCloudflare(row: LiveStreamDTO) {
   currentMediaStream.value = row
 
+  // 同时填充表单数据,以便用户可以切换到编辑 tab
+  Object.assign(form, {
+    id: row.id,
+    name: row.name,
+    lssId: row.lssId || '',
+    cameraId: row.cameraId || '',
+    channelId: row.channelId,
+    pushMethod: row.pushMethod || 'ffmpeg',
+    commandTemplate: row.commandTemplate || '',
+    timeoutSeconds: row.timeoutSeconds || 30,
+    remark: row.remark || '',
+    enabled: row.enabled
+  })
+
   // 默认值
   let videoId = ''
   let customerDomain = 'customer-pj89kn2ke2tcuh19.cloudflarestream.com'
@@ -924,7 +1021,8 @@ async function handleViewCloudflare(row: LiveStreamDTO) {
     }
   }
 
-  mediaDrawerVisible.value = true
+  activeDrawerTab.value = 'play'
+  drawerVisible.value = true
 }
 
 // 播放控制
@@ -1181,8 +1279,8 @@ onMounted(async () => {
   }
 }
 
-// 抽屉样式
-.stream-drawer {
+// 合并抽屉样式
+.combined-drawer {
   :deep(.el-drawer__body) {
     padding: 0;
     display: flex;
@@ -1197,6 +1295,46 @@ onMounted(async () => {
   height: 100%;
 }
 
+.drawer-tabs {
+  flex-shrink: 0;
+  padding: 0 20px;
+  border-bottom: 1px solid #e5e7eb;
+
+  :deep(.el-tabs__header) {
+    margin: 0;
+  }
+
+  :deep(.el-tabs__nav-wrap::after) {
+    display: none;
+  }
+
+  :deep(.el-tabs__item) {
+    font-size: 15px;
+    padding: 0 20px;
+    height: 48px;
+    line-height: 48px;
+  }
+}
+
+.tab-content {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.edit-content {
+  .drawer-body {
+    flex: 1;
+    overflow-y: auto;
+  }
+}
+
+.play-content {
+  background-color: #f5f7fa;
+}
+
 .drawer-header {
   flex-shrink: 0;
   padding: 16px 20px;
@@ -1312,6 +1450,7 @@ onMounted(async () => {
     display: flex;
     align-items: center;
     justify-content: center;
+    position: relative;
 
     .player-placeholder {
       display: flex;
@@ -1325,6 +1464,20 @@ onMounted(async () => {
         font-size: 14px;
       }
     }
+
+    .stream-control-overlay {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      z-index: 10;
+
+      .el-button {
+        font-size: 18px;
+        padding: 16px 32px;
+        border-radius: 0;
+      }
+    }
   }
 
   .player-controls {