Browse Source

优化素材管理功能模块

yawuga 2 tuần trước cách đây
mục cha
commit
6c73a8bcd3

+ 430 - 118
src/views/base/mediAasset/index.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="app-container">
-    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px">
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="90px">
       <el-form-item label="素材名称" prop="assetName">
         <el-input
           v-model="queryParams.assetName"
@@ -15,16 +15,31 @@
             v-for="dict in media_asset_type"
             :key="dict.value"
             :label="dict.label"
-            :value="dict.value"
+            :value="String(dict.value)"
           />
         </el-select>
       </el-form-item>
-      <el-form-item label="是否被播放方案引用:0否,1是,由系统维护" prop="quotedFlag">
-        <el-input
-          v-model="queryParams.quotedFlag"
-          placeholder="请输入是否被播放方案引用:0否,1是,由系统维护"
-          clearable
-          @keyup.enter="handleQuery"
+      <el-form-item label="引用状态" prop="quotedFlag">
+        <el-select v-model="queryParams.quotedFlag" placeholder="请选择引用状态" clearable style="width: 160px">
+          <el-option label="未引用" value="0" />
+          <el-option label="已引用" value="1" />
+        </el-select>
+      </el-form-item>
+      <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 label="上传时间">
+        <el-date-picker
+          v-model="dateRange"
+          type="daterange"
+          range-separator="-"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          value-format="YYYY-MM-DD"
+          style="width: 240px"
         />
       </el-form-item>
       <el-form-item>
@@ -38,20 +53,10 @@
         <el-button
           type="primary"
           plain
-          icon="Plus"
+          icon="Upload"
           @click="handleAdd"
           v-hasPermi="['base:mediAasset:add']"
-        >新增</el-button>
-      </el-col>
-      <el-col :span="1.5">
-        <el-button
-          type="success"
-          plain
-          icon="Edit"
-          :disabled="single"
-          @click="handleUpdate"
-          v-hasPermi="['base:mediAasset:edit']"
-        >修改</el-button>
+        >上传素材</el-button>
       </el-col>
       <el-col :span="1.5">
         <el-button
@@ -77,35 +82,92 @@
 
     <el-table v-loading="loading" :data="mediAassetList" @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="assetName" />
-      <el-table-column label="素材类型" align="center" prop="assetType">
+      <el-table-column label="缩略图" align="center" width="100">
+        <template #default="scope">
+          <div class="asset-thumb" @click="handlePreview(scope.row)">
+            <image-preview
+              v-if="getPreviewImage(scope.row)"
+              :src="getPreviewImage(scope.row)"
+              :width="54"
+              :height="54"
+            />
+            <div v-else class="video-thumb">
+              <el-icon><VideoPlay /></el-icon>
+            </div>
+          </div>
+        </template>
+      </el-table-column>
+      <el-table-column label="素材名称" align="left" prop="assetName" min-width="180" show-overflow-tooltip />
+      <el-table-column label="素材类型" align="center" prop="assetType" width="100">
         <template #default="scope">
-          <dict-tag :options="media_asset_type" :value="scope.row.assetType"/>
+          <dict-tag :options="media_asset_type" :value="scope.row.assetType != null ? String(scope.row.assetType) : ''" />
         </template>
       </el-table-column>
-      <el-table-column label="缩略图地址" align="center" prop="thumbnailUrl" />
-      <el-table-column label="文件大小" align="center" prop="fileSize" />
-      <el-table-column label="文件格式" align="center" prop="fileFormat" />
-      <el-table-column label="文件MIME类型,如image/jpeg、video/mp4" align="center" prop="mimeType" />
-      <el-table-column label="视频时长,单位秒;图片为空" align="center" prop="durationSeconds" />
-      <el-table-column label="分辨率,如1920x1080" align="center" prop="resolution" />
-      <el-table-column label="启用状态:0停用,1启用" align="center" prop="status" />
-      <el-table-column label="是否被播放方案引用:0否,1是,由系统维护" align="center" prop="quotedFlag" />
-      <el-table-column label="备注" align="center" prop="remark" />
-      <el-table-column label="创建时间/上传时间" align="center" prop="createTime" width="180">
+      <el-table-column label="文件格式" align="center" prop="fileFormat" width="90">
         <template #default="scope">
-          <span>{{ parseTime(scope.row.createTime, '{y}-{m}-{d}') }}</span>
+          <el-tag v-if="scope.row.fileFormat" type="info">{{ scope.row.fileFormat }}</el-tag>
+          <span v-else>-</span>
         </template>
       </el-table-column>
-      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+      <el-table-column label="文件大小" align="center" prop="fileSize" width="110">
         <template #default="scope">
-          <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['base:mediAasset:edit']">修改</el-button>
+          <span>{{ formatFileSize(scope.row.fileSize) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="视频时长" align="center" prop="durationSeconds" width="100">
+        <template #default="scope">
+          <span>{{ formatDuration(scope.row.durationSeconds) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="分辨率" align="center" prop="resolution" width="120">
+        <template #default="scope">
+          <span>{{ scope.row.resolution || '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="引用状态" align="center" prop="quotedFlag" width="100">
+        <template #default="scope">
+          <el-tag v-if="String(scope.row.quotedFlag) === '1'" type="warning">已引用</el-tag>
+          <el-tag v-else-if="String(scope.row.quotedFlag) === '0'" type="info">未引用</el-tag>
+          <el-tag v-else type="info">-</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="启用状态" align="center" prop="status" width="100">
+        <template #default="scope">
+          <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>
+          <el-tag v-else type="info">-</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="上传时间" align="center" prop="createTime" width="170">
+        <template #default="scope">
+          <span>{{ parseTime(scope.row.createTime, '{y}-{m}-{d} {h}:{i}:{s}') || '-' }}</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="primary"
+            icon="CircleCheck"
+            @click="handleStatusChange(scope.row, '1')"
+            v-hasPermi="['base:mediAasset: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:mediAasset:edit']"
+          >停用</el-button>
+          <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['base:mediAasset:edit']">编辑</el-button>
           <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['base:mediAasset:remove']">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
-    
+
     <pagination
       v-show="total>0"
       :total="total"
@@ -115,64 +177,92 @@
     />
 
     <!-- 添加或修改素材资源对话框 -->
-    <el-dialog :title="title" v-model="open" width="500px" append-to-body>
-      <el-form ref="mediAassetRef" :model="form" :rules="rules" label-width="100px">
+    <el-dialog :title="title" v-model="open" width="760px" append-to-body>
+      <el-form ref="mediAassetRef" :model="form" :rules="rules" label-width="110px">
         <el-row>
           <el-col :span="24">
             <el-form-item label="素材名称" prop="assetName">
-              <el-input v-model="form.assetName" placeholder="请输入素材名称" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="24">
-            <el-form-item label="素材类型" prop="assetType">
-              <el-select v-model="form.assetType" placeholder="请选择素材类型">
-                <el-option
-                  v-for="dict in media_asset_type"
-                  :key="dict.value"
-                  :label="dict.label"
-                  :value="dict.value"
-                ></el-option>
-              </el-select>
-            </el-form-item>
-          </el-col>
-          <el-col :span="24">
-            <el-form-item label="素材文件地址" prop="fileUrl">
-              <el-input v-model="form.fileUrl" type="textarea" placeholder="请输入内容" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="24">
-            <el-form-item label="缩略图地址" prop="thumbnailUrl">
-              <el-input v-model="form.thumbnailUrl" type="textarea" placeholder="请输入内容" />
+              <el-input
+                v-model="form.assetName"
+                maxlength="100"
+                show-word-limit
+                placeholder="请输入素材名称"
+              />
             </el-form-item>
           </el-col>
           <el-col :span="24">
-            <el-form-item label="文件大小" prop="fileSize">
-              <el-input v-model="form.fileSize" placeholder="请输入文件大小" />
+            <el-form-item label="素材文件" prop="fileUrl">
+              <el-alert
+                v-if="form.id"
+                title="当前为编辑已有素材,可只修改素材名称、启用状态和备注;如需替换文件,再重新上传素材文件。"
+                type="info"
+                show-icon
+                :closable="false"
+                class="upload-edit-tip"
+              />
+              <el-upload
+                class="asset-uploader"
+                drag
+                :action="uploadUrl"
+                :headers="uploadHeaders"
+                :show-file-list="false"
+                :before-upload="beforeUpload"
+                :on-success="handleUploadSuccess"
+                :on-error="handleUploadError"
+              >
+                <el-icon class="el-icon--upload"><UploadFilled /></el-icon>
+                <div class="el-upload__text">拖拽文件到此处,或 <em>点击上传</em></div>
+                <template #tip>
+                  <div class="el-upload__tip">
+                    {{ form.id ? '如需替换素材文件,请重新上传;不上传则保留原文件。' : '支持 jpg/png/webp 图片和 mp4 视频,文件信息由系统自动解析。' }}
+                    <span class="upload-api-tip">当前上传功能需后端完成 /base/mediAasset/upload 接口后可用。</span>
+                  </div>
+                </template>
+              </el-upload>
             </el-form-item>
           </el-col>
-          <el-col :span="24">
-            <el-form-item label="文件格式" prop="fileFormat">
-              <el-input v-model="form.fileFormat" placeholder="请输入文件格式" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="24">
-            <el-form-item label="视频时长,单位秒;图片为空" prop="durationSeconds">
-              <el-input v-model="form.durationSeconds" placeholder="请输入视频时长,单位秒;图片为空" />
-            </el-form-item>
+          <el-col :span="24" v-if="form.fileUrl">
+            <el-descriptions title="文件信息" :column="2" border class="file-info">
+              <el-descriptions-item label="素材类型">
+                <dict-tag :options="media_asset_type" :value="form.assetType != null ? String(form.assetType) : ''" />
+              </el-descriptions-item>
+              <el-descriptions-item label="文件格式">{{ form.fileFormat || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="文件大小">{{ formatFileSize(form.fileSize) }}</el-descriptions-item>
+              <el-descriptions-item label="MIME 类型">{{ form.mimeType || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="视频时长">{{ formatDuration(form.durationSeconds) }}</el-descriptions-item>
+              <el-descriptions-item label="分辨率">{{ form.resolution || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="文件地址" :span="2">
+                <el-link
+                  v-if="form.fileUrl"
+                  type="primary"
+                  :href="form.fileUrl"
+                  target="_blank"
+                  class="file-url-link"
+                >
+                  {{ form.fileUrl }}
+                </el-link>
+                <span v-else>-</span>
+              </el-descriptions-item>
+            </el-descriptions>
           </el-col>
           <el-col :span="24">
-            <el-form-item label="分辨率,如1920x1080" prop="resolution">
-              <el-input v-model="form.resolution" placeholder="请输入分辨率,如1920x1080" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="24">
-            <el-form-item label="是否被播放方案引用:0否,1是,由系统维护" prop="quotedFlag">
-              <el-input v-model="form.quotedFlag" placeholder="请输入是否被播放方案引用:0否,1是,由系统维护" />
+            <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>
             </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>
@@ -184,11 +274,30 @@
         </div>
       </template>
     </el-dialog>
+
+    <!-- 素材预览 -->
+    <el-dialog title="素材预览" v-model="previewOpen" width="820px" append-to-body @closed="previewAsset = {}">
+      <div class="preview-box">
+        <img
+          v-if="previewAsset.assetType === 'image'"
+          :src="previewAsset.fileUrl"
+          class="preview-image"
+        />
+        <video
+          v-else-if="previewAsset.assetType === 'video'"
+          :src="previewAsset.fileUrl"
+          class="preview-video"
+          controls
+        />
+        <el-empty v-else description="暂无可预览内容" />
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script setup name="MediAasset">
 import { listMediAasset, getMediAasset, delMediAasset, addMediAasset, updateMediAasset } from "@/api/base/mediAasset"
+import useUserStore from '@/store/modules/user'
 
 const { proxy } = getCurrentInstance()
 const { media_asset_type } = useDict('media_asset_type')
@@ -198,11 +307,21 @@ const open = ref(false)
 const loading = ref(true)
 const showSearch = ref(true)
 const ids = ref([])
+const selectedRows = ref([])
 const single = ref(true)
 const multiple = ref(true)
 const total = ref(0)
 const title = ref("")
 
+const uploadUrl = ref(import.meta.env.VITE_APP_BASE_API + '/base/mediAasset/upload')
+const uploadHeaders = ref({
+  Authorization: 'Bearer ' + useUserStore()?.token
+})
+
+const previewOpen = ref(false)
+const previewAsset = ref({})
+const dateRange = ref([])
+
 const data = reactive({
   form: {},
   queryParams: {
@@ -210,48 +329,57 @@ const data = reactive({
     pageSize: 10,
     assetName: undefined,
     assetType: undefined,
-    mimeType: undefined,
-    status: undefined,
     quotedFlag: undefined,
+    status: undefined,
+    params: {}
   },
   rules: {
     assetName: [
-      { required: true, message: "素材名称不能为空", trigger: "blur" }
-    ],
-    assetType: [
-      { required: true, message: "素材类型不能为空", trigger: "change" }
+      { required: true, message: "素材名称不能为空", trigger: "blur" },
+      { max: 100, message: "素材名称不能超过 100 字", trigger: "blur" }
     ],
     fileUrl: [
-      { required: true, message: "素材文件地址不能为空", trigger: "blur" }
+      { required: true, message: "请先上传素材文件", trigger: "change" }
     ],
     status: [
-      { required: true, message: "启用状态:0停用,1启用不能为空", trigger: "change" }
-    ],
-    quotedFlag: [
-      { required: true, message: "是否被播放方案引用:0否,1是,由系统维护不能为空", trigger: "blur" }
+      { required: true, message: "请选择启用状态", trigger: "change" }
     ],
+    remark: [
+      { max: 500, message: "备注不能超过 500 字", trigger: "blur" }
+    ]
   }
 })
 
 const { queryParams, form, rules } = toRefs(data)
 
-/** 查询素材资源列表 */
 function getList() {
   loading.value = true
-  listMediAasset(queryParams.value).then(response => {
-    mediAassetList.value = response.rows
-    total.value = response.total
+  const params = {
+    ...queryParams.value,
+    params: {
+      ...(queryParams.value.params || {})
+    }
+  }
+  if (dateRange.value && dateRange.value.length === 2) {
+    params.params.beginCreateTime = dateRange.value[0]
+    params.params.endCreateTime = dateRange.value[1]
+  }
+  listMediAasset(params).then(response => {
+    mediAassetList.value = response.rows || []
+    total.value = response.total || 0
+  }).catch(() => {
+    mediAassetList.value = []
+    total.value = 0
+  }).finally(() => {
     loading.value = false
   })
 }
 
-/** 取消按钮 */
 function cancel() {
   open.value = false
   reset()
 }
 
-/** 表单重置 */
 function reset() {
   form.value = {
     id: null,
@@ -264,8 +392,8 @@ function reset() {
     mimeType: null,
     durationSeconds: null,
     resolution: null,
-    status: null,
-    quotedFlag: null,
+    status: '1',
+    quotedFlag: '0',
     remark: null,
     createBy: null,
     createTime: null,
@@ -275,55 +403,72 @@ function reset() {
   proxy.resetForm("mediAassetRef")
 }
 
-/** 搜索按钮操作 */
 function handleQuery() {
   queryParams.value.pageNum = 1
   getList()
 }
 
-/** 重置按钮操作 */
 function resetQuery() {
+  dateRange.value = []
   proxy.resetForm("queryRef")
   handleQuery()
 }
 
-/** 多选框选中数据 */
 function handleSelectionChange(selection) {
+  selectedRows.value = selection
   ids.value = selection.map(item => item.id)
   single.value = selection.length != 1
   multiple.value = !selection.length
 }
 
-/** 新增按钮操作 */
 function handleAdd() {
   reset()
   open.value = true
-  title.value = "添加素材资源"
+  title.value = "上传素材"
 }
 
-/** 修改按钮操作 */
 function handleUpdate(row) {
   reset()
   const _id = row.id || ids.value
   getMediAasset(_id).then(response => {
-    form.value = response.data
+    const data = response.data || {}
+    form.value = {
+      ...data,
+      assetType: data.assetType != null ? String(data.assetType) : null,
+      status: data.status != null ? String(data.status) : '1',
+      quotedFlag: data.quotedFlag != null ? String(data.quotedFlag) : '0'
+    }
     open.value = true
-    title.value = "修改素材资源"
+    title.value = "编辑素材"
   })
 }
 
-/** 提交按钮 */
 function submitForm() {
   proxy.$refs["mediAassetRef"].validate(valid => {
     if (valid) {
+      const payload = {
+        id: form.value.id,
+        assetName: form.value.assetName,
+        assetType: form.value.assetType,
+        fileUrl: form.value.fileUrl,
+        thumbnailUrl: form.value.thumbnailUrl,
+        fileSize: form.value.fileSize,
+        fileFormat: form.value.fileFormat,
+        mimeType: form.value.mimeType,
+        durationSeconds: form.value.durationSeconds,
+        resolution: form.value.resolution,
+        status: String(form.value.status),
+        quotedFlag: form.value.quotedFlag || '0',
+        remark: form.value.remark
+      }
       if (form.value.id != null) {
-        updateMediAasset(form.value).then(() => {
+        updateMediAasset(payload).then(() => {
           proxy.$modal.msgSuccess("修改成功")
           open.value = false
           getList()
         })
       } else {
-        addMediAasset(form.value).then(() => {
+        addMediAasset(payload).then(() => {
           proxy.$modal.msgSuccess("新增成功")
           open.value = false
           getList()
@@ -333,10 +478,15 @@ function submitForm() {
   })
 }
 
-/** 删除按钮操作 */
 function handleDelete(row) {
   const _ids = row.id || ids.value
-  proxy.$modal.confirm('是否确认删除素材资源编号为"' + _ids + '"的数据项?').then(function() {
+  let confirmMsg = '确认删除选中的素材吗?如素材已被播放方案引用,将无法删除。'
+  if (row && String(row.quotedFlag) === '1') {
+    confirmMsg = '该素材已被播放方案引用,删除可能会失败。确认继续删除吗?'
+  } else if (!row && selectedRows.value.some(item => String(item.quotedFlag) === '1')) {
+    confirmMsg = '选中的素材中包含已被播放方案引用的素材,删除可能会失败。确认继续删除吗?'
+  }
+  proxy.$modal.confirm(confirmMsg).then(function() {
     return delMediAasset(_ids)
   }).then(() => {
     getList()
@@ -344,12 +494,174 @@ function handleDelete(row) {
   }).catch(() => {})
 }
 
-/** 导出按钮操作 */
 function handleExport() {
-  proxy.download('base/mediAasset/export', {
-    ...queryParams.value
-  }, `mediAasset_${new Date().getTime()}.xlsx`)
+  const params = {
+    ...queryParams.value,
+    params: {
+      ...(queryParams.value.params || {})
+    }
+  }
+  if (dateRange.value && dateRange.value.length === 2) {
+    params.params.beginCreateTime = dateRange.value[0]
+    params.params.endCreateTime = dateRange.value[1]
+  }
+  proxy.download('base/mediAasset/export', params, `素材管理_${new Date().getTime()}.xlsx`)
+}
+
+function getPreviewImage(row) {
+  if (row.thumbnailUrl) return row.thumbnailUrl
+  if (String(row.assetType) === 'image' && row.fileUrl) return row.fileUrl
+  return ''
+}
+
+function formatFileSize(size) {
+  const value = Number(size)
+  if (!value) return '-'
+  if (value < 1024) return value + ' B'
+  if (value < 1024 * 1024) return (value / 1024).toFixed(1) + ' KB'
+  if (value < 1024 * 1024 * 1024) return (value / 1024 / 1024).toFixed(1) + ' MB'
+  return (value / 1024 / 1024 / 1024).toFixed(1) + ' GB'
+}
+
+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 beforeUpload(file) {
+  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4']
+  const isAllowed = allowedTypes.includes(file.type)
+  const isLt500M = file.size / 1024 / 1024 < 500
+  if (!isAllowed) {
+    proxy.$modal.msgError('仅支持 jpg、png、webp 图片和 mp4 视频')
+    return false
+  }
+  if (!isLt500M) {
+    proxy.$modal.msgError('素材文件大小不能超过 500MB')
+    return false
+  }
+  return true
+}
+
+function handleUploadSuccess(response) {
+  const data = response.data || response || {}
+  const isSuccess = response.code === 200 || response.code === undefined
+  if (!isSuccess) {
+    proxy.$modal.msgError(response.msg || '上传失败')
+    return
+  }
+  form.value.assetName = form.value.assetName || data.assetName || data.originalFilename || data.fileName || data.newFileName || ''
+  form.value.assetType = data.assetType || form.value.assetType
+  form.value.fileUrl = data.fileUrl || data.url || form.value.fileUrl
+  form.value.thumbnailUrl = data.thumbnailUrl || data.fileUrl || data.url || form.value.thumbnailUrl
+  form.value.fileSize = data.fileSize || form.value.fileSize
+  form.value.fileFormat = data.fileFormat || form.value.fileFormat
+  form.value.mimeType = data.mimeType || form.value.mimeType
+  form.value.durationSeconds = data.durationSeconds || form.value.durationSeconds
+  form.value.resolution = data.resolution || form.value.resolution
+  proxy.$modal.msgSuccess('上传成功')
+  proxy.$refs["mediAassetRef"]?.validateField?.("fileUrl")
+}
+
+function handleUploadError() {
+  proxy.$modal.msgError('素材上传接口暂不可用,请后端完成 /base/mediAasset/upload 后再联调')
+}
+
+function handlePreview(row) {
+  if (!row.fileUrl) {
+    proxy.$modal.msgWarning('暂无可预览文件')
+    return
+  }
+  if (!['image', 'video'].includes(String(row.assetType))) {
+    proxy.$modal.msgWarning('暂不支持该素材类型预览')
+    return
+  }
+  previewAsset.value = {
+    ...row,
+    assetType: String(row.assetType || '')
+  }
+  previewOpen.value = true
+}
+
+function handleStatusChange(row, status) {
+  const actionText = status === '1' ? '启用' : '停用'
+  proxy.$modal.confirm('确认' + actionText + '素材“' + row.assetName + '”吗?').then(() => {
+    return updateMediAasset({
+      id: row.id,
+      assetName: row.assetName,
+      assetType: row.assetType,
+      fileUrl: row.fileUrl,
+      thumbnailUrl: row.thumbnailUrl,
+      fileSize: row.fileSize,
+      fileFormat: row.fileFormat,
+      mimeType: row.mimeType,
+      durationSeconds: row.durationSeconds,
+      resolution: row.resolution,
+      quotedFlag: row.quotedFlag || '0',
+      remark: row.remark,
+      status
+    })
+  }).then(() => {
+    proxy.$modal.msgSuccess(actionText + '成功')
+    getList()
+  }).catch(() => {})
 }
 
 getList()
 </script>
+
+<style scoped>
+.asset-thumb {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+}
+.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;
+}
+.file-info {
+  margin: 10px 0 18px;
+}
+.preview-box {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 360px;
+}
+.preview-image {
+  max-width: 100%;
+  max-height: 70vh;
+  border-radius: 6px;
+}
+.preview-video {
+  width: 100%;
+  max-height: 70vh;
+  border-radius: 6px;
+  background: #000;
+}
+.upload-edit-tip {
+  margin-bottom: 12px;
+}
+.upload-api-tip {
+  display: block;
+  margin-top: 4px;
+  color: #e6a23c;
+}
+.file-url-link {
+  max-width: 100%;
+  word-break: break-all;
+}
+</style>

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

@@ -101,7 +101,7 @@
         <tr><td>首页</td><td>首页总览</td><td>定制开发</td><td>否</td><td>首页涉及机器人实时状态、统计卡片、告警摘要、快捷操作入口,属于聚合看板页面,不能直接按单表 CRUD 生成。</td></tr>
         <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>基础列表、查询、编辑可生成;上传、缩略图展示、图片/视频预览、引用保护需要定制。</td></tr>
+        <tr><td>素材管理</td><td>RuoYi 生成后定制</td><td>部分适合</td><td>可基于 robot_ops_media_asset 生成基础列表、查询、详情、编辑、删除接口和页面;上传素材、自动解析文件信息、缩略图/视频封面、图片/视频预览、引用状态维护、删除引用保护需要二次定制。前端只允许用户维护素材名称、启用状态和备注,文件信息由上传接口自动生成或解析。</td></tr>
         <tr><td>播放方案管理</td><td>定制开发</td><td>否</td><td>涉及主子表、素材选择、拖拽排序、播放时长、复制方案、预览方案等复杂交互,建议 Cursor 或开发人员手工实现。</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>
@@ -150,7 +150,21 @@
 <tr><td>业务规则</td><td>同一分类下标准问题建议不重复,新增和编辑时由后端进行重复校验。删除主问答时,应同步删除其相似问数据。</td></tr>
 <tr><td>导入说明</td><td>问答库一期暂不支持导入。后续如运营确实需要批量维护问答内容,再单独扩展 Excel 导入模板、问题分类匹配、相似问拆分、重复校验和失败明细回显能力。</td></tr>
 </tbody></table>
-    <h4>6.3.3 素材管理页面</h4><table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody><tr><td>查询条件</td><td>素材名称、素材类型、启用状态、上传时间范围。</td></tr><tr><td>列表字段</td><td>缩略图(thumbnailUrl)、素材名称(assetName)、素材类型(assetType)、文件格式(fileFormat)、文件大小(fileSize)、时长秒数(durationSeconds)、分辨率(resolution)、上传时间(createTime)、引用状态(quotedFlag)、启用状态(status)、操作。</td></tr><tr><td>操作按钮</td><td>上传、预览、编辑名称、删除、启用/停用、批量删除。</td></tr><tr><td>上传规则</td><td>图片支持 jpg/png/webp;视频支持 mp4;单文件大小默认上限 500MB。</td></tr><tr><td>引用保护</td><td>被播放方案引用的素材不可直接删除,需先解除引用。</td></tr><tr><td>预览规则</td><td>图片弹窗预览;视频弹窗播放器预览;无法播放时提示格式不支持。</td></tr></tbody></table>
+    <h4>6.3.3 素材管理页面</h4>
+<table><thead><tr><th>模块</th><th>详细设计</th></tr></thead><tbody>
+<tr><td>页面目标</td><td>维护机器人展示、播放方案等功能使用的图片和视频素材。素材管理可基于 RuoYi 生成基础列表和表单,再定制上传、缩略图展示、图片/视频预览、启用/停用、引用状态和删除保护能力。</td></tr>
+<tr><td>查询条件</td><td>素材名称、素材类型、启用状态、上传时间范围。</td></tr>
+<tr><td>列表字段</td><td>缩略图(thumbnailUrl)、素材名称(assetName)、素材类型(assetType)、文件格式(fileFormat)、文件大小(fileSize)、视频时长(durationSeconds)、分辨率(resolution)、上传时间(createTime)、引用状态(quotedFlag)、启用状态(status)、操作。</td></tr>
+<tr><td>操作按钮</td><td>上传素材、预览、编辑、删除、启用/停用、批量删除。</td></tr>
+<tr><td>编辑字段</td><td>素材名称(assetName)、启用状态(status)、备注(remark)。文件地址、缩略图、文件大小、文件格式、MIME 类型、视频时长、分辨率等信息由上传接口自动生成或解析,前端只读展示,不允许用户手动填写。</td></tr>
+<tr><td>素材类型</td><td>素材类型 assetType 为业务类型,建议使用 RuoYi 字典 media_asset_type,字典项:image=图片,video=视频。素材类型可由上传接口根据文件 MIME 类型或后缀自动判断。</td></tr>
+<tr><td>文件信息</td><td>fileUrl 为素材文件访问地址;thumbnailUrl 为缩略图地址,图片可等于 fileUrl,视频可为视频封面图;fileSize 为文件大小,单位字节;fileFormat 为文件格式/后缀;mimeType 为文件 MIME 类型;resolution 为分辨率;durationSeconds 为视频时长,图片素材为空。</td></tr>
+<tr><td>上传规则</td><td>图片支持 jpg/png/webp;视频支持 mp4;单文件大小默认上限 500MB。上传接口至少应返回 fileUrl、fileSize、fileFormat、mimeType、assetType;thumbnailUrl、resolution、durationSeconds 可根据后端解析能力逐步完善,前端需做好空值兜底展示。</td></tr>
+<tr><td>引用状态</td><td>quotedFlag 表示素材是否被播放方案引用,由系统维护,前端只读展示,不允许编辑。删除素材时,后端必须检查播放方案明细表中的实际引用关系,不应仅依赖 quotedFlag。</td></tr>
+<tr><td>引用保护</td><td>被播放方案引用的素材不可直接删除,需先解除引用。接口应返回明确提示,例如"该素材已被 2 个播放方案引用,请先解除引用后再删除"。</td></tr>
+<tr><td>预览规则</td><td>图片弹窗预览;视频弹窗播放器预览;视频无缩略图时,列表展示默认视频图标;无法播放时提示格式不支持。</td></tr>
+<tr><td>业务规则</td><td>启用状态为启用的素材可被新播放方案选择;停用素材不可被新播放方案选择,但历史播放方案已引用的停用素材仍需支持回显。</td></tr>
+</tbody></table>
     <h4>6.3.4 播放方案管理页面</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>方案名称(planName)、循环方式(loopMode)、是否默认(isDefault)、启用状态(status)、备注(remark)、素材明细列表(itemList)。</td></tr><tr><td>素材明细字段</td><td>素材 ID(assetId)、素材名称(assetName,关联素材表展示字段)、素材类型(assetType,关联素材表展示字段)、播放顺序(playOrder)、停留时长(staySeconds)、转场方式(transitionType)。</td></tr><tr><td>操作按钮</td><td>新增、编辑、复制、删除、设为默认、启用/停用、预览。</td></tr><tr><td>交互规则</td><td>支持拖拽排序;图片素材必须填写停留时长;视频素材默认播完切换。</td></tr></tbody></table>
     <h4>6.3.5 播报内容管理页面</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>内容名称(contentName)、内容分类(contentType)、播报文本(broadcastText)、启用状态(status)、备注(remark)。</td></tr><tr><td>操作按钮</td><td>新增、编辑、删除、启用/停用、测试播报。</td></tr><tr><td>内容分类</td><td>通知、宣传、提示、安防提醒、自定义。</td></tr></tbody></table>
     <h4>6.3.6 播报任务管理页面</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>任务名称(taskName)、关联播报内容(contentId,页面显示内容名称)、开始时间(startTime)、结束时间(endTime)、播报频率分钟数(frequencyMinutes,单位:分钟)、循环类型(cycleType)、循环取值(cycleValue)、启用状态(status)、备注(remark)。</td></tr><tr><td>cycleType</td><td>使用 RuoYi 字典 <code class="inline">broadcast_task_cycle_type</code>,字典值:1=按星期,2=按日期。</td></tr><tr><td>cycleValue</td><td>当 cycleType=1(按星期)时,保存星期值,1=星期一、2=星期二、3=星期三、4=星期四、5=星期五、6=星期六、7=星期日,多个值用英文逗号分隔,例如 1,2,3,4,5。当 cycleType=2(按日期)时,保存指定日期,多个日期用英文逗号分隔,例如 2026-03-20,2026-03-21。</td></tr><tr><td>操作按钮</td><td>新增、编辑、复制、删除、启用/停用。</td></tr><tr><td>校验规则</td><td>结束时间必须大于开始时间;frequencyMinutes 必须大于 0,单位为分钟;当循环类型为按星期时,至少选择一个星期;当循环类型为按日期时,至少选择一个指定日期。</td></tr><tr><td>交互规则</td><td>关联播报内容列表显示内容名称;新增/编辑时仅允许选择启用状态的播报内容;历史任务关联的播报内容如已停用,编辑时仍需可回显,并显示"已停用"提示,但不可重新选择停用内容。</td></tr></tbody></table>
@@ -225,6 +239,7 @@
       <tr><td>/robot-ops/common/file/upload</td><td>POST</td><td>通用文件上传</td><td>文件(file)、业务类型(bizType)</td><td>文件地址(fileUrl)、文件名称(fileName)、文件大小(fileSize)、文件格式(fileFormat)</td><td>主题Logo、主题背景资源,普通附件等。素材库文件仍优先使用素材上传接口。</td></tr>
     </tbody></table>
     <div class="note">通用文件上传接口默认不单独建设文件记录表,上传后的 fileUrl 由具体业务表保存;如后续需要统一文件管理,再扩展附件表。</div>
+    <div class="note">素材库文件上传优先使用素材管理专用上传接口 /robot-ops/content/media/upload。素材上传接口除保存文件外,还需要生成或解析 assetType、fileSize、fileFormat、mimeType、thumbnailUrl、durationSeconds、resolution 等素材字段;通用文件上传接口不承担素材入库和素材元数据解析职责。</div>
 
 
     <h3>7.1 通用接口规范</h3>
@@ -357,14 +372,18 @@
     <div class="note">删除问答时需同步删除 robot_ops_faq_similar 中对应 faq_id 的相似问数据;启用/停用仅影响主问答状态,相似问不单独设置状态。</div>
 
     <h4>7.4.3 素材管理接口</h4>
-    <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求参数</th><th>返回/处理字段</th><th>数据库表</th></tr></thead><tbody>
-      <tr><td>/robot-ops/content/media/page</td><td>GET</td><td>素材分页</td><td>素材名称(assetName)、素材类型(assetType)、启用状态(status)、上传时间范围、pageNum、pageSize</td><td>id、assetName、assetType、fileFormat、fileSize、durationSeconds、resolution、thumbnailUrl、quotedFlag、status、createTime</td><td>robot_ops_media_asset</td></tr>
-<tr><td>/robot-ops/content/media/{id}</td><td>GET</td><td>素材详情</td><td>素材ID(id)</td><td>id、assetName、assetType、fileUrl、thumbnailUrl、fileFormat、fileSize、durationSeconds、resolution、quotedFlag、status、remark、createTime</td><td>robot_ops_media_asset</td></tr>
-      <tr><td>/robot-ops/content/media/upload</td><td>POST</td><td>上传素材</td><td>文件(file)、素材名称(assetName)、素材类型(assetType)</td><td>素材ID(id)、文件地址(fileUrl)、缩略图地址(thumbnailUrl)、文件大小(fileSize)、文件格式(fileFormat)</td><td>robot_ops_media_asset</td></tr>
-      <tr><td>/robot-ops/content/media</td><td>PUT</td><td>编辑素材</td><td>素材ID(id)、素材名称(assetName)、启用状态(status)、备注(remark)</td><td>无</td><td>robot_ops_media_asset</td></tr>
-      <tr><td>/robot-ops/content/media/{id}</td><td>DELETE</td><td>删除素材</td><td>素材ID(id)</td><td>无</td><td>robot_ops_media_asset</td></tr>
-    </tbody></table>
-    <div class="warn">被播放方案引用的素材不可直接删除,需先解除播放方案引用。</div>
+<table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求参数</th><th>返回/处理字段</th><th>数据库表</th></tr></thead><tbody>
+<tr><td>/robot-ops/content/media/page</td><td>GET</td><td>素材分页</td><td>素材名称(assetName)、素材类型(assetType)、启用状态(status)、上传时间范围、pageNum、pageSize</td><td>id、assetName、assetType、fileFormat、fileSize、durationSeconds、resolution、thumbnailUrl、quotedFlag、status、createTime</td><td>robot_ops_media_asset</td></tr>
+<tr><td>/robot-ops/content/media/{id}</td><td>GET</td><td>素材详情</td><td>素材ID(id)</td><td>id、assetName、assetType、fileUrl、thumbnailUrl、fileSize、fileFormat、mimeType、durationSeconds、resolution、quotedFlag、status、remark、createTime、updateTime</td><td>robot_ops_media_asset</td></tr>
+<tr><td>/robot-ops/content/media/upload</td><td>POST</td><td>上传素材</td><td>文件(file)、素材名称(assetName,可选,未填写时默认使用文件名)、启用状态(status,可选,默认启用)、备注(remark,可选)</td><td>素材ID(id)、素材名称(assetName)、素材类型(assetType)、文件地址(fileUrl)、缩略图地址(thumbnailUrl)、文件大小(fileSize)、文件格式(fileFormat)、MIME类型(mimeType)、视频时长(durationSeconds)、分辨率(resolution)、启用状态(status)</td><td>robot_ops_media_asset</td></tr>
+<tr><td>/robot-ops/content/media</td><td>PUT</td><td>编辑素材</td><td>素材ID(id)、素材名称(assetName)、启用状态(status)、备注(remark)</td><td>无</td><td>robot_ops_media_asset</td></tr>
+<tr><td>/robot-ops/content/media/{id}</td><td>DELETE</td><td>删除素材</td><td>素材ID(id)</td><td>无;如素材已被播放方案引用,应返回不可删除原因和引用数量</td><td>robot_ops_media_asset、robot_ops_play_plan_item</td></tr>
+<tr><td>/robot-ops/content/media/export</td><td>GET</td><td>导出素材</td><td>同分页查询条件</td><td>Excel文件;建议导出素材名称、素材类型、文件格式、文件大小、视频时长、分辨率、引用状态、启用状态、上传时间、备注</td><td>robot_ops_media_asset</td></tr>
+</tbody></table>
+<div class="note">素材上传接口需要在 RuoYi 生成的基础 CRUD 之外进行二次开发。RuoYi 生成器可生成列表、详情、编辑、删除等基础接口,但不会自动解析上传文件信息、生成视频封面、解析视频时长和分辨率。</div>
+<div class="note">上传接口一期至少应返回 fileUrl、fileSize、fileFormat、mimeType、assetType。thumbnailUrl、resolution、durationSeconds 可根据后端解析能力逐步完善;前端需对这些字段为空的情况做兜底展示。</div>
+<div class="note">素材类型 assetType 是业务类型,用于页面筛选和播放方案选择;mimeType 是文件 MIME 类型,用于技术校验、预览和文件响应处理;fileFormat 是文件后缀/格式,用于页面展示和导出。</div>
+<div class="note">quotedFlag 由后端根据播放方案明细引用关系维护,前端只读展示。删除素材时,后端必须检查 robot_ops_play_plan_item 是否存在引用,不能仅依赖 quotedFlag。</div>
 
     <h4>7.4.4 播放方案接口</h4>
     <table><thead><tr><th>接口</th><th>方法</th><th>说明</th><th>请求/返回字段</th><th>数据库表</th></tr></thead><tbody>
@@ -614,18 +633,19 @@ VALUES
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='问答相似问表';</div>
 
     <h4>8.2.4 素材资源表 robot_ops_media_asset</h4>
-    <div class="code">CREATE TABLE `robot_ops_media_asset` (
+<div class="code">CREATE TABLE `robot_ops_media_asset` (
   `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
   `asset_name` VARCHAR(100) NOT NULL COMMENT '素材名称',
   `asset_type` VARCHAR(20) NOT NULL COMMENT '素材类型:image图片,video视频',
-  `file_url` VARCHAR(255) NOT NULL COMMENT '素材文件地址',
-  `thumbnail_url` VARCHAR(255) DEFAULT NULL COMMENT '缩略图地址',
+  `file_url` VARCHAR(500) NOT NULL COMMENT '素材文件地址',
+  `thumbnail_url` VARCHAR(500) DEFAULT NULL COMMENT '缩略图地址,图片可等于file_url,视频可为封面图',
   `file_size` BIGINT DEFAULT NULL COMMENT '文件大小,单位字节',
   `file_format` VARCHAR(20) DEFAULT NULL COMMENT '文件格式,如jpg、png、webp、mp4',
+  `mime_type` VARCHAR(100) DEFAULT NULL COMMENT '文件MIME类型,如image/jpeg、video/mp4',
   `duration_seconds` INT DEFAULT NULL COMMENT '视频时长,单位秒;图片为空',
   `resolution` VARCHAR(50) DEFAULT NULL COMMENT '分辨率,如1920x1080',
   `status` CHAR(1) NOT NULL DEFAULT '1' COMMENT '启用状态:0停用,1启用',
-  `quoted_flag` CHAR(1) NOT NULL DEFAULT '0' COMMENT '是否被播放方案引用:0否,1是',
+  `quoted_flag` 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 '创建时间/上传时间',
@@ -633,8 +653,13 @@ VALUES
   `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   PRIMARY KEY (`id`),
   KEY `idx_robot_ops_media_asset_type` (`asset_type`),
-  KEY `idx_robot_ops_media_asset_status` (`status`)
+  KEY `idx_robot_ops_media_asset_status` (`status`),
+  KEY `idx_robot_ops_media_asset_quoted_flag` (`quoted_flag`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='素材资源表';</div>
+<div class="note">说明:asset_type 为业务素材类型,用于页面筛选、播放方案选择和预览方式判断;mime_type 为文件标准 MIME 类型,用于文件校验、响应和预览处理;file_format 为文件后缀/格式,用于页面展示和导出。</div>
+<div class="note">说明:file_url、thumbnail_url、file_size、file_format、mime_type、duration_seconds、resolution 等字段由上传接口自动生成或解析,前端不允许用户手动填写。duration_seconds 仅视频素材需要,图片素材为空。</div>
+<div class="note">说明:thumbnail_url 用于列表缩略图和视频封面展示。图片素材如未单独生成缩略图,可使用 file_url;视频素材如一期暂未生成封面图,可为空,前端展示默认视频图标。</div>
+<div class="note">说明:quoted_flag 由系统维护,表示素材是否被播放方案引用。删除素材时,应以后端检查 robot_ops_play_plan_item 的实际引用关系为准。</div>
 
     <h4>8.2.5 播放方案表 robot_ops_play_plan</h4>
     <div class="code">CREATE TABLE `robot_ops_play_plan` (