Przeglądaj źródła

完善素材播放方案管理

yawuga 2 tygodni temu
rodzic
commit
6bd30f9949

+ 852 - 127
src/views/base/plan/index.vue

@@ -1,37 +1,26 @@
 <template>
   <div class="app-container">
-    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px">
-      <el-form-item label="播放方案名称" prop="planName">
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="90px">
+      <el-form-item label="方案名称" prop="planName">
         <el-input
           v-model="queryParams.planName"
-          placeholder="请输入播放方案名称"
+          placeholder="请输入方案名称"
           clearable
+          style="width: 200px"
           @keyup.enter="handleQuery"
         />
       </el-form-item>
-      <el-form-item label="循环方式:loop循环播放,once播放一次" prop="loopMode">
-        <el-input
-          v-model="queryParams.loopMode"
-          placeholder="请输入循环方式:loop循环播放,once播放一次"
-          clearable
-          @keyup.enter="handleQuery"
-        />
-      </el-form-item>
-      <el-form-item label="默认方案标识:0否,1是" prop="defaultFlag">
-        <el-input
-          v-model="queryParams.defaultFlag"
-          placeholder="请输入默认方案标识:0否,1是"
-          clearable
-          @keyup.enter="handleQuery"
-        />
+      <el-form-item label="循环方式" prop="loopMode">
+        <el-select v-model="queryParams.loopMode" placeholder="请选择循环方式" clearable style="width: 160px">
+          <el-option label="循环播放" value="loop" />
+          <el-option label="播放一次" value="once" />
+        </el-select>
       </el-form-item>
-      <el-form-item label="素材数量" prop="assetCount">
-        <el-input
-          v-model="queryParams.assetCount"
-          placeholder="请输入素材数量"
-          clearable
-          @keyup.enter="handleQuery"
-        />
+      <el-form-item label="播放状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="请选择播放状态" clearable style="width: 160px">
+          <el-option label="当前播放" value="1" />
+          <el-option label="备用方案" value="0" />
+        </el-select>
       </el-form-item>
       <el-form-item>
         <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
@@ -81,23 +70,62 @@
       <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
     </el-row>
 
+    <el-alert
+      v-if="currentPlayingCount > 1"
+      title="检测到存在多条当前播放方案,请联系后端检查播放状态唯一性规则。"
+      type="warning"
+      show-icon
+      :closable="false"
+      class="play-status-alert"
+    />
+
     <el-table v-loading="loading" :data="planList" @selection-change="handleSelectionChange">
       <el-table-column type="selection" width="55" align="center" />
-      <el-table-column label="主键ID" align="center" prop="id" />
-      <el-table-column label="播放方案名称" align="center" prop="planName" />
-      <el-table-column label="循环方式:loop循环播放,once播放一次" align="center" prop="loopMode" />
-      <el-table-column label="默认方案标识:0否,1是" align="center" prop="defaultFlag" />
-      <el-table-column label="素材数量" align="center" prop="assetCount" />
-      <el-table-column label="启用状态:0停用,1启用" align="center" prop="status" />
-      <el-table-column label="备注" align="center" prop="remark" />
-      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+      <el-table-column label="方案名称" align="left" prop="planName" min-width="180" show-overflow-tooltip />
+      <el-table-column label="循环方式" align="center" prop="loopMode" width="110">
+        <template #default="scope">
+          <el-tag v-if="scope.row.loopMode === 'loop'" type="success">循环播放</el-tag>
+          <el-tag v-else-if="scope.row.loopMode === 'once'" type="info">播放一次</el-tag>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="素材数" align="center" prop="assetCount" width="90">
+        <template #default="scope">
+          <el-tag type="primary">{{ scope.row.assetCount || 0 }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="播放状态" align="center" prop="status" width="110">
         <template #default="scope">
-          <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['base:plan:edit']">修改</el-button>
-          <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['base:plan:remove']">删除</el-button>
+          <el-tag v-if="String(scope.row.status) === '1'" type="success">当前播放</el-tag>
+          <el-tag v-else-if="String(scope.row.status) === '0'" type="info">备用方案</el-tag>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" width="240" fixed="right" class-name="small-padding fixed-width">
+        <template #default="scope">
+          <el-button link type="primary" icon="View" @click="handlePreview(scope.row)">预览</el-button>
+          <el-button
+            v-if="String(scope.row.status) !== '1'"
+            link
+            type="success"
+            icon="CircleCheck"
+            @click="handleStatusChange(scope.row, '1')"
+            v-hasPermi="['base:plan:edit']"
+          >启用播放</el-button>
+          <el-button
+            v-if="String(scope.row.status) === '1'"
+            link
+            type="warning"
+            icon="CircleClose"
+            @click="handleStatusChange(scope.row, '0')"
+            v-hasPermi="['base:plan:edit']"
+          >设为备用</el-button>
+          <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['base:plan:edit']">编辑</el-button>
+          <el-button link type="danger" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['base:plan:remove']">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
-    
+
     <pagination
       v-show="total>0"
       :total="total"
@@ -107,71 +135,118 @@
     />
 
     <!-- 添加或修改播放方案对话框 -->
-    <el-dialog :title="title" v-model="open" width="500px" append-to-body>
-      <el-form ref="planRef" :model="form" :rules="rules" label-width="100px">
+    <el-dialog :title="title" v-model="open" width="960px" append-to-body>
+      <el-form ref="planRef" :model="form" :rules="rules" label-width="110px">
         <el-row>
           <el-col :span="24">
-            <el-form-item label="播放方案名称" prop="planName">
-              <el-input v-model="form.planName" placeholder="请输入播放方案名称" />
+            <el-form-item label="方案名称" prop="planName">
+              <el-input
+                v-model="form.planName"
+                maxlength="100"
+                show-word-limit
+                placeholder="请输入方案名称"
+              />
             </el-form-item>
           </el-col>
-          <el-col :span="24">
-            <el-form-item label="循环方式:loop循环播放,once播放一次" prop="loopMode">
-              <el-input v-model="form.loopMode" placeholder="请输入循环方式:loop循环播放,once播放一次" />
+          <el-col :span="12">
+            <el-form-item label="循环方式" prop="loopMode">
+              <el-radio-group v-model="form.loopMode">
+                <el-radio label="loop">循环播放</el-radio>
+                <el-radio label="once">播放一次</el-radio>
+              </el-radio-group>
             </el-form-item>
           </el-col>
-          <el-col :span="24">
-            <el-form-item label="默认方案标识:0否,1是" prop="defaultFlag">
-              <el-input v-model="form.defaultFlag" placeholder="请输入默认方案标识:0否,1是" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="24">
-            <el-form-item label="素材数量" prop="assetCount">
-              <el-input v-model="form.assetCount" placeholder="请输入素材数量" />
+          <el-col :span="12">
+            <el-form-item label="播放状态" prop="status">
+              <el-radio-group v-model="form.status">
+                <el-radio label="1">当前播放</el-radio>
+                <el-radio label="0">备用方案</el-radio>
+              </el-radio-group>
+              <div class="form-tip">同一时间仅允许一套方案作为当前播放方案,建议新增方案先保存为备用方案。</div>
             </el-form-item>
           </el-col>
           <el-col :span="24">
             <el-form-item label="备注" prop="remark">
-              <el-input v-model="form.remark" type="textarea" placeholder="请输入内容" />
+              <el-input
+                v-model="form.remark"
+                type="textarea"
+                :rows="3"
+                maxlength="500"
+                show-word-limit
+                placeholder="请输入备注"
+              />
             </el-form-item>
           </el-col>
         </el-row>
-        <el-divider content-position="center">播放方案素材明细信息</el-divider>
+        <el-divider content-position="center">素材编排</el-divider>
         <el-row :gutter="10" class="mb8">
           <el-col :span="1.5">
-            <el-button type="primary" icon="Plus" @click="handleAddRobotOpsPlayPlanItem">添加</el-button>
+            <el-button type="primary" icon="Plus" @click="handleSelectAsset">选择素材</el-button>
           </el-col>
           <el-col :span="1.5">
-            <el-button type="danger" icon="Delete" @click="handleDeleteRobotOpsPlayPlanItem">删除</el-button>
+            <el-button type="danger" icon="Delete" @click="handleDeleteRobotOpsPlayPlanItem">移除素材</el-button>
           </el-col>
         </el-row>
-        <el-table :data="robotOpsPlayPlanItemList" @selection-change="handleRobotOpsPlayPlanItemSelectionChange" ref="robotOpsPlayPlanItem">
+        <el-table
+          :data="robotOpsPlayPlanItemList"
+          @selection-change="handleRobotOpsPlayPlanItemSelectionChange"
+          ref="robotOpsPlayPlanItem"
+          empty-text="请点击「选择素材」添加播放内容"
+        >
           <el-table-column type="selection" width="50" align="center" />
-          <el-table-column label="序号" width="60">
-            <template #default="{ $index }">
-              {{ $index + 1 }}
-            </template>
-          </el-table-column>
-          <el-table-column label="素材ID,关联robot_ops_media_asset.id" prop="assetId" width="150">
+          <el-table-column label="素材" min-width="260">
             <template #default="scope">
-              <el-input v-model="scope.row.assetId" placeholder="请输入素材ID,关联robot_ops_media_asset.id" />
+              <div class="asset-info">
+                <div class="asset-thumb">
+                  <image-preview
+                    v-if="getAssetThumb(scope.row)"
+                    :src="getAssetThumb(scope.row)"
+                    :width="54"
+                    :height="54"
+                  />
+                  <div v-else class="video-thumb">
+                    <el-icon><VideoPlay /></el-icon>
+                  </div>
+                </div>
+                <div class="asset-meta">
+                  <div class="asset-name">{{ scope.row.assetName || '未命名素材' }}</div>
+                  <div class="asset-sub">
+                    <el-tag v-if="scope.row.assetType === 'image'" size="small" type="success">图片</el-tag>
+                    <el-tag v-else-if="scope.row.assetType === 'video'" size="small" type="warning">视频</el-tag>
+                    <el-tag v-else size="small" type="info">未知</el-tag>
+                    <span class="asset-id">ID: {{ scope.row.assetId }}</span>
+                  </div>
+                </div>
+              </div>
             </template>
           </el-table-column>
-          <el-table-column label="播放顺序,数字越小越靠前" prop="playOrder" width="150">
+          <el-table-column label="顺序" prop="playOrder" width="140" align="center">
             <template #default="scope">
-              <el-input v-model="scope.row.playOrder" placeholder="请输入播放顺序,数字越小越靠前" />
+              <el-button link type="primary" :disabled="scope.$index === 0" @click="moveItem(scope.$index, -1)">上移</el-button>
+              <el-button link type="primary" :disabled="scope.$index === robotOpsPlayPlanItemList.length - 1" @click="moveItem(scope.$index, 1)">下移</el-button>
             </template>
           </el-table-column>
-          <el-table-column label="停留时长,图片必填,视频可为空" prop="staySeconds" width="150">
+          <el-table-column label="停留时长" prop="staySeconds" width="160" align="center">
             <template #default="scope">
-              <el-input v-model="scope.row.staySeconds" placeholder="请输入停留时长,图片必填,视频可为空" />
+              <el-input-number
+                v-if="scope.row.assetType === 'image'"
+                v-model="scope.row.staySeconds"
+                :min="1"
+                :max="3600"
+                :step="1"
+                controls-position="right"
+                style="width: 120px"
+              />
+              <span v-else class="text-muted">视频播完切换</span>
             </template>
           </el-table-column>
-          <el-table-column label="转场方式,预留字段:none无转场,fade淡入淡出" prop="transitionType" width="150">
+          <el-table-column label="文件信息" min-width="160">
             <template #default="scope">
-              <el-select v-model="scope.row.transitionType" placeholder="请选择转场方式,预留字段:none无转场,fade淡入淡出">
-                <el-option label="请选择字典生成" value="" />
-              </el-select>
+              <div class="asset-file-info">
+                <div>{{ scope.row.resolution || '-' }}</div>
+                <div v-if="scope.row.assetType === 'video'">时长:{{ formatDuration(scope.row.durationSeconds) }}</div>
+                <div v-else>图片停留:{{ scope.row.staySeconds || '-' }} 秒</div>
+              </div>
             </template>
           </el-table-column>
         </el-table>
@@ -183,11 +258,184 @@
         </div>
       </template>
     </el-dialog>
+
+    <!-- 选择素材弹窗 -->
+    <el-dialog title="选择素材" v-model="assetSelectOpen" width="900px" append-to-body @closed="selectedAssets = []">
+      <el-form :model="assetQueryParams" :inline="true" label-width="80px">
+        <el-form-item label="素材名称">
+          <el-input
+            v-model="assetQueryParams.assetName"
+            placeholder="请输入素材名称"
+            clearable
+            @keyup.enter="handleAssetQuery"
+          />
+        </el-form-item>
+        <el-form-item label="素材类型">
+          <el-select v-model="assetQueryParams.assetType" placeholder="请选择素材类型" clearable style="width: 140px">
+            <el-option label="图片" value="image" />
+            <el-option label="视频" value="video" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="Search" @click="handleAssetQuery">搜索</el-button>
+          <el-button icon="Refresh" @click="resetAssetQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+      <el-alert
+        title="仅显示启用状态的素材;已停用素材不能被新播放方案选择。"
+        type="info"
+        show-icon
+        :closable="false"
+        class="asset-select-tip"
+      />
+      <el-table v-loading="assetLoading" :data="assetList" @selection-change="handleAssetSelectionChange">
+        <el-table-column type="selection" width="50" align="center" />
+        <el-table-column label="缩略图" width="90" align="center">
+          <template #default="scope">
+            <image-preview
+              v-if="getAssetThumb(scope.row)"
+              :src="getAssetThumb(scope.row)"
+              :width="48"
+              :height="48"
+            />
+            <div v-else class="video-thumb small">
+              <el-icon><VideoPlay /></el-icon>
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column label="素材名称" prop="assetName" min-width="180" show-overflow-tooltip />
+        <el-table-column label="类型" prop="assetType" width="90" align="center">
+          <template #default="scope">
+            <el-tag v-if="scope.row.assetType === 'image'" size="small" type="success">图片</el-tag>
+            <el-tag v-else-if="scope.row.assetType === 'video'" size="small" type="warning">视频</el-tag>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="分辨率" prop="resolution" width="120" align="center">
+          <template #default="scope">{{ scope.row.resolution || '-' }}</template>
+        </el-table-column>
+        <el-table-column label="视频时长" prop="durationSeconds" width="120" align="center">
+          <template #default="scope">{{ formatDuration(scope.row.durationSeconds) }}</template>
+        </el-table-column>
+      </el-table>
+      <pagination
+        v-show="assetTotal > 0"
+        :total="assetTotal"
+        v-model:page="assetQueryParams.pageNum"
+        v-model:limit="assetQueryParams.pageSize"
+        @pagination="getAssetList"
+      />
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button type="primary" @click="confirmSelectAsset">确 定</el-button>
+          <el-button @click="handleCancelSelectAsset">取 消</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
+    <!-- 播放方案预览弹窗 -->
+    <el-dialog
+      :title="previewPlan?.planName ? '播放方案预览 - ' + previewPlan.planName : '播放方案预览'"
+      v-model="previewOpen"
+      width="960px"
+      append-to-body
+      @closed="resetPreview"
+    >
+      <div v-if="previewPlan" class="plan-preview">
+        <div class="preview-header">
+          <div>
+            <div class="preview-title">{{ previewPlan.planName || '-' }}</div>
+            <div class="preview-sub">
+              <el-tag v-if="previewPlan.loopMode === 'loop'" type="success">循环播放</el-tag>
+              <el-tag v-else-if="previewPlan.loopMode === 'once'" type="info">播放一次</el-tag>
+              <el-tag v-if="String(previewPlan.status) === '1'" type="warning">当前播放</el-tag>
+              <el-tag v-else type="info">备用方案</el-tag>
+              <span>素材数:{{ previewItems.length }}</span>
+              <span v-if="previewItems.length">当前:{{ previewIndex + 1 }} / {{ previewItems.length }}</span>
+            </div>
+          </div>
+        </div>
+        <div class="preview-body">
+          <div class="preview-main">
+            <div class="preview-stage">
+              <div v-if="currentPreviewItem && currentPreviewItem.fileUrl">
+                <img
+                  v-if="currentPreviewItem.assetType === 'image'"
+                  :src="currentPreviewItem.fileUrl"
+                  class="preview-media preview-image"
+                  @error="handlePreviewMediaError"
+                />
+                <video
+                  v-else-if="currentPreviewItem.assetType === 'video'"
+                  ref="previewVideoRef"
+                  :src="currentPreviewItem.fileUrl"
+                  class="preview-media preview-video"
+                  controls
+                  @error="handlePreviewMediaError"
+                />
+                <div v-else class="preview-empty">暂不支持该素材类型预览</div>
+              </div>
+              <div v-else-if="currentPreviewItem && !currentPreviewItem.fileUrl" class="preview-empty">素材文件地址为空,无法预览</div>
+              <div v-else class="preview-empty">暂无可预览素材</div>
+            </div>
+            <div v-if="currentPreviewItem" class="preview-current-info">
+              <div class="preview-current-name">{{ currentPreviewItem.assetName || '未命名素材' }}</div>
+              <div class="preview-current-desc">
+                <el-tag v-if="currentPreviewItem.assetType === 'image'" size="small" type="success">图片</el-tag>
+                <el-tag v-else-if="currentPreviewItem.assetType === 'video'" size="small" type="warning">视频</el-tag>
+                <span v-if="currentPreviewItem.assetType === 'image'">停留 {{ currentPreviewItem.staySeconds || '-' }} 秒</span>
+                <span v-else-if="currentPreviewItem.assetType === 'video'">视频播完切换</span>
+                <span v-if="currentPreviewItem.resolution">分辨率:{{ currentPreviewItem.resolution }}</span>
+                <span v-if="currentPreviewItem.durationSeconds">时长:{{ formatDuration(currentPreviewItem.durationSeconds) }}</span>
+              </div>
+            </div>
+          </div>
+          <div class="preview-list">
+            <div class="preview-list-title">播放清单</div>
+            <div
+              v-for="(item, index) in previewItems"
+              :key="item.assetId || index"
+              class="preview-item"
+              :class="{ active: index === previewIndex }"
+              @click="setPreviewIndex(index)"
+            >
+              <div class="preview-item-index">{{ index + 1 }}</div>
+              <div class="preview-item-thumb">
+                <image-preview
+                  v-if="getAssetThumb(item)"
+                  :src="getAssetThumb(item)"
+                  :width="44"
+                  :height="44"
+                />
+                <div v-else class="video-thumb small">
+                  <el-icon><VideoPlay /></el-icon>
+                </div>
+              </div>
+              <div class="preview-item-meta">
+                <div class="preview-item-name">{{ item.assetName || '未命名素材' }}</div>
+                <div class="preview-item-desc">
+                  <el-tag v-if="item.assetType === 'image'" size="small" type="success">图片</el-tag>
+                  <el-tag v-else-if="item.assetType === 'video'" size="small" type="warning">视频</el-tag>
+                  <el-tag v-if="String(item.assetStatus) === '0'" size="small" type="danger">已停用</el-tag>
+                  <span v-if="item.assetType === 'image'">停留 {{ item.staySeconds || '-' }} 秒</span>
+                  <span v-else>视频播完切换</span>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="preview-actions">
+          <el-button :disabled="previewIndex <= 0" @click="setPreviewIndex(previewIndex - 1)">上一个</el-button>
+          <el-button :disabled="previewIndex >= previewItems.length - 1" @click="setPreviewIndex(previewIndex + 1)">下一个</el-button>
+        </div>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script setup name="Plan">
 import { listPlan, getPlan, delPlan, addPlan, updatePlan } from "@/api/base/plan"
+import { listMediAasset } from "@/api/base/mediAasset"
 
 const { proxy } = getCurrentInstance()
 
@@ -203,6 +451,44 @@ const multiple = ref(true)
 const total = ref(0)
 const title = ref("")
 
+const previewOpen = ref(false)
+const previewPlan = ref(null)
+const previewItems = ref([])
+const previewIndex = ref(0)
+const previewVideoRef = ref(null)
+const currentPreviewItem = computed(() => {
+  return previewItems.value[previewIndex.value] || null
+})
+const currentPlayingCount = computed(() => {
+  return planList.value.filter(item => String(item.status) === '1').length
+})
+
+function pausePreviewVideo() {
+  const video = previewVideoRef.value
+  if (video && typeof video.pause === 'function') {
+    video.pause()
+  }
+}
+
+function setPreviewIndex(index) {
+  if (index < 0 || index >= previewItems.value.length) return
+  pausePreviewVideo()
+  previewIndex.value = index
+}
+
+const assetSelectOpen = ref(false)
+const assetLoading = ref(false)
+const assetList = ref([])
+const selectedAssets = ref([])
+const assetTotal = ref(0)
+const assetQueryParams = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  assetName: undefined,
+  assetType: undefined,
+  status: '1'
+})
+
 const data = reactive({
   form: {},
   queryParams: {
@@ -210,37 +496,68 @@ const data = reactive({
     pageSize: 10,
     planName: undefined,
     loopMode: undefined,
-    defaultFlag: undefined,
-    assetCount: undefined,
     status: undefined,
   },
   rules: {
     planName: [
-      { required: true, message: "播放方案名称不能为空", trigger: "blur" }
+      { required: true, message: "方案名称不能为空", trigger: "blur" },
+      { max: 100, message: "方案名称不能超过 100 字", trigger: "blur" }
     ],
     loopMode: [
-      { required: true, message: "循环方式:loop循环播放,once播放一次不能为空", trigger: "blur" }
-    ],
-    defaultFlag: [
-      { required: true, message: "默认方案标识:0否,1是不能为空", trigger: "blur" }
-    ],
-    assetCount: [
-      { required: true, message: "素材数量不能为空", trigger: "blur" }
+      { required: true, message: "请选择循环方式", trigger: "change" }
     ],
     status: [
-      { required: true, message: "启用状态:0停用,1启用不能为空", trigger: "change" }
+      { required: true, message: "请选择播放状态", trigger: "change" }
     ],
+    remark: [
+      { max: 500, message: "备注不能超过 500 字", trigger: "blur" }
+    ]
   }
 })
 
 const { queryParams, form, rules } = toRefs(data)
 
+function getAssetThumb(row) {
+  if (row.thumbnailUrl) return row.thumbnailUrl
+  if (row.assetType === 'image' && row.fileUrl) return row.fileUrl
+  return ''
+}
+
+function formatDuration(seconds) {
+  const value = Number(seconds)
+  if (!value) return '-'
+  const m = Math.floor(value / 60)
+  const s = value % 60
+  return m > 0 ? `${m}分${s}秒` : `${s}秒`
+}
+
+function moveItem(index, direction) {
+  const targetIndex = index + direction
+  if (targetIndex < 0 || targetIndex >= robotOpsPlayPlanItemList.value.length) return
+  const list = robotOpsPlayPlanItemList.value
+  const temp = list[index]
+  list[index] = list[targetIndex]
+  list[targetIndex] = temp
+  refreshPlayOrder()
+}
+
+function refreshPlayOrder() {
+  robotOpsPlayPlanItemList.value.forEach((item, index) => {
+    item.playOrder = index + 1
+    item.transitionType = item.transitionType || 'none'
+  })
+}
+
 /** 查询播放方案列表 */
 function getList() {
   loading.value = true
   listPlan(queryParams.value).then(response => {
-    planList.value = response.rows
-    total.value = response.total
+    planList.value = response.rows || []
+    total.value = response.total || 0
+  }).catch(() => {
+    planList.value = []
+    total.value = 0
+  }).finally(() => {
     loading.value = false
   })
 }
@@ -256,10 +573,9 @@ function reset() {
   form.value = {
     id: null,
     planName: null,
-    loopMode: null,
-    defaultFlag: null,
-    assetCount: null,
-    status: null,
+    loopMode: 'loop',
+    assetCount: 0,
+    status: '0',
     remark: null,
     createBy: null,
     createTime: null,
@@ -273,6 +589,9 @@ function reset() {
 /** 搜索按钮操作 */
 function handleQuery() {
   queryParams.value.pageNum = 1
+  if (queryParams.value.planName) {
+    queryParams.value.planName = queryParams.value.planName.trim()
+  }
   getList()
 }
 
@@ -293,7 +612,10 @@ function handleSelectionChange(selection) {
 function handleAdd() {
   reset()
   open.value = true
-  title.value = "添加播放方案"
+  title.value = "新增播放方案"
+  nextTick(() => {
+    proxy.$refs.robotOpsPlayPlanItem?.clearSelection?.()
+  })
 }
 
 /** 修改按钮操作 */
@@ -301,39 +623,93 @@ function handleUpdate(row) {
   reset()
   const _id = row.id || ids.value
   getPlan(_id).then(response => {
-    form.value = response.data
-    robotOpsPlayPlanItemList.value = response.data.robotOpsPlayPlanItemList
+    const data = response.data || {}
+    form.value = {
+      ...data,
+      loopMode: data.loopMode || 'loop',
+      status: data.status != null ? String(data.status) : '0'
+    }
+    const itemList = data.robotOpsPlayPlanItemList || data.itemList || []
+    robotOpsPlayPlanItemList.value = itemList.map((item, index) => ({
+      ...item,
+      playOrder: item.playOrder || index + 1,
+      transitionType: item.transitionType || 'none'
+    }))
     open.value = true
-    title.value = "修改播放方案"
+    title.value = "编辑播放方案"
+  }).finally(() => {
+    nextTick(() => {
+      proxy.$refs.robotOpsPlayPlanItem?.clearSelection?.()
+    })
   })
 }
 
 /** 提交按钮 */
 function submitForm() {
   proxy.$refs["planRef"].validate(valid => {
-    if (valid) {
-      form.value.robotOpsPlayPlanItemList = robotOpsPlayPlanItemList.value
-      if (form.value.id != null) {
-        updatePlan(form.value).then(() => {
-          proxy.$modal.msgSuccess("修改成功")
-          open.value = false
-          getList()
-        })
-      } else {
-        addPlan(form.value).then(() => {
-          proxy.$modal.msgSuccess("新增成功")
-          open.value = false
-          getList()
-        })
+    if (!valid) return
+    if (!robotOpsPlayPlanItemList.value.length) {
+      proxy.$modal.msgWarning('请至少选择一个素材')
+      return
+    }
+    for (const item of robotOpsPlayPlanItemList.value) {
+      if (!item.assetId) {
+        proxy.$modal.msgWarning('存在未选择素材的明细,请检查')
+        return
       }
+      if (item.assetType === 'image' && (!item.staySeconds || Number(item.staySeconds) <= 0)) {
+        proxy.$modal.msgWarning('图片素材必须填写停留时长')
+        return
+      }
+    }
+    if (String(form.value.status) === '1') {
+      proxy.$modal.confirm('设为当前播放后,其他播放方案应自动变为备用方案,确认继续吗?').then(() => {
+        doSubmitForm()
+      }).catch(() => {})
+      return
     }
+    doSubmitForm()
   })
 }
 
+function doSubmitForm() {
+  refreshPlayOrder()
+  const submitItemList = robotOpsPlayPlanItemList.value.map(item => ({
+    id: item.id,
+    planId: item.planId,
+    assetId: item.assetId,
+    playOrder: item.playOrder,
+    staySeconds: item.assetType === 'image' ? item.staySeconds : null,
+    transitionType: item.transitionType || 'none'
+  }))
+  const payload = {
+    id: form.value.id,
+    planName: form.value.planName,
+    loopMode: form.value.loopMode,
+    status: String(form.value.status || '0'),
+    remark: form.value.remark,
+    robotOpsPlayPlanItemList: submitItemList,
+    itemList: submitItemList
+  }
+  if (form.value.id != null) {
+    updatePlan(payload).then(() => {
+      proxy.$modal.msgSuccess("修改成功")
+      open.value = false
+      getList()
+    })
+  } else {
+    addPlan(payload).then(() => {
+      proxy.$modal.msgSuccess("新增成功")
+      open.value = false
+      getList()
+    })
+  }
+}
+
 /** 删除按钮操作 */
 function handleDelete(row) {
   const _ids = row.id || ids.value
-  proxy.$modal.confirm('是否确认删除播放方案编号为"' + _ids + '"的数据项?').then(function() {
+  proxy.$modal.confirm('确认删除选中的播放方案吗?如删除的是当前播放方案,可能导致车端无可播放方案,请谨慎操作。').then(function() {
     return delPlan(_ids)
   }).then(() => {
     getList()
@@ -341,40 +717,389 @@ function handleDelete(row) {
   }).catch(() => {})
 }
 
-/** 播放方案素材明细添加按钮操作 */
-function handleAddRobotOpsPlayPlanItem() {
-  let obj = {}
-  obj.assetId = undefined
-  obj.playOrder = undefined
-  obj.staySeconds = undefined
-  obj.transitionType = undefined
-  robotOpsPlayPlanItemList.value.push(obj)
-}
-
 /** 播放方案素材明细删除按钮操作 */
 function handleDeleteRobotOpsPlayPlanItem() {
-  if (checkedRobotOpsPlayPlanItem.value.length == 0) {
-    proxy.$modal.msgError("请先选择要删除的播放方案素材明细数据")
-  } else {
-    const robotOpsPlayPlanItems = robotOpsPlayPlanItemList.value
-    const checkedRobotOpsPlayPlanItems = checkedRobotOpsPlayPlanItem.value
-    robotOpsPlayPlanItemList.value = robotOpsPlayPlanItems.filter(function(item) {
-      return checkedRobotOpsPlayPlanItems.indexOf(item.index) == -1
-    })
+  if (checkedRobotOpsPlayPlanItem.value.length === 0) {
+    proxy.$modal.msgError("请先选择要移除的素材")
+    return
   }
+  robotOpsPlayPlanItemList.value = robotOpsPlayPlanItemList.value.filter(item => {
+    return !checkedRobotOpsPlayPlanItem.value.includes(item)
+  })
+  checkedRobotOpsPlayPlanItem.value = []
+  proxy.$refs.robotOpsPlayPlanItem?.clearSelection?.()
+  refreshPlayOrder()
 }
 
 /** 复选框选中数据 */
 function handleRobotOpsPlayPlanItemSelectionChange(selection) {
-  checkedRobotOpsPlayPlanItem.value = selection.map(item => item.index)
+  checkedRobotOpsPlayPlanItem.value = selection
 }
 
 /** 导出按钮操作 */
 function handleExport() {
   proxy.download('base/plan/export', {
     ...queryParams.value
-  }, `plan_${new Date().getTime()}.xlsx`)
+  }, `播放方案_${new Date().getTime()}.xlsx`)
+}
+
+/** 预览 */
+function handlePreview(row) {
+  if (!row || !row.id) {
+    proxy.$modal.msgWarning('请选择要预览的播放方案')
+    return
+  }
+  getPlan(row.id).then(response => {
+    const data = response.data || {}
+    const itemList = data.robotOpsPlayPlanItemList || data.itemList || []
+    previewPlan.value = data
+    previewItems.value = itemList
+      .map((item, index) => ({
+        ...item,
+        playOrder: item.playOrder || index + 1,
+        transitionType: item.transitionType || 'none'
+      }))
+      .sort((a, b) => Number(a.playOrder || 0) - Number(b.playOrder || 0))
+    previewIndex.value = 0
+    previewOpen.value = true
+    if (!previewItems.value.length) {
+      proxy.$modal.msgWarning('该播放方案暂无素材明细')
+    }
+  }).catch(() => {
+    proxy.$modal.msgError('获取播放方案详情失败,无法预览')
+  })
+}
+
+function resetPreview() {
+  pausePreviewVideo()
+  previewPlan.value = null
+  previewItems.value = []
+  previewIndex.value = 0
+}
+
+function handlePreviewMediaError() {
+  proxy.$modal.msgWarning('素材文件暂无法预览,请检查文件地址是否有效。')
+}
+
+
+/** 状态变更 */
+function handleStatusChange(row, status) {
+  const actionText = status === '1' ? '启用播放' : '设为备用'
+  proxy.$modal.msgWarning('播放方案\u201c' + actionText + '\u201d接口暂未单独接入,建议后端提供独立状态接口后再联调,避免通过完整编辑接口覆盖素材明细。')
+}
+
+/** 选择素材 */
+function handleSelectAsset() {
+  selectedAssets.value = []
+  assetSelectOpen.value = true
+  getAssetList()
+}
+
+/** 获取素材列表 */
+function getAssetList() {
+  assetLoading.value = true
+  listMediAasset(assetQueryParams).then(response => {
+    assetList.value = response.rows || []
+    assetTotal.value = response.total || 0
+  }).catch(() => {
+    assetList.value = []
+    assetTotal.value = 0
+  }).finally(() => {
+    assetLoading.value = false
+  })
+}
+
+/** 重置素材查询 */
+function resetAssetQuery() {
+  assetQueryParams.pageNum = 1
+  assetQueryParams.assetName = undefined
+  assetQueryParams.assetType = undefined
+  assetQueryParams.status = '1'
+  getAssetList()
+}
+
+/** 素材选择变化 */
+function handleAssetSelectionChange(selection) {
+  selectedAssets.value = selection
+}
+
+/** 素材搜索 */
+function handleAssetQuery() {
+  assetQueryParams.pageNum = 1
+  if (assetQueryParams.assetName) {
+    assetQueryParams.assetName = assetQueryParams.assetName.trim()
+  }
+  getAssetList()
+}
+
+/** 确认选择素材 */
+function confirmSelectAsset() {
+  if (!selectedAssets.value.length) {
+    proxy.$modal.msgWarning('请选择素材')
+    return
+  }
+  const existsIds = robotOpsPlayPlanItemList.value.map(item => Number(item.assetId))
+  let addCount = 0
+  let skipCount = 0
+  selectedAssets.value.forEach(asset => {
+    if (existsIds.includes(Number(asset.id))) {
+      skipCount++
+      return
+    }
+    robotOpsPlayPlanItemList.value.push({
+      assetId: asset.id,
+      assetName: asset.assetName,
+      assetType: asset.assetType,
+      fileUrl: asset.fileUrl,
+      thumbnailUrl: asset.thumbnailUrl,
+      durationSeconds: asset.durationSeconds,
+      resolution: asset.resolution,
+      assetStatus: asset.status,
+      playOrder: robotOpsPlayPlanItemList.value.length + 1,
+      staySeconds: asset.assetType === 'image' ? 10 : null,
+      transitionType: 'none'
+    })
+    existsIds.push(Number(asset.id))
+    addCount++
+  })
+  refreshPlayOrder()
+  nextTick(() => {
+    selectedAssets.value = []
+  })
+  assetSelectOpen.value = false
+  if (addCount > 0 && skipCount > 0) {
+    proxy.$modal.msgSuccess('已添加 ' + addCount + ' 个素材,跳过 ' + skipCount + ' 个重复素材')
+  } else if (addCount > 0) {
+    proxy.$modal.msgSuccess('已添加 ' + addCount + ' 个素材')
+  } else if (skipCount > 0) {
+    proxy.$modal.msgWarning('所选素材已全部存在,请勿重复添加')
+  }
+}
+
+function handleCancelSelectAsset() {
+  selectedAssets.value = []
+  assetSelectOpen.value = false
 }
 
 getList()
 </script>
+
+<style scoped>
+.asset-info {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+.asset-thumb {
+  width: 54px;
+  height: 54px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.video-thumb {
+  width: 54px;
+  height: 54px;
+  border-radius: 6px;
+  background: #f5f7fa;
+  border: 1px solid #e4e7ed;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+  font-size: 24px;
+}
+.video-thumb.small {
+  width: 48px;
+  height: 48px;
+  font-size: 22px;
+}
+.asset-meta {
+  min-width: 0;
+}
+.asset-name {
+  font-weight: 500;
+  color: #303133;
+  max-width: 170px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+.asset-sub {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-top: 4px;
+}
+.asset-id {
+  color: #909399;
+  font-size: 12px;
+}
+.asset-file-info {
+  color: #606266;
+  font-size: 13px;
+  line-height: 1.6;
+}
+.text-muted {
+  color: #909399;
+}
+.asset-select-tip {
+  margin-bottom: 12px;
+}
+.form-tip {
+  width: 100%;
+  margin-top: 4px;
+  color: #909399;
+  font-size: 12px;
+  line-height: 1.5;
+}
+.play-status-alert {
+  margin-bottom: 12px;
+}
+.plan-preview {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+.preview-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.preview-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+}
+.preview-sub {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-top: 8px;
+  color: #909399;
+  font-size: 13px;
+}
+.preview-body {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 320px;
+  gap: 16px;
+}
+.preview-stage {
+  min-height: 420px;
+  background: #111827;
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+}
+.preview-media {
+  max-width: 100%;
+  max-height: 420px;
+}
+.preview-image {
+  object-fit: contain;
+}
+.preview-video {
+  width: 100%;
+  height: 420px;
+  background: #000;
+}
+.preview-empty {
+  color: #cbd5e1;
+  font-size: 14px;
+}
+.preview-list {
+  border: 1px solid #e4e7ed;
+  border-radius: 10px;
+  padding: 12px;
+  max-height: 420px;
+  overflow-y: auto;
+}
+.preview-list-title {
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 10px;
+}
+.preview-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px;
+  border-radius: 8px;
+  cursor: pointer;
+}
+.preview-item:hover {
+  background: #f5f7fa;
+}
+.preview-item.active {
+  background: #ecf5ff;
+}
+.preview-item-index {
+  width: 22px;
+  height: 22px;
+  border-radius: 50%;
+  background: #e4e7ed;
+  color: #606266;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
+}
+.preview-item.active .preview-item-index {
+  background: #409eff;
+  color: #fff;
+}
+.preview-item-thumb {
+  width: 44px;
+  height: 44px;
+  flex-shrink: 0;
+}
+.preview-item-meta {
+  min-width: 0;
+  flex: 1;
+}
+.preview-item-name {
+  font-size: 13px;
+  color: #303133;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.preview-item-desc {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-top: 4px;
+  color: #909399;
+  font-size: 12px;
+}
+.preview-actions {
+  display: flex;
+  justify-content: center;
+  gap: 12px;
+}
+.preview-main {
+  min-width: 0;
+}
+.preview-current-info {
+  margin-top: 10px;
+  padding: 10px 12px;
+  border: 1px solid #e4e7ed;
+  border-radius: 8px;
+  background: #f8fafc;
+}
+.preview-current-name {
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 6px;
+}
+.preview-current-desc {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
+  color: #606266;
+  font-size: 13px;
+}
+</style>

+ 23 - 26
迎宾巡逻安防机器人运维端Web管理系统详细设计开发文档_V2.1.html

@@ -78,7 +78,7 @@
   <div class="section" id="s5"><h2>5. 菜单结构设计</h2>
     <table><thead><tr><th>一级菜单</th><th>二级菜单</th><th>页面职责</th><th>一期优先级</th></tr></thead><tbody>
       <tr><td>首页</td><td>首页总览</td><td>展示机器人实时状态、摘要统计、异常告警与快捷操作入口。</td><td>P0</td></tr>
-      <tr><td rowspan="7">内容管理</td><td>欢迎语配置</td><td>维护机器人默认欢迎语和触发控制参数。</td><td>P0</td></tr><tr><td>问答库管理</td><td>维护 FAQ 问答数据,支持字典分类、相似问、导出;一期暂不支持导入。问答分类使用 RuoYi 字典,不单独建设问答分类管理菜单。</td><td>P0</td></tr><tr><td>素材管理</td><td>维护图片、视频素材。</td><td>P0</td></tr><tr><td>播放方案管理</td><td>维护素材播放编排关系、时长、顺序、默认方案。</td><td>P0</td></tr><tr><td>播报内容管理</td><td>维护可被播报任务引用的播报文本模板。</td><td>P0</td></tr><tr><td>播报任务管理</td><td>维护播报时间策略、频率、启停状态。</td><td>P0</td></tr><tr><td>展示主题配置</td><td>维护机器人对外展示界面的品牌与主题风格。</td><td>P1</td></tr>
+      <tr><td rowspan="7">内容管理</td><td>欢迎语配置</td><td>维护机器人默认欢迎语和触发控制参数。</td><td>P0</td></tr><tr><td>问答库管理</td><td>维护 FAQ 问答数据,支持字典分类、相似问、导出;一期暂不支持导入。问答分类使用 RuoYi 字典,不单独建设问答分类管理菜单。</td><td>P0</td></tr><tr><td>素材管理</td><td>维护图片、视频素材。</td><td>P0</td></tr><tr><td>播放方案管理</td><td>维护素材播放编排关系、时长、顺序和当前播放方案。</td><td>P0</td></tr><tr><td>播报内容管理</td><td>维护可被播报任务引用的播报文本模板。</td><td>P0</td></tr><tr><td>播报任务管理</td><td>维护播报时间策略、频率、启停状态。</td><td>P0</td></tr><tr><td>展示主题配置</td><td>维护机器人对外展示界面的品牌与主题风格。</td><td>P1</td></tr>
       <tr><td rowspan="3">访客管理</td><td>访客记录</td><td>查看访客登记记录,支持查询、详情、导出。</td><td>P1</td></tr><tr><td>预约记录</td><td>查看主控平台同步的预约记录。</td><td>P1</td></tr><tr><td>白名单管理</td><td>维护人员白名单数据,支持通过人脸照片、身份证号、手机号进行白名单匹配。</td><td>P1</td></tr>
       <tr><td rowspan="4">监控管理</td><td>视频预览</td><td>查看机器人摄像头实时画面。</td><td>P1</td></tr><tr><td>远程喊话</td><td>下发喊话内容、查看执行结果。</td><td>P1</td></tr><tr><td>对话日志</td><td>查看人机交互日志。</td><td>P1</td></tr><tr><td>安防告警日志</td><td>查看机器人侧安防告警记录。</td><td>P1</td></tr>
       <tr><td rowspan="6">运维管理</td><td>设备状态</td><td>查看详细设备状态、资源占用、模块状态。</td><td>P0</td></tr><tr><td>设备控制</td><td>提供一键充电、停止充电、重启、关机、服务重启等操作。</td><td>P0</td></tr><tr><td>运行参数配置</td><td>动态读取参数分组与字段,支持编辑与保存。</td><td>P0</td></tr><tr><td>系统诊断</td><td>查看诊断检查结果、自检结果与关键资源状态。</td><td>P1</td></tr><tr><td>日志中心</td><td>统一查看系统、设备、升级、操作等日志。</td><td>P0</td></tr><tr><td>软件版本 / OTA 升级</td><td>查看版本、上传安装包、执行升级、查看升级记录。</td><td>P0</td></tr>
@@ -102,7 +102,7 @@
         <tr><td rowspan="7">内容管理</td><td>欢迎语配置</td><td>定制开发</td><td>不建议生成前端列表页</td><td>欢迎语配置为单配置页,不是多条数据 CRUD 页面。数据库通过 config_key=default 定位默认配置;前端建议自定义表单页,直接加载和保存默认配置,不提供列表、新增、删除。</td></tr>
         <tr><td>问答库管理</td><td>RuoYi 主子表生成后定制</td><td>部分适合</td><td>可基于 robot_ops_faq、robot_ops_faq_similar 生成基础 CRUD;问题分类使用 RuoYi 字典 robot_faq_category,不单独生成问答分类管理页面;前端需将主子表明细表格调整为“相似问多行输入,一行一个”的交互方式;sortNo 作为保留字段,不在页面展示和编辑;启用/停用、导出、分类字典回显需按业务微调。一期暂不支持问答库导入,后续如运营需要批量维护再扩展。</td></tr>
         <tr><td>素材管理</td><td>RuoYi 生成后定制</td><td>部分适合</td><td>可基于 robot_ops_media_asset 生成基础列表、查询、详情、编辑、删除、导出接口和页面;当前前端已基于 RuoYi 接口和 /common/uploadMediaFile 上传接口完成定制。前端只允许用户维护素材名称、启用状态和备注,文件信息由上传接口返回或系统维护;缩略图展示、图片/视频预览、引用状态展示、删除引用保护提示均需定制。</td></tr>
-        <tr><td>播放方案管理</td><td>RuoYi 主子表生成后定制</td><td>部分适合</td><td>播放方案由主表 robot_ops_play_plan 和子表 robot_ops_play_plan_item 组成,适合先使用 RuoYi 主子表生成基础列表、表单、Controller、Service、Mapper,再进行定制。前端需将原始子表表格调整为“选择素材 + 素材编排”交互,支持素材选择、顺序调整、图片停留时长、视频默认播完切换、设为默认、启用/停用、复制方案和预览方案。默认方案唯一性、素材 quotedFlag 维护、删除默认方案限制等需由后端补充业务逻辑。</td></tr>
+        <tr><td>播放方案管理</td><td>RuoYi 主子表生成后定制</td><td>部分适合</td><td>播放方案由主表 robot_ops_play_plan 和子表 robot_ops_play_plan_item 组成,适合先使用 RuoYi 主子表生成基础列表、表单、Controller、Service、Mapper,再进行定制。前端需将原始子表表格调整为“选择素材 + 素材编排”交互,支持素材选择、顺序调整、图片停留时长、视频默认播完切换、播放状态切换、预览方案。播放状态 status=1 表示当前播放方案,status=0 表示备用方案;同一时间只允许一个播放方案处于当前播放状态,该规则需由后端事务控制。素材 quotedFlag 维护需由后端补充业务逻辑。</td></tr>
         <tr><td>播报内容管理</td><td>RuoYi 生成后微调</td><td>适合</td><td>典型单表 CRUD,可基于 robot_ops_broadcast_content 生成,再补充测试播报按钮。</td></tr>
         <tr><td>播报任务管理</td><td>RuoYi 生成后定制</td><td>部分适合</td><td>基础 CRUD 可生成;时间段、频率、循环规则、复制任务等需要定制表单校验和交互。</td></tr>
         <tr><td>展示主题配置</td><td>RuoYi 生成后定制</td><td>部分适合</td><td>基础 CRUD 可生成;Logo/背景上传、颜色选择器、主题预览、设为启用需要定制。</td></tr>
@@ -139,7 +139,7 @@
     <h4>6.3.1 欢迎语配置页面</h4><table><thead><tr><th>字段/功能</th><th>类型</th><th>详细设计</th></tr></thead><tbody><tr><td>页面目标</td><td>-</td><td>维护机器人默认欢迎语配置。该页面为单配置页,不提供列表、新增、删除;页面打开后直接加载 config_key=default 的默认配置,保存时更新该配置。</td></tr><tr><td>欢迎语文本(welcomeText)</td><td>textarea</td><td>欢迎语文本,最大 200 字。</td></tr><tr><td>语音播报(voiceEnabled)</td><td>switch</td><td>控制欢迎语触发时是否进行语音播报。启用后,机器人检测到访客时可语音播报欢迎语;关闭后,欢迎语可仅用于屏幕展示。</td></tr><tr><td>语音播报冷却时间(cooldownSeconds)</td><td>number</td><td>控制语音欢迎语的重复播报间隔,单位秒,默认 30。机器人语音播报欢迎语后,在冷却时间内再次检测到访客时,不重复语音播报,可仅进行屏幕展示。</td></tr><tr><td>启用欢迎语(status)</td><td>switch</td><td>控制欢迎语功能整体是否启用。停用后,机器人检测到访客时不触发欢迎语。</td></tr><tr><td>备注(remark)</td><td>input</td><td>备注说明。</td></tr><tr><td>保存</td><td>button</td><td>保存当前配置(更新 config_key=default 的配置)。</td></tr><tr><td>恢复默认</td><td>button</td><td>前端本地恢复系统默认欢迎语配置,不直接写入数据库;用户需再次点击“保存配置”后才更新 config_key=default 的配置。</td></tr><tr><td>测试播报</td><td>button</td><td>下发测试播报指令,用于验证当前欢迎语文本和语音播报配置。测试播报不新增或修改配置数据。</td></tr></tbody></table>
     <h4>6.3.2 问答库管理页面</h4>
 <table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody>
-<tr><td>页面目标</td><td>维护机器人 FAQ 问答内容,支持按问题分类管理标准问题、相似问和答案内容。页面基于 robot_ops_faq 主表和 robot_ops_faq_similar 相似问表实现,前端以一个"问答库管理"页面统一维护,不单独建设相似问管理菜单。</td></tr>
+<tr><td>页面目标</td><td>维护机器人 FAQ 问答内容,支持按问题分类管理标准问题、相似问和答案内容。页面基于 robot_ops_faq 主表和 robot_ops_faq_similar 相似问表实现,前端以一个“问答库管理”页面统一维护,不单独建设相似问管理菜单。</td></tr>
 <tr><td>查询条件</td><td>问题分类(categoryType,数据来源:RuoYi 字典 robot_faq_category)、标准问题关键字、启用状态。</td></tr>
 <tr><td>列表字段</td><td>问题分类、标准问题、相似问数量、答案摘要、启用状态、更新时间、操作。</td></tr>
 <tr><td>操作按钮</td><td>新增、编辑、删除、批量删除、启用/停用、导出。</td></tr>
@@ -161,7 +161,7 @@
 <tr><td>文件信息</td><td>fileUrl 为素材文件访问地址;thumbnailUrl 为缩略图地址,图片可等于 fileUrl,视频只有后端返回 thumbnailUrl 时才展示封面,否则前端显示默认视频图标;fileSize 为文件大小,单位字节;fileFormat 为文件格式/后缀;mimeType 为文件 MIME 类型;resolution 为分辨率;durationSeconds 为视频时长,图片素材为空。resolution、durationSeconds 允许为空。</td></tr>
 <tr><td>上传规则</td><td>图片支持 jpg/png/webp;视频支持 mp4;单文件大小默认上限 500MB。当前前端实际调用 /common/uploadMediaFile 上传素材文件,上传成功后回填 fileUrl、thumbnailUrl、fileSize、fileFormat、mimeType、durationSeconds、resolution、assetType 等字段。若部分字段为空,前端按默认图标或“-”兜底展示。</td></tr>
 <tr><td>引用状态</td><td>quotedFlag 表示素材是否被播放方案引用,由系统维护,前端只读展示,不允许编辑。删除素材时,后端必须检查播放方案明细表中的实际引用关系,不应仅依赖 quotedFlag。</td></tr>
-<tr><td>引用保护</td><td>被播放方案引用的素材不可直接删除,需先解除引用。接口应返回明确提示,例如“该素材已被 2 个播放方案引用,请先解除引用后再删除"。</td></tr>
+<tr><td>引用保护</td><td>被播放方案引用的素材不可直接删除,需先解除引用。接口应返回明确提示,例如“该素材已被 2 个播放方案引用,请先解除引用后再删除。</td></tr>
 <tr><td>预览规则</td><td>图片弹窗预览;视频弹窗播放器预览;视频无缩略图时,列表展示默认视频图标;无法播放时提示格式不支持。</td></tr>
 <tr><td>业务规则</td><td>启用状态为启用的素材可被新播放方案选择;停用素材不可被新播放方案选择,但历史播放方案已引用的停用素材仍需支持回显。</td></tr>
 </tbody></table>
@@ -169,20 +169,20 @@
 <table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody>
 <tr><td>页面目标</td><td>维护机器人屏幕播放使用的素材编排方案。一个播放方案由主表信息和多个素材明细组成,素材来源于素材管理模块。</td></tr>
 <tr><td>开发方式</td><td>基于 RuoYi 主子表生成基础页面和接口,主表 robot_ops_play_plan,子表 robot_ops_play_plan_item,关联字段 plan_id。生成后前端需要定制为“选择素材 + 素材编排”的交互方式。</td></tr>
-<tr><td>查询条件</td><td>方案名称、默认方案标识(defaultFlag)、启用状态(status)。</td></tr>
-<tr><td>列表字段</td><td>方案名称(planName)、素材数量(assetCount)、循环方式(loopMode)、默认方案标识(defaultFlag)、启用状态(status)、更新时间(updateTime)、操作。</td></tr>
-<tr><td>主表编辑字段</td><td>方案名称(planName)、循环方式(loopMode)、默认方案标识(defaultFlag)、启用状态(status)、备注(remark)。</td></tr>
+<tr><td>查询条件</td><td>方案名称、循环方式(loopMode)、播放状态(status)。</td></tr>
+<tr><td>列表字段</td><td>方案名称(planName)、循环方式(loopMode)、素材数量(assetCount)、播放状态(status)、更新时间(updateTime)、操作。</td></tr>
+<tr><td>主表编辑字段</td><td>方案名称(planName)、循环方式(loopMode)、播放状态(status)、备注(remark)。播放状态 status=1 表示当前播放,status=0 表示备用方案。</td></tr>
 <tr><td>素材明细字段</td><td>素材ID(assetId)、播放顺序(playOrder)、停留时长(staySeconds)、转场方式(transitionType,预留字段,默认 none)。素材名称、素材类型、文件地址、缩略图、视频时长、分辨率等展示字段不在明细表中冗余保存,由后端根据 assetId 关联 robot_ops_media_asset 返回。</td></tr>
 <tr><td>素材选择</td><td>新增素材明细时,通过“选择素材”弹窗从素材库选择启用状态的素材。前端保存时只提交 assetId、playOrder、staySeconds、transitionType 等播放编排字段;素材名称、素材类型、文件地址、缩略图、视频时长等信息由后端查询详情或预览时关联素材表返回。</td></tr>
 <tr><td>素材编排</td><td>播放方案明细支持上移、下移或拖拽排序。保存时根据当前顺序重新生成 playOrder。</td></tr>
 <tr><td>图片播放规则</td><td>当 assetType=image 时,staySeconds 必填,建议默认 10 秒,取值范围 1-3600 秒。</td></tr>
 <tr><td>视频播放规则</td><td>当 assetType=video 时,staySeconds 可为空,表示按视频自身播放。一期不做视频截断播放逻辑。</td></tr>
 <tr><td>转场方式</td><td>transitionType 作为预留字段,一期前端不展示、不编辑,默认值为 none。后续车端屏幕支持转场效果后,再开放配置,例如 none=无转场、fade=淡入淡出。</td></tr>
-<tr><td>操作按钮</td><td>新增、编辑、复制、删除、设为默认、启用/停用、预览。</td></tr>
-<tr><td>默认方案规则</td><td>同一时间仅允许一个播放方案 defaultFlag=1。只有启用状态的方案允许设为默认。设为默认时,后端应在事务中将其他方案 defaultFlag 置为 0,将当前方案 defaultFlag 置为 1。</td></tr>
-<tr><td>删除规则</td><td>默认方案不允许直接删除,需先设其他方案为默认或取消默认。删除方案时需要同步删除其素材明细,并重新计算相关素材的 quotedFlag。</td></tr>
+<tr><td>操作按钮</td><td>新增、编辑、删除、启用播放、设为备用、预览。</td></tr>
+<tr><td>播放状态规则</td><td>status=1 表示当前播放方案,status=0 表示备用方案。同一时间只允许一个播放方案 status=1。启用某个方案为当前播放时,后端应在事务中将其他方案 status 置为 0,将当前方案 status 置为 1。车端只读取 status=1 的播放方案。</td></tr>
+<tr><td>删除规则</td><td>删除方案时需要同步删除其素材明细,并重新计算相关素材的 quotedFlag。如删除当前播放方案,可能导致车端无可播放方案,建议后端根据实际业务策略进行限制或提示。</td></tr>
 <tr><td>素材引用规则</td><td>播放方案新增、编辑、删除后,后端需要重新计算相关素材的 quotedFlag。只要素材被任意播放方案明细引用,则 quotedFlag=1;不再被任何播放方案引用,则 quotedFlag=0。</td></tr>
-<tr><td>预览规则</td><td>预览方案时按 playOrder 顺序展示素材;图片按 staySeconds 停留;视频使用 fileUrl 播放,默认播完后切换到下一个素材。若视频时长为空,不影响预览。</td></tr>
+<tr><td>预览规则</td><td>一期前端已基于播放方案详情接口实现预览弹窗。点击"预览"后,前端调用详情接口获取方案信息和素材明细,按 playOrder 顺序展示播放清单;左侧展示当前素材的大图或视频播放器,右侧展示播放清单,支持上一个、下一个和点击清单切换。图片按 staySeconds 展示停留时长;视频使用 fileUrl 播放,默认播完后切换到下一个素材。若后续后端提供独立 preview 接口,可仅替换预览数据源,页面结构无需大改。</td></tr>
 <tr><td>业务规则</td><td>新增/编辑时至少选择一个素材。停用素材不可被新播放方案选择,但历史方案中已引用的停用素材仍需支持回显。</td></tr>
 <tr><td>素材信息回显</td><td>播放方案详情、编辑回显和预览接口需要关联 robot_ops_media_asset 返回素材展示信息,包括 assetName、assetType、fileUrl、thumbnailUrl、durationSeconds、resolution、assetStatus 等。明细表本身不保存这些快照字段。</td></tr>
 </tbody></table>
@@ -208,7 +208,7 @@
 <tr><td>业务规则</td><td>访客记录代表已经完成登记的到访记录,不设置登记结果字段。登记失败、身份证读取失败、扫码失败、预约匹配失败等过程异常,不进入访客记录,应进入日志中心或后续扩展的登记异常日志。</td></tr>
 <tr><td>与白名单关系</td><td>访客记录与白名单识别记录分开管理。命中白名单不作为访客登记结果;白名单命中属于识别或通行逻辑,可后续在识别日志、通行记录或对话/安防日志中体现。</td></tr>
 <tr><td>与预约记录关系</td><td>预约记录由主控平台同步,表示计划来访;访客到现场后通过机器人屏幕或扫码 H5 完成登记确认,并生成访客记录。</td></tr>
-<tr><td>导出字段</td><td>导出列表主要字段;访客照片建议导出"有照片/无照片"或照片链接,不直接导出图片文件。</td></tr>
+<tr><td>导出字段</td><td>导出列表主要字段;访客照片建议导出“有照片/无照片”或照片链接,不直接导出图片文件。</td></tr>
 </tbody></table>
     <h4>6.4.2 预约记录页面</h4><table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody><tr><td>查询条件</td><td>预约单号、访客姓名、手机号、预约状态、预约时间范围。</td></tr><tr><td>列表字段</td><td>预约单号、访客姓名、手机号、被访人、预约时间、状态、同步时间、操作。</td></tr><tr><td>详情字段</td><td>预约单号(appointmentNo)、访客姓名(visitorName)、手机号(mobile)、被访人(visitedPerson)、预约时间(appointmentTime)、预约状态(status)、同步时间(syncTime)、来源平台(sourcePlatform)、备注(remark)。</td></tr><tr><td>操作按钮</td><td>查看详情。</td></tr><tr><td>数据来源</td><td>主控平台同步;本地端仅展示,不发起预约流程。</td></tr></tbody></table>
     <h4>6.4.3 白名单管理页面</h4>
@@ -413,14 +413,13 @@
 
     <h4>7.4.4 播放方案接口</h4>
     <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求/返回字段</th><th>数据库表</th></tr></thead><tbody>
-      <tr><td>/robot-ops/content/play-plan/page</td><td>GET</td><td>播放方案分页</td><td>请求:方案名称(planName)、默认方案标识(defaultFlag)、启用状态(status)、pageNum、pageSize;返回:id、planName、assetCount、loopMode、defaultFlag、status、updateTime</td><td>robot_ops_play_plan</td></tr>
-      <tr><td>/robot-ops/content/play-plan/{id}</td><td>GET</td><td>播放方案详情</td><td>返回:id、planName、assetCount、loopMode、defaultFlag、status、remark、itemList;itemList 从明细表返回 assetId、playOrder、staySeconds、transitionType,并通过关联 robot_ops_media_asset 补充 assetName、assetType、fileUrl、thumbnailUrl、durationSeconds、resolution、assetStatus 等展示字段。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
-      <tr><td>/robot-ops/content/play-plan</td><td>POST</td><td>新增播放方案</td><td>请求:planName、loopMode、defaultFlag、status、remark、itemList。itemList 只需提交 assetId、playOrder、staySeconds、transitionType;其中 transitionType 为预留字段,一期可不传,由后端默认写入 none;新增时至少包含一个素材明细;assetCount 由后端根据 itemList 数量计算。后端保存时需校验 assetId 对应素材是否存在且可用。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
-      <tr><td>/robot-ops/content/play-plan</td><td>PUT</td><td>编辑播放方案</td><td>请求:id、planName、loopMode、defaultFlag、status、remark、itemList。itemList 只需提交 assetId、playOrder、staySeconds、transitionType;其中 transitionType 为预留字段,一期可不传,由后端默认写入 none;保存时以后端接收的 itemList 为准重建或更新明细;assetCount 由后端重新计算。后端保存时需校验 assetId 对应素材是否存在。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
-      <tr><td>/robot-ops/content/play-plan/{id}</td><td>DELETE</td><td>删除播放方案</td><td>方案ID(id)。默认方案不允许直接删除;删除时同步删除明细并重新计算相关素材 quotedFlag。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
-      <tr><td>/robot-ops/content/play-plan/{id}/set-default</td><td>POST</td><td>设为默认方案</td><td>方案ID(id)。仅启用状态方案允许设为默认;后端事务中将其他方案 defaultFlag 置为 0,当前方案置为 1。</td><td>robot_ops_play_plan</td></tr>
-      <tr><td>/robot-ops/content/play-plan/{id}/copy</td><td>POST</td><td>复制播放方案</td><td>方案ID(id)。复制主表和明细表,复制后的方案 defaultFlag 默认 0,方案名称建议追加“副本”。</td><td>robot_ops_play_plan、robot_ops_play_plan_item</td></tr>
-      <tr><td>/robot-ops/content/play-plan/{id}/preview</td><td>GET</td><td>预览播放方案</td><td>方案ID(id)。返回方案基本信息和按 playOrder 排序后的 itemList;itemList 需关联素材表返回 assetName、assetType、fileUrl、thumbnailUrl、durationSeconds、resolution、assetStatus 等字段,用于前端预览。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
+      <tr><td>/robot-ops/content/play-plan/page</td><td>GET</td><td>播放方案分页</td><td>请求:方案名称(planName)、循环方式(loopMode)、播放状态(status)、pageNum、pageSize;返回:id、planName、assetCount、loopMode、status、updateTime</td><td>robot_ops_play_plan</td></tr>
+      <tr><td>/robot-ops/content/play-plan/{id}</td><td>GET</td><td>播放方案详情</td><td>返回:id、planName、assetCount、loopMode、status、remark、itemList;itemList 从明细表返回 assetId、playOrder、staySeconds、transitionType,并通过关联 robot_ops_media_asset 补充 assetName、assetType、fileUrl、thumbnailUrl、durationSeconds、resolution、assetStatus 等展示字段。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
+      <tr><td>/robot-ops/content/play-plan</td><td>POST</td><td>新增播放方案</td><td>请求:planName、loopMode、status、remark、itemList。itemList 只需提交 assetId、playOrder、staySeconds、transitionType;其中 transitionType 为预留字段,一期可不传,由后端默认写入 none;新增时至少包含一个素材明细;assetCount 由后端根据 itemList 数量计算。后端保存时需校验 assetId 对应素材是否存在且可用。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
+      <tr><td>/robot-ops/content/play-plan</td><td>PUT</td><td>编辑播放方案</td><td>请求:id、planName、loopMode、status、remark、itemList。itemList 只需提交 assetId、playOrder、staySeconds、transitionType;其中 transitionType 为预留字段,一期可不传,由后端默认写入 none;保存时以后端接收的 itemList 为准重建或更新明细;assetCount 由后端重新计算。后端保存时需校验 assetId 对应素材是否存在。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
+      <tr><td>/robot-ops/content/play-plan/{id}</td><td>DELETE</td><td>删除播放方案</td><td>方案ID(id)。删除时同步删除明细并重新计算相关素材 quotedFlag。如删除当前播放方案,可能导致车端无可播放方案,建议后端根据实际业务策略进行限制或提示。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
+      <tr><td>/robot-ops/content/play-plan/{id}/status</td><td>POST</td><td>切换播放状态</td><td>方案ID(id)、播放状态(status)。当 status=1 时,后端事务中将其他方案 status 置为 0,将当前方案 status 置为 1;当 status=0 时,需根据业务策略判断是否允许将当前播放方案设为备用。</td><td>robot_ops_play_plan</td></tr>
+      <tr><td>/robot-ops/content/play-plan/{id}/preview</td><td>GET</td><td>预览播放方案(后续规划)</td><td>方案ID(id)。返回方案基本信息和按 playOrder 排序后的 itemList;itemList 需关联素材表返回 assetName、assetType、fileUrl、thumbnailUrl、durationSeconds、resolution、assetStatus 等字段,用于前端预览。当前前端一期已先基于详情接口 getPlan(id) 实现预览弹窗,后续可切换为该接口。</td><td>robot_ops_play_plan、robot_ops_play_plan_item、robot_ops_media_asset</td></tr>
     </tbody></table>
     <div class="code">新增/编辑播放方案请求 itemList 示例:
 [
@@ -471,7 +470,7 @@
     <div class="note">播放方案明细表一期不保存素材快照字段,只保存 asset_id、play_order、stay_seconds、transition_type 等编排字段。播放方案详情、编辑回显和预览接口应根据 asset_id 关联 robot_ops_media_asset 返回 assetName、assetType、fileUrl、thumbnailUrl、durationSeconds、resolution、assetStatus 等展示字段。</div>
     <div class="note">保存播放方案时,后端应根据 asset_id 校验素材是否存在。新增方案和新增明细时,仅允许选择启用状态的素材;历史方案中已引用的停用素材仍需支持详情回显和编辑回显,并提示素材已停用。</div>
     <div class="note">新增、编辑、删除播放方案后,需要重新计算相关素材的 quoted_flag,确保素材管理列表中的引用状态准确。</div>
-    <div class="warn">默认方案唯一性由后端事务控制。设为默认时需将其他方案 default_flag 更新为 0,将当前方案 default_flag 更新为 1;默认方案不允许直接删除。</div>
+    <div class="warn">播放状态唯一性由后端事务控制。status=1 表示当前播放方案,同一时间只允许一个播放方案 status=1。启用某个方案为当前播放时,后端需将其他方案 status 更新为 0,将当前方案 status 更新为 1。车端只读取 status=1 的播放方案。</div>
     <div class="note">transitionType / transition_type 为播放转场预留字段,一期前端不展示、不编辑。保存播放方案时,如前端未传 transitionType,后端默认写入 none。后续车端屏幕支持转场效果后,再开放 fade 等配置项。</div>
     <div class="note">字典建议:loopMode 可使用 RuoYi 字典 play_plan_loop_mode,字典项:loop=循环播放,once=播放一次。transitionType 为预留字段,一期不开放配置。</div>
 
@@ -735,21 +734,19 @@ VALUES
   `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
   `plan_name` VARCHAR(100) NOT NULL COMMENT '播放方案名称',
   `loop_mode` VARCHAR(20) NOT NULL DEFAULT 'loop' COMMENT '循环方式:loop循环播放,once播放一次',
-  `default_flag` CHAR(1) NOT NULL DEFAULT '0' COMMENT '默认方案标识:0否,1是',
   `asset_count` INT NOT NULL DEFAULT 0 COMMENT '素材数量',
-  `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '启用状态:0停用,1启用',
+  `status` CHAR(1) NOT NULL DEFAULT '0' COMMENT '播放状态:0备用方案,1当前播放',
   `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
   `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
   `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
   `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
   `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   PRIMARY KEY (`id`),
-  KEY `idx_robot_ops_play_plan_default_flag` (`default_flag`),
   KEY `idx_robot_ops_play_plan_status` (`status`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='播放方案主表';</div>
-    <div class="note">说明:default_flag 替代 is_default,用于避免 Java/RuoYi 字段映射时与布尔 getter 命名产生混淆。前端字段为 defaultFlag。</div>
+    <div class="note">说明:status 表示播放状态,0=备用方案,1=当前播放。车端只读取 status=1 的播放方案。</div>
     <div class="note">说明:asset_count 为素材数量,由后端根据播放方案明细 itemList 数量维护,前端不允许手动填写。</div>
-    <div class="note">说明:同一时间仅允许一个播放方案 default_flag=1,唯一性由后端事务控制,不依赖数据库唯一索引。</div>
+    <div class="note">说明:同一时间仅允许一个播放方案 status=1,唯一性由后端事务控制,不依赖数据库唯一索引。</div>
 
     <h4>8.2.6 播放方案素材明细表 robot_ops_play_plan_item</h4>
     <div class="code">CREATE TABLE `robot_ops_play_plan_item` (