6 次代碼提交 8bc0d5312a ... a42c0e04fa

作者 SHA1 備註 提交日期
  Shinohara Haruna a42c0e04fa 合并分支 5 月之前
  Shinohara Haruna 484c3f8170 实现轮播组预览 5 月之前
  Shinohara Haruna ba10dfa9a1 修复选择轮播组时id精度丢失的问题 5 月之前
  Shinohara Haruna 996839c750 补充必要的API 5 月之前
  Shinohara Haruna 16aa5a223a 实现轮播组选择 5 月之前
  Shinohara Haruna f6a57c4207 初步实现轮播组选择 5 月之前

+ 12 - 0
smsb-modules/smsb-source/src/main/java/com/inspur/source/controller/SmsbItemFileRelController.java

@@ -65,6 +65,18 @@ public class SmsbItemFileRelController extends BaseController {
         return R.ok(smsbItemFileRelService.queryById(id));
     }
 
+    /**
+     * 根据节目ID查询节目资源关联列表
+     * @param itemId 节目ID
+     */
+    @GetMapping("/itemId/{itemId}")
+    public R<List<SmsbItemFileRelVo>> getInfoByItemId(@NotNull(message = "主键不能为空")
+                                        @PathVariable Long itemId) {
+        var list = smsbItemFileRelService.queryByItemId(itemId);
+        System.out.println(list);
+        return R.ok(list);
+    }
+
     /**
      * 新增节目资源关联
      */

+ 8 - 0
smsb-modules/smsb-source/src/main/java/com/inspur/source/service/ISmsbItemFileRelService.java

@@ -41,6 +41,14 @@ public interface ISmsbItemFileRelService {
      */
     List<SmsbItemFileRelVo> queryList(SmsbItemFileRelBo bo);
 
+    /**
+     * 根据节目ID查询节目资源关联列表
+     *
+     * @param itemId 节目ID
+     * @return 节目资源关联列表
+     */
+    List<SmsbItemFileRelVo> queryByItemId(Long itemId);
+
     /**
      * 新增节目资源关联
      *

+ 11 - 0
smsb-modules/smsb-source/src/main/java/com/inspur/source/service/impl/SmsbItemFileRelServiceImpl.java

@@ -67,6 +67,17 @@ public class SmsbItemFileRelServiceImpl implements ISmsbItemFileRelService {
         return baseMapper.selectVoList(lqw);
     }
 
+    /**
+     * 根据节目ID查询节目资源关联列表
+     * @param itemId 节目ID
+     * @return 节目资源关联列表
+     */
+    public List<SmsbItemFileRelVo> queryByItemId(Long itemId) {
+        LambdaQueryWrapper<SmsbItemFileRel> lqw = Wrappers.lambdaQuery();
+        lqw.eq(SmsbItemFileRel::getItemId, itemId);
+        return baseMapper.selectVoList(lqw);
+    }
+
     private LambdaQueryWrapper<SmsbItemFileRel> buildQueryWrapper(SmsbItemFileRelBo bo) {
         Map<String, Object> params = bo.getParams();
         LambdaQueryWrapper<SmsbItemFileRel> lqw = Wrappers.lambdaQuery();

+ 8 - 0
smsb-plus-ui/src/api/smsb/source/item.ts

@@ -1,6 +1,7 @@
 import request from '@/utils/request';
 import { AxiosPromise } from 'axios';
 import { ItemVO, ItemForm, ItemQuery, SplitUploadForm } from '@/api/smsb/source/item_type';
+import { ItemFileRelVO, ItemFileRelForm, ItemFileRelQuery } from '@/api/smsb/source/itemFile_type';
 
 /**
  * 查询节目管理列表
@@ -27,6 +28,13 @@ export const getItem = (id: string | number): AxiosPromise<ItemVO> => {
   });
 };
 
+export const getItemFileRelList = (itemId: string | number): AxiosPromise<ItemFileRelVO[]> => {
+  return request({
+    url: '/source/itemFileRel/itemId/' + itemId,
+    method: 'get'
+  });
+};
+
 /**
  * 新增节目管理
  * @param data

+ 251 - 0
smsb-plus-ui/src/components/CarouselGroupSelector.vue

@@ -0,0 +1,251 @@
+<template>
+  <div>
+    <el-button type="primary" @click="dialogVisible = true">选择轮播组</el-button>
+    <div v-if="showSelected && selectedGroup" class="selected-group">
+      <el-tag closable @close="removeGroup" style="margin: 2px">
+        {{ selectedGroup.name }}
+      </el-tag>
+    </div>
+    <el-dialog title="选择轮播组" v-model="dialogVisible" width="900px" append-to-body>
+      <el-form :inline="true" :model="queryParams" class="mb-2">
+        <el-form-item label="名称">
+          <el-input v-model="queryParams.itemName" placeholder="轮播组名称" clearable style="width: 180px"
+            @keyup.enter="getCarouselList" />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="getCarouselList">搜索</el-button>
+        </el-form-item>
+      </el-form>
+      <el-table v-loading="dialogLoading" :data="carouselList" @row-click="handleRowClick" highlight-current-row>
+        <el-table-column width="50">
+          <template #default="{ row }">
+            <el-checkbox v-model="row.selected" @click.stop @change="handleSelectChange(row)" />
+          </template>
+        </el-table-column>
+        <el-table-column prop="name" label="轮播组名称" min-width="150" />
+        <el-table-column prop="itemCount" label="轮播项数量" width="100" />
+        <el-table-column prop="duration" label="轮播间隔(秒)" width="120" />
+        <el-table-column prop="createTime" label="创建时间" width="180" />
+      </el-table>
+      <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum"
+        v-model:limit="queryParams.pageSize" @pagination="getCarouselList" />
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmSelect">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, defineProps, defineEmits, nextTick } from 'vue';
+import { listItem } from '@/api/smsb/source/item';
+import type { ItemVO, ItemQuery } from '@/api/smsb/source/item_type';
+
+export interface CarouselGroup {
+  id: string | number;
+  name: string;
+  itemCount: number;
+  duration: number;
+  createTime: string;
+  items: any[];
+  selected?: boolean;
+  [key: string]: any; // Allow additional properties
+}
+
+const props = defineProps<{
+  modelValue: string;
+  showSelected?: boolean;
+}>();
+
+const emit = defineEmits(['update:modelValue']);
+
+const dialogVisible = ref(false);
+const dialogLoading = ref(false);
+const carouselList = ref<CarouselGroup[]>([]);
+const total = ref(0);
+const currentRow = ref<CarouselGroup | null>(null);
+const selectedGroup = ref<CarouselGroup | null>(null);
+
+// 定义查询参数类型
+interface CarouselQuery extends ItemQuery {
+  pageNum: number;
+  pageSize: number;
+  itemName: string;
+  itemType: number;
+}
+
+const queryParams = ref<CarouselQuery>({
+  pageNum: 1,
+  pageSize: 10,
+  itemName: '',
+  itemType: 1 // 1表示轮播组类型
+});
+
+// 初始化时,如果有值,反序列化
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (val) {
+      try {
+        // 使用 JSON.parse 的 reviver 函数确保大数 ID 保持为字符串
+        const parsedValue = JSON.parse(val, (key, value) => {
+          // 如果键是 id,确保它作为字符串返回
+          if (key === 'id' && value !== null && value !== undefined) {
+            return String(value);
+          }
+          return value;
+        });
+
+        // 确保 id 是字符串
+        const id = parsedValue.id ? String(parsedValue.id) : '';
+
+        selectedGroup.value = {
+          id: id,
+          name: String(parsedValue.name || ''),
+          itemCount: Number(parsedValue.itemCount) || 0,
+          duration: Number(parsedValue.duration) || 5,
+          createTime: String(parsedValue.createTime || ''),
+          items: Array.isArray(parsedValue.items) ? parsedValue.items : []
+        };
+      } catch (error) {
+        console.error('Error parsing modelValue:', error);
+        selectedGroup.value = null;
+      }
+    } else {
+      selectedGroup.value = null;
+    }
+  },
+  { immediate: true, deep: true }
+);
+
+// 查询轮播组列表
+const getCarouselList = async () => {
+  dialogLoading.value = true;
+  try {
+    // 构建查询参数,确保类型安全
+    const query: ItemQuery = {
+      itemName: queryParams.value.itemName,
+      itemType: 1, // 1表示轮播组类型
+      pageNum: queryParams.value.pageNum,
+      pageSize: queryParams.value.pageSize
+    };
+
+    const res = await listItem(query);
+
+    // 转换数据格式,并标记已选中的项,确保 ID 是字符串
+    carouselList.value = (res.rows || []).map((item: ItemVO) => {
+      // 确保 ID 是字符串,避免大数精度问题
+      const itemId = item.id ? String(item.id) : '';
+      const isSelected = selectedGroup.value?.id === itemId;
+
+      return {
+        id: itemId,
+        name: item.itemName || '',
+        itemName: item.itemName || '', // 保持与API返回的字段名一致
+        itemCount: item.sourceNum || 0,
+        duration: 5, // 默认5秒,可以根据实际需求调整
+        createTime: item.createTime || '',
+        selected: isSelected,
+        items: [] // 不需要在UI中显示,仅用于内部处理
+      };
+    });
+
+    // 如果当前有选中的组,确保它在列表中也被选中
+    if (selectedGroup.value?.id) {
+      const selectedItem = carouselList.value.find((item) => item.id === selectedGroup.value?.id);
+      if (selectedItem) {
+        selectedItem.selected = true;
+        currentRow.value = selectedItem;
+      }
+    }
+
+    total.value = res.total || 0;
+  } finally {
+    dialogLoading.value = false;
+  }
+};
+
+// 行点击
+const handleRowClick = (row: CarouselGroup) => {
+  currentRow.value = row;
+};
+
+// 处理选择变化
+const handleSelectChange = (row: CarouselGroup) => {
+  if (row.selected) {
+    // 只允许单选,取消其他项
+    carouselList.value.forEach((item) => {
+      if (item !== row) item.selected = false;
+    });
+    currentRow.value = row;
+  } else {
+    // 当前项取消选中
+    currentRow.value = null;
+  }
+};
+
+// 选择轮播组
+const selectGroup = (row: CarouselGroup) => {
+  row.selected = true;
+  handleSelectChange(row);
+  confirmSelect();
+};
+
+// 移除已选轮播组
+const removeGroup = () => {
+  selectedGroup.value = null;
+  emit('update:modelValue', '');
+};
+
+// 确认选择
+const confirmSelect = () => {
+  console.log('confirmSelect called, currentRow:', currentRow.value);
+
+  if (!currentRow.value) {
+    // 无选中项,清除选中
+    console.log('No row selected, clearing selection');
+    selectedGroup.value = null;
+    emit('update:modelValue', '');
+    dialogVisible.value = false;
+    return;
+  }
+
+  // 保持ID为字符串,避免精度丢失
+  const id = String(currentRow.value.id);
+  console.log('Selected group ID (as string):', id, 'Type:', typeof id);
+
+  if (!id) {
+    console.error('Invalid group ID:', currentRow.value.id);
+    return;
+  }
+
+  // 只存储 id 和 name 字段,确保id是字符串
+  const selectedData = {
+    id: id, // 已经是字符串
+    name: String(currentRow.value.name || '') // 确保name是字符串
+  };
+
+  console.log('Emitting selected data:', selectedData);
+  selectedGroup.value = selectedData;
+
+  // 更新所有行的选中状态
+  carouselList.value.forEach((item) => {
+    item.selected = String(item.id) === id; // 确保比较时类型一致
+  });
+
+  emit('update:modelValue', JSON.stringify(selectedData));
+  dialogVisible.value = false;
+};
+
+// 弹窗首次打开时自动加载
+watch(dialogVisible, (val) => {
+  if (val) getCarouselList();
+});
+</script>
+
+<style scoped>
+.selected-group {
+  margin-top: 8px;
+}
+</style>

+ 4 - 4
smsb-plus-ui/src/views/smsb/itemProgram/EditProgram.vue

@@ -35,7 +35,7 @@
               fill="currentColor"></path>
           </svg>
         </el-icon>
-        <div style="font-size: 12px; color: #bbb">回收站</div>
+        <div style="font-size: 12px; color: #bbb">拖拽移除组件</div>
       </div>
     </div>
 
@@ -86,7 +86,7 @@
                   " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
                 <MediaAssetBoard v-if="item.type === 'mediaAsset'" :width="item.width * canvasScale"
                   :height="item.height * canvasScale" :media-id="item.mediaId" :selected="selectedComponent === item"
-                  @resize="
+                  v-model="item.mediaGroup" @resize="
                     ({ width, height }) => {
                       item.width = width / canvasScale;
                       item.height = height / canvasScale;
@@ -148,7 +148,7 @@
                 <el-switch v-model="selectedComponent[key]" active-text="开" inactive-text="关" />
               </template>
               <template v-else-if="key === 'mediaId'">
-                <MediaFileSelector v-model="selectedComponent[key]" :showSelected="false" />
+                <CarouselGroupSelector v-model="selectedComponent[key]" :showSelected="false" />
               </template>
               <template v-else-if="key === 'bg'">
                 <BackgroundSelector v-model="selectedComponent[key]" :isCanvas="selectedComponent.type === 'canvas'" />
@@ -328,7 +328,7 @@ import CanvasBoard from './component/CanvasBoard.vue';
 import TextBoard from './component/TextBoard.vue';
 import ScrollingTextBoard from './component/ScrollingTextBoard.vue';
 import MediaAssetBoard from './component/MediaAssetBoard.vue';
-import MediaFileSelector from '@/components/MediaFileSelector.vue';
+import CarouselGroupSelector from '@/components/CarouselGroupSelector.vue';
 import BackgroundSelector from '@/components/BackgroundSelector.vue';
 import LiveBoard from './component/LiveBoard.vue';
 import WebPageBoard from './component/WebPageBoard.vue';

+ 181 - 41
smsb-plus-ui/src/views/smsb/itemProgram/component/MediaAssetBoard.vue

@@ -6,7 +6,10 @@
     display: 'flex',
     alignItems: 'center',
     justifyContent: 'center',
-    overflow: 'hidden'
+    overflow: 'hidden',
+    flexDirection: 'column',
+    gap: '8px',
+    padding: '8px'
   }" @click="$emit('click', $event)">
     <!-- 预览窗口 -->
     <div v-if="mediaItems.length > 0" class="preview-container">
@@ -17,6 +20,11 @@
             @loadeddata="onVideoLoaded"></video>
         </div>
       </template>
+
+      <!-- 轮播组名称显示 -->
+      <div v-if="carouselGroup.name" class="group-name-overlay">
+        {{ carouselGroup.name }}
+      </div>
     </div>
 
     <!-- 无媒体时的默认状态 -->
@@ -26,10 +34,13 @@
         <path d="M8 22l6-6 4 4 6-6" stroke="#409eff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
         <circle cx="11" cy="12" r="2" fill="#409eff" />
       </svg>
+      <div v-if="!props.readonly" class="select-hint">
+        {{ carouselGroup.name ? `轮播组:${carouselGroup.name}` : '请选择轮播组' }}
+      </div>
     </div>
 
     <!-- Resize -->
-    <template v-if="selected">
+    <template v-if="selected && !props.readonly">
       <div v-for="dir in ['nw', 'ne', 'sw', 'se']" :key="dir" class="resize-handle" :class="'resize-' + dir"
         @mousedown.stop="onResizeMouseDown(dir, $event)" />
     </template>
@@ -37,78 +48,184 @@
 </template>
 
 <script setup lang="ts">
-import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
+import { ref, computed, watch, onUnmounted, defineProps, defineEmits } from 'vue';
+import { getItem, getItemFileRelList } from '@/api/smsb/source/item';
+import { getMinioData } from '@/api/smsb/source/minioData';
 
-const emit = defineEmits(['resize', 'click']);
+const emit = defineEmits(['update:mediaId', 'resize', 'click']);
 
-interface Props {
-  width?: number;
-  height?: number;
-  mediaId?: string;
-  selected?: boolean;
-}
+const props = withDefaults(
+  defineProps<{
+    width: number;
+    height: number;
+    mediaId?: string;
+    selected?: boolean;
+    readonly?: boolean;
+    modelValue?: any;
+  }>(),
+  {
+    selected: false,
+    readonly: false,
+    modelValue: null
+  }
+);
 
-const props = withDefaults(defineProps<Props>(), {
-  width: 120,
-  height: 120,
-  mediaId: ''
-});
+interface MediaItem {
+  id: string | number;
+  type: number; // 1-图片 2-视频
+  url: string;
+  duration: number; // 秒
+  name?: string;
+}
 
-const selected = computed(() => !!props.selected);
-const mediaItems = ref<any[]>([]);
+const mediaItems = ref<MediaItem[]>([]);
 const currentMediaIndex = ref(0);
 let slideInterval: number | null = null;
-const SLIDE_DURATION = 10000; // 暂且先写死 10s
 
-const parseMediaItems = () => {
+// 解析轮播组数据
+const carouselGroup = computed(() => {
   try {
     if (props.mediaId) {
-      const parsed = JSON.parse(props.mediaId);
-      mediaItems.value = Array.isArray(parsed) ? parsed : [parsed];
-    } else {
-      mediaItems.value = [];
+      return JSON.parse(props.mediaId);
     }
-    currentMediaIndex.value = 0;
-    startSlideShow();
   } catch (e) {
-    console.error('Error parsing media items:', e);
-    mediaItems.value = [];
+    console.error('Failed to parse mediaId:', e);
+  }
+  return {};
+});
+
+const selected = computed(() => !!props.selected);
+
+// 清理轮播
+const clearSlideShow = () => {
+  if (slideInterval !== null) {
+    clearInterval(slideInterval);
+    slideInterval = null;
   }
 };
 
+// 开始轮播
 const startSlideShow = () => {
   clearSlideShow();
   if (mediaItems.value.length <= 1) return;
 
-  slideInterval = window.setInterval(() => {
-    currentMediaIndex.value = (currentMediaIndex.value + 1) % mediaItems.value.length;
-  }, SLIDE_DURATION);
+  const playNext = () => {
+    const currentItem = mediaItems.value[currentMediaIndex.value];
+    const duration = (currentItem?.duration || 5) * 1000; // 转为毫秒,默认5秒
+
+    // console.log(`播放第 ${currentMediaIndex.value + 1}/${mediaItems.value.length} 项,类型: ${currentItem.type},时长: ${duration}ms`);
+
+    slideInterval = window.setTimeout(() => {
+      currentMediaIndex.value = (currentMediaIndex.value + 1) % mediaItems.value.length;
+      playNext();
+    }, duration);
+  };
+
+  // 开始第一项播放
+  playNext();
 };
 
-const clearSlideShow = () => {
-  if (slideInterval !== null) {
-    clearInterval(slideInterval);
-    slideInterval = null;
-  }
+// 获取文件类型(1-图片,2-视频)
+const getFileType = (type: number): number => {
+  // 1-图片,2-视频,3-音频
+  return type === 2 ? 2 : 1; // 视频为2,其他都视为图片
 };
 
-const onVideoLoaded = (event: Event) => {
-  const video = event.target as HTMLVideoElement;
-  video.pause();
-  video.currentTime = 0;
+// 加载轮播组媒体项
+const loadCarouselGroupMedia = async () => {
+  // console.log('开始加载轮播组媒体项', carouselGroup.value);
+  try {
+    if (!carouselGroup.value || !carouselGroup.value.id) {
+      // console.log('轮播组ID不存在');
+      mediaItems.value = [];
+      return;
+    }
+
+    // 1. 获取轮播组关联的文件关系列表
+    const relRes = await getItemFileRelList(carouselGroup.value.id);
+    // console.log('获取到轮播组关联文件关系:', relRes.data);
+
+    if (!Array.isArray(relRes.data) || relRes.data.length === 0) {
+      // console.log('轮播组没有关联的文件');
+      mediaItems.value = [];
+      return;
+    }
+
+    // 2. 处理每个关联文件
+    const items: MediaItem[] = [];
+    for (const rel of relRes.data) {
+      try {
+        // 获取文件详情
+        // console.log(`加载文件详情: ${rel.fileId}`);
+        const fileRes = await getMinioData(rel.fileId);
+        const fileInfo = fileRes.data;
+        // console.log('文件详情:', fileInfo);
+
+        if (fileInfo) {
+          // 使用 fileUrl 如果存在,否则回退到构建的 URL
+          const fileUrl = fileInfo.fileUrl || `/api/source/file/${rel.fileId}`;
+          // console.log(`文件 ${fileInfo.originalName} 的 URL:`, fileUrl);
+
+          items.push({
+            id: rel.fileId,
+            type: getFileType(fileInfo.type),
+            url: fileUrl,
+            duration: rel.duration || 5, // 使用关联关系中的 duration
+            name: fileInfo.originalName
+          });
+        }
+      } catch (fileError) {
+        console.error(`加载文件 ${rel.fileId} 详情失败:`, fileError);
+      }
+    }
+
+    // 3. 按sort排序
+    items.sort((a, b) => (a as any).sort - (b as any).sort);
+    mediaItems.value = items;
+    // console.log('最终媒体项列表:', mediaItems.value);
+
+    // 4. 开始轮播
+    currentMediaIndex.value = 0;
+    startSlideShow();
+  } catch (error) {
+    console.error('加载轮播组媒体项失败:', error);
+    mediaItems.value = [];
+  }
 };
 
+// 监听mediaId变化
 watch(
   () => props.mediaId,
-  () => {
-    parseMediaItems();
+  (newVal) => {
+    if (newVal) {
+      loadCarouselGroupMedia();
+    } else {
+      mediaItems.value = [];
+      clearSlideShow();
+    }
   },
   { immediate: true }
 );
 
+const onVideoLoaded = (event: Event) => {
+  const video = event.target as HTMLVideoElement;
+  video.pause();
+  video.currentTime = 0;
+};
+
 onUnmounted(() => {
   clearSlideShow();
 });
+
+// 暴露方法,供父组件调用
+const getCarouselGroupId = () => {
+  return props.mediaId;
+};
+
+defineExpose({
+  getCarouselGroupId
+});
+
 let startX = 0,
   startY = 0,
   startW = 0,
@@ -151,6 +268,13 @@ function onResizeMouseDown(dir: string, e: MouseEvent) {
   transition: border-color 0.2s;
   position: relative;
   overflow: hidden;
+  box-sizing: border-box;
+}
+
+.select-hint {
+  margin-top: 8px;
+  font-size: 12px;
+  color: #909399;
 }
 
 .media-asset-board-wrapper.selected {
@@ -189,6 +313,7 @@ function onResizeMouseDown(dir: string, e: MouseEvent) {
 
 .media-asset-icon {
   display: flex;
+  flex-direction: column;
   align-items: center;
   justify-content: center;
   width: 100%;
@@ -196,6 +321,21 @@ function onResizeMouseDown(dir: string, e: MouseEvent) {
   background: #f5f7fa;
 }
 
+.group-name-overlay {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background-color: rgba(0, 0, 0, 0.5);
+  color: white;
+  padding: 4px 8px;
+  font-size: 12px;
+  text-align: center;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
 .resize-handle {
   position: absolute;
   width: 10px;

+ 1 - 1
smsb-plus-ui/src/views/smsb/itemProgram/component/propNameMaps.ts

@@ -30,7 +30,7 @@ export const scrollingTextPropNameMap = {
 };
 
 export const mediaAssetPropNameMap = {
-  mediaId: '媒资文件',
+  mediaId: '轮播组',
   width: '宽度',
   height: '高度',
   x: '横坐标',