Переглянути джерело

fix(banner): 保存时使用 linkType/linkUrl 与后端 json 一致;支持视频与登录页位置
- 创建/更新 Banner 提交 linkType、linkUrl(camelCase),修复再编辑变「无链接」
- Banner 表单:视频上传与预览、位置「登录页」、素材库
- 文案:positionLogin、媒体提示等(zh/en/vi)

urban 3 тижнів тому
батько
коміт
5c672616f4

+ 9 - 2
src/languages/modules/en.ts

@@ -199,23 +199,30 @@ export default {
     positionHome: "Home Carousel",
     positionTask: "Task Page",
     positionMine: "Mine Page",
+    positionLogin: "Login (tutorial)",
     linkNone: "No Link",
     linkInternal: "Internal Page",
     linkExternal: "External Link",
     linkTaskDetail: "Task Detail",
     selectFromMaterial: "Select from Material",
     orInputImageUrl: "Or enter image URL directly",
+    orInputMediaUrl: "Or enter image/video URL",
     imageSizeTip: "Recommended: 750 x 350 px, jpg/png/gif",
+    mediaSizeTip:
+      "Recommended: 750 x 350 px; images jpg/png/gif up to 5MB; video mp4/webm/mov etc. up to 50MB",
     placeholderLinkUrl: "Enter link URL",
     placeholderPosition: "Select position",
     placeholderLinkType: "Select link type",
     placeholderImageUrl: "Enter image URL",
+    placeholderMediaUrl: "Enter image or video URL",
     confirmDeleteBanner: "Confirm delete this banner?",
     materialSelected: "Material selected",
     imageOnly: "Only image files allowed",
+    imageOrVideoOnly: "Only image or video files allowed",
     imageSizeLimit: "Image size must not exceed 5MB",
-    uploadSuccess: "Image uploaded successfully",
-    uploadFailed: "Image upload failed"
+    videoSizeLimit: "Video size must not exceed 50MB",
+    uploadSuccess: "Uploaded successfully",
+    uploadFailed: "Upload failed"
   },
   user: {
     userId: "User ID",

+ 9 - 2
src/languages/modules/vi.ts

@@ -199,23 +199,30 @@ export default {
     positionHome: "Carousel trang chủ",
     positionTask: "Trang nhiệm vụ",
     positionMine: "Trang của tôi",
+    positionLogin: "Đăng nhập (hướng dẫn)",
     linkNone: "Không có liên kết",
     linkInternal: "Trang nội bộ",
     linkExternal: "Liên kết ngoài",
     linkTaskDetail: "Chi tiết nhiệm vụ",
     selectFromMaterial: "Chọn từ thư viện",
     orInputImageUrl: "Hoặc nhập URL hình ảnh",
+    orInputMediaUrl: "Hoặc nhập URL hình/video",
     imageSizeTip: "Khuyến nghị: 750 x 350 px, jpg/png/gif",
+    mediaSizeTip:
+      "Khuyến nghị: 750 x 350 px; ảnh jpg/png/gif tối đa 5MB; video mp4/webm/mov tối đa 50MB",
     placeholderLinkUrl: "Nhập địa chỉ liên kết",
     placeholderPosition: "Chọn vị trí",
     placeholderLinkType: "Chọn loại liên kết",
     placeholderImageUrl: "Nhập URL hình ảnh",
+    placeholderMediaUrl: "Nhập URL hình ảnh hoặc video",
     confirmDeleteBanner: "Xác nhận xóa banner này?",
     materialSelected: "Đã chọn tài liệu",
     imageOnly: "Chỉ cho phép file hình ảnh",
+    imageOrVideoOnly: "Chỉ cho phép file hình ảnh hoặc video",
     imageSizeLimit: "Kích thước hình không quá 5MB",
-    uploadSuccess: "Tải hình thành công",
-    uploadFailed: "Tải hình thất bại"
+    videoSizeLimit: "Kích thước video không quá 50MB",
+    uploadSuccess: "Tải lên thành công",
+    uploadFailed: "Tải lên thất bại"
   },
   user: {
     userId: "ID người dùng",

+ 9 - 2
src/languages/modules/zh.ts

@@ -208,23 +208,30 @@ export default {
     positionHome: "首页轮播",
     positionTask: "任务页",
     positionMine: "我的页",
+    positionLogin: "登录页(操作说明)",
     linkNone: "无链接",
     linkInternal: "内部页面",
     linkExternal: "外部链接",
     linkTaskDetail: "任务详情",
     selectFromMaterial: "从素材库选择",
     orInputImageUrl: "或直接输入图片URL",
+    orInputMediaUrl: "或直接输入图片/视频 URL",
     imageSizeTip: "建议尺寸: 750 x 350 px,支持 jpg/png/gif 格式",
+    mediaSizeTip:
+      "建议尺寸: 750 x 350 px;图片支持 jpg/png/gif,单文件最大 5MB;视频支持 mp4/webm/mov 等,单文件最大 50MB",
     placeholderLinkUrl: "请输入链接地址",
     placeholderPosition: "请选择位置",
     placeholderLinkType: "请选择链接类型",
     placeholderImageUrl: "请输入图片URL",
+    placeholderMediaUrl: "请输入图片或视频 URL",
     confirmDeleteBanner: "确认删除该Banner吗?",
     materialSelected: "已选择素材",
     imageOnly: "只能上传图片文件",
+    imageOrVideoOnly: "只能上传图片或视频文件",
     imageSizeLimit: "图片大小不能超过 5MB",
-    uploadSuccess: "图片上传成功",
-    uploadFailed: "图片上传失败"
+    videoSizeLimit: "视频大小不能超过 50MB",
+    uploadSuccess: "上传成功",
+    uploadFailed: "上传失败"
   },
   user: {
     userId: "用户ID",

+ 6 - 0
src/utils/bannerMedia.ts

@@ -0,0 +1,6 @@
+/** 根据 URL 判断 Banner 资源是否为视频(与扩展名一致即可,素材库路径通常带后缀) */
+export function isBannerVideoUrl(url: string | null | undefined): boolean {
+  if (!url || typeof url !== "string") return false;
+  const base = url.trim().split(/[?#]/)[0].toLowerCase();
+  return /\.(mp4|webm|ogg|ogv|mov|m4v|mkv|avi)(\/)?$/.test(base);
+}

+ 49 - 17
src/views/daytask/content/banner.vue

@@ -15,7 +15,20 @@
         <el-table-column prop="title" :label="$t('common.title')" align="center" min-width="150" />
         <el-table-column prop="image" :label="$t('banner.image')" align="center" width="120">
           <template #default="{ row }">
-            <el-image v-if="row.image" :src="row.image" style="width: 100px; height: 50px" fit="cover" :preview-src-list="[row.image]" />
+            <video
+              v-if="row.image && isBannerVideoUrl(row.image)"
+              :src="row.image"
+              muted
+              playsinline
+              style="width: 100px; height: 50px; object-fit: cover; border-radius: 4px; vertical-align: middle"
+            />
+            <el-image
+              v-else-if="row.image"
+              :src="row.image"
+              style="width: 100px; height: 50px"
+              fit="cover"
+              :preview-src-list="[row.image]"
+            />
           </template>
         </el-table-column>
         <el-table-column prop="position" :label="$t('banner.position')" align="center" width="100">
@@ -68,9 +81,18 @@
             :show-file-list="false"
             :http-request="handleBannerUpload"
             :before-upload="beforeBannerUpload"
-            accept="image/*"
+            accept="image/*,video/mp4,video/webm,video/quicktime,video/ogg,video/x-m4v"
           >
-            <el-image v-if="formData.image" :src="formData.image" class="banner-preview" fit="cover" />
+            <video
+              v-if="formData.image && isBannerVideoUrl(formData.image)"
+              :src="formData.image"
+              class="banner-preview"
+              muted
+              loop
+              playsinline
+              controls
+            />
+            <el-image v-else-if="formData.image" :src="formData.image" class="banner-preview" fit="cover" />
             <el-icon v-else class="banner-uploader-icon"><Plus /></el-icon>
           </el-upload>
           <div class="upload-actions">
@@ -79,15 +101,16 @@
           </div>
         </div>
         <div class="image-url-input">
-          <el-input v-model="formData.image" :placeholder="$t('banner.orInputImageUrl')" size="small" clearable />
+          <el-input v-model="formData.image" :placeholder="$t('banner.orInputMediaUrl')" size="small" clearable />
         </div>
-        <div class="upload-tip">{{ $t('banner.imageSizeTip') }}</div>
+        <div class="upload-tip">{{ $t('banner.mediaSizeTip') }}</div>
       </el-form-item>
       <el-form-item :label="$t('banner.position')" prop="position">
         <el-select v-model="formData.position" :placeholder="$t('banner.placeholderPosition')" style="width: 100%">
           <el-option :label="$t('banner.positionHome')" value="home" />
           <el-option :label="$t('banner.positionTask')" value="task" />
           <el-option :label="$t('banner.positionMine')" value="mine" />
+          <el-option :label="$t('banner.positionLogin')" value="login" />
         </el-select>
       </el-form-item>
       <el-form-item :label="$t('banner.linkType')" prop="linkType">
@@ -135,6 +158,7 @@ import Pagination from "@/components/Pangination/Pagination.vue";
 import MaterialPicker from "@/components/MaterialPicker/index.vue";
 import { getBannerList, createBanner, updateBanner, deleteBanner } from "@/api/modules/daytask.js";
 import { uploadImg } from "@/api/modules/upload.js";
+import { isBannerVideoUrl } from "@/utils/bannerMedia";
 
 const { t } = useI18n();
 const tableData = ref([]);
@@ -143,7 +167,8 @@ const pageable = reactive({ pageNum: 1, pageSize: 30, total: 0 });
 const positionMap = computed(() => ({
   home: t("banner.positionHome"),
   task: t("banner.positionTask"),
-  mine: t("banner.positionMine")
+  mine: t("banner.positionMine"),
+  login: t("banner.positionLogin")
 }));
 const linkTypeMap = computed(() => ({
   0: t("banner.linkNone"),
@@ -169,7 +194,7 @@ const formData = ref({
 });
 const formRules = computed(() => ({
   title: [{ required: true, message: t("common.placeholderTitle"), trigger: "blur" }],
-  image: [{ required: true, message: t("banner.placeholderImageUrl"), trigger: "blur" }],
+  image: [{ required: true, message: t("banner.placeholderMediaUrl"), trigger: "blur" }],
   position: [{ required: true, message: t("banner.placeholderPosition"), trigger: "change" }],
   status: [{ required: true, message: t("common.placeholderStatus"), trigger: "change" }]
 }));
@@ -218,8 +243,8 @@ const openDialog = (row) => {
       title: row.title || "",
       image: row.image || "",
       position: row.position || "home",
-      linkType: row.linkType ?? 0,
-      linkUrl: row.linkUrl || "",
+      linkType: row.linkType ?? row.link_type ?? 0,
+      linkUrl: row.linkUrl ?? row.link_url ?? "",
       sort: row.sort ?? 0,
       status: row.status ?? 1
     };
@@ -248,14 +273,14 @@ const handleSubmit = async () => {
     submitLoading.value = true;
     try {
       const api = isEdit.value ? updateBanner : createBanner;
-      // 后端期望 { id, data: {...} } 格式,字段名需要转为 snake_case
+      // 后端 go_server DtBanner 使用 json:"linkType"/"linkUrl";StructToMapWithZero 只认与 json 标签一致的键名
       const { id } = formData.value;
       const data = {
         title: formData.value.title,
         image: formData.value.image,
         position: formData.value.position,
-        link_type: formData.value.linkType,
-        link_url: formData.value.linkUrl,
+        linkType: formData.value.linkType,
+        linkUrl: formData.value.linkUrl,
         sort: formData.value.sort,
         status: formData.value.status
       };
@@ -308,18 +333,25 @@ const handleMaterialSelect = (material) => {
 };
 
 // ==================== 图片上传 ====================
+const MAX_IMAGE_MB = 5;
+const MAX_VIDEO_MB = 50;
+
 const beforeBannerUpload = (file) => {
   const isImage = file.type.startsWith("image/");
-  const isLt5M = file.size / 1024 / 1024 < 5;
-
-  if (!isImage) {
-    ElNotification.warning(t("banner.imageOnly"));
+  const isVideo = file.type.startsWith("video/");
+  if (!isImage && !isVideo) {
+    ElNotification.warning(t("banner.imageOrVideoOnly"));
     return false;
   }
-  if (!isLt5M) {
+  const sizeMb = file.size / 1024 / 1024;
+  if (isImage && sizeMb > MAX_IMAGE_MB) {
     ElNotification.warning(t("banner.imageSizeLimit"));
     return false;
   }
+  if (isVideo && sizeMb > MAX_VIDEO_MB) {
+    ElNotification.warning(t("banner.videoSizeLimit"));
+    return false;
+  }
   return true;
 };