5 Revize a42c0e04fa ... 09fcfde7e2

Autor SHA1 Zpráva Datum
  Shinohara Haruna 09fcfde7e2 优化界面布局样式 před 5 měsíci
  Shinohara Haruna 3206d95abe 左侧组件栏固定直角 před 5 měsíci
  Shinohara Haruna 2a3eb46e1e 支持自定义圆角半径 před 5 měsíci
  Shinohara Haruna ed42e4eaa7 改组件圆角为直角 před 5 měsíci
  Shinohara Haruna c52a28f667 添加对节目布局的预览支持 před 5 měsíci

+ 1 - 1
smsb-plus-ui/src/layout/components/Sidebar/index.vue

@@ -30,7 +30,7 @@ const topMenus = computed(() => permissionStore.getTopbarRoutes().filter((menu)
 const currentTopMenuPath = computed(() => {
   // console.log("[Sidebar] route.path", route.path);
   // 特例
-  if (route.path === '/source/push/approval' || route.path.startsWith('/source/split/edit') || route.path.startsWith('/smsb/itemProgram/edit')) {
+  if (route.path === '/source/push/approval' || route.path.startsWith('/source/split/edit') || route.path.startsWith('/smsb/itemProgram/edit') || route.path.startsWith('/smsb/itemProgram/preview')) {
     return '/source';
   }
   // 取当前路由的一级菜单 path

+ 6 - 0
smsb-plus-ui/src/router/index.ts

@@ -208,6 +208,12 @@ export const dynamicRoutes: RouteRecordRaw[] = [
         component: () => import('@/views/smsb/itemProgram/EditProgram.vue'),
         name: 'EditItemProgram',
         meta: { title: '编辑节目', activeMenu: '/smsb/itemProgram', icon: '' }
+      },
+      {
+        path: 'preview/:id',
+        component: () => import('@/views/smsb/itemProgram/PreviewProgram.vue'),
+        name: 'PreviewItemProgram',
+        meta: { title: '预览节目', activeMenu: '/smsb/itemProgram', icon: '' }
       }
     ]
   },

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

@@ -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"
-                  v-model="item.mediaGroup" @resize="
+                  v-model="item.mediaGroup" :border-radius="item.borderRadius || 0" @resize="
                     ({ width, height }) => {
                       item.width = width / canvasScale;
                       item.height = height / canvasScale;
@@ -94,14 +94,15 @@
                   " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
                 <LiveBoard v-if="item.type === 'live'" :width="item.width * canvasScale"
                   :height="item.height * canvasScale" :live-url="item.liveUrl" :play-audio="item.playAudio"
-                  :selected="selectedComponent === item" @resize="
+                  :selected="selectedComponent === item" :border-radius="item.borderRadius || 0" @resize="
                     ({ width, height }) => {
                       item.width = width / canvasScale;
                       item.height = height / canvasScale;
                     }
                   " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
                 <WebPageBoard v-if="item.type === 'webPage'" :width="item.width * canvasScale"
-                  :height="item.height * canvasScale" :url="item.url" :selected="selectedComponent === item" @resize="
+                  :height="item.height * canvasScale" :url="item.url" :selected="selectedComponent === item"
+                  :border-radius="item.borderRadius || 0" @resize="
                     ({ width, height }) => {
                       item.width = width / canvasScale;
                       item.height = height / canvasScale;
@@ -164,6 +165,9 @@
                   <el-option label="日期+时间 (YYYY-MM-DD HH:mm:ss)" value="date" />
                 </el-select>
               </template>
+              <template v-else-if="key === 'borderRadius'">
+                <el-input-number v-model="selectedComponent[key]" :min="0" :max="100" :step="1" style="width: 100%" />
+              </template>
               <template v-else>
                 <el-input v-model="selectedComponent[key]" />
               </template>
@@ -376,8 +380,21 @@ function selectCanvasFromSidebar() {
 function showEditableProp(key: string) {
   // 明确排除 type 字段,防止被编辑
   if (!selectedComponent.value) return false;
-  // 对于文本和滚动文本组件,移除 'fontWeight' 属性
-  if ((selectedComponent.value.type === 'text' || selectedComponent.value.type === 'scrollingText') && (key === 'fontWeight' || key === 'align')) {
+  // 对于文本、滚动文本和时钟组件,移除 'fontWeight' 和 'align' 属性
+  if (
+    (selectedComponent.value.type === 'text' || selectedComponent.value.type === 'scrollingText' || selectedComponent.value.type === 'clock') &&
+    (key === 'fontWeight' || key === 'align')
+  ) {
+    return false;
+  }
+  // 画布、文本、滚动文本和时钟组件不显示 borderRadius 属性
+  if (
+    (selectedComponent.value.type === 'canvas' ||
+      selectedComponent.value.type === 'text' ||
+      selectedComponent.value.type === 'scrollingText' ||
+      selectedComponent.value.type === 'clock') &&
+    key === 'borderRadius'
+  ) {
     return false;
   }
   return !['type', 'depth'].includes(key);
@@ -544,7 +561,8 @@ function onCanvasDrop(e: DragEvent) {
       y: y,
       width: 200,
       height: 40,
-      depth: getMaxDepth() + 1
+      depth: getMaxDepth() + 1,
+      borderRadius: 0
     };
     editorContent.value.elements.push(newText);
     nextTick(() => selectComponent(newText));
@@ -561,7 +579,8 @@ function onCanvasDrop(e: DragEvent) {
       y: y,
       width: 300,
       height: 40,
-      depth: getMaxDepth() + 1
+      depth: getMaxDepth() + 1,
+      borderRadius: 0
     };
     editorContent.value.elements.push(newScrollingText);
     nextTick(() => selectComponent(newScrollingText));
@@ -573,7 +592,8 @@ function onCanvasDrop(e: DragEvent) {
       y: y,
       width: 120,
       height: 120,
-      depth: getMaxDepth() + 1
+      depth: getMaxDepth() + 1,
+      borderRadius: 0
     };
     editorContent.value.elements.push(newMediaAsset);
     nextTick(() => selectComponent(newMediaAsset));
@@ -586,19 +606,21 @@ function onCanvasDrop(e: DragEvent) {
       y: y,
       width: 200,
       height: 120,
-      depth: getMaxDepth() + 1
+      depth: getMaxDepth() + 1,
+      borderRadius: 0
     };
     editorContent.value.elements.push(newLive);
     nextTick(() => selectComponent(newLive));
   } else if (dragType.value === 'webPage') {
     const newWebPage = {
       type: 'webPage',
-      url: '',
+      url: 'https://example.com',
       x: x,
       y: y,
-      width: 120,
-      height: 120,
-      depth: getMaxDepth() + 1
+      width: 300,
+      height: 200,
+      depth: getMaxDepth() + 1,
+      borderRadius: 0
     };
     editorContent.value.elements.push(newWebPage);
     nextTick(() => selectComponent(newWebPage));
@@ -755,6 +777,7 @@ const goBack = () => {
   margin-bottom: 18px;
   cursor: pointer;
   width: 90%;
+  border-radius: 0 !important;
 }
 
 .sidebar-icon {
@@ -773,7 +796,7 @@ const goBack = () => {
   justify-content: center;
   font-weight: bold;
   font-size: 22px;
-  border-radius: 6px;
+  border-radius: 0 !important;
   margin-bottom: 4px;
 }
 
@@ -792,7 +815,7 @@ const goBack = () => {
   width: 90%;
   height: 75%;
   background: #e9eef3;
-  border-radius: 10px;
+  border-radius: 0px;
   display: flex;
   align-items: center;
   justify-content: center;
@@ -813,7 +836,7 @@ const goBack = () => {
   margin-top: -5%;
   margin-bottom: 1%;
   background: #fafbfc;
-  border-radius: 8px;
+  border-radius: 0px;
   box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
   gap: 16px;
   padding: 0 16px;
@@ -823,7 +846,7 @@ const goBack = () => {
   user-select: none;
   cursor: grab;
   background: #fff;
-  border-radius: 4px;
+  border-radius: 0px;
   /* margin: 8px 0 8px 8px; */
   width: 92px;
   min-height: 50px;
@@ -956,13 +979,13 @@ const goBack = () => {
   justify-content: center;
   font-size: 20px;
   color: #aaa;
-  border-radius: 12px;
+  border-radius: 0px;
   box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
 }
 
 .component-item {
   border: 2px solid #e3e3e3;
-  border-radius: 10px;
+  border-radius: 0px;
   background: #fafbfc;
   margin-bottom: 16px;
   padding: 18px 0;

+ 241 - 0
smsb-plus-ui/src/views/smsb/itemProgram/PreviewProgram.vue

@@ -0,0 +1,241 @@
+<template>
+  <div class="preview-program-layout">
+    <!-- 只保留中央预览区 -->
+    <div class="main-preview">
+      <div class="preview-canvas" ref="previewCanvasRef">
+        <CanvasBoard v-if="canvasItem" :width="canvasItem.width" :height="canvasItem.height" :bg="canvasItem.bg"
+          :scale="canvasScale">
+          <template #default>
+            <template
+              v-for="item in editorContent.elements.filter((el) => el.type !== 'canvas').sort((a, b) => a.depth - b.depth)"
+              :key="item.depth + '-' + item.type">
+              <div :style="{
+                position: 'absolute',
+                left: (item.x || 0) * canvasScale + 'px',
+                top: (item.y || 0) * canvasScale + 'px',
+                width: (item.width || 200) * canvasScale + 'px',
+                height: (item.height || 40) * canvasScale + 'px',
+                zIndex: 10,
+                pointerEvents: 'none' // 禁用所有交互
+              }">
+                <TextBoard v-if="item.type === 'text'" :text="item.text" :color="item.color" :font-size="item.fontSize"
+                  :font-weight="item.fontWeight" :align="item.align" :width="item.width * canvasScale"
+                  :height="item.height * canvasScale" />
+                <ScrollingTextBoard v-if="item.type === 'scrollingText'" :text="item.text" :color="item.color"
+                  :font-size="item.fontSize" :font-weight="item.fontWeight" :align="item.align"
+                  :width="item.width * canvasScale" :height="item.height * canvasScale" :speed="item.speed" />
+                <MediaAssetBoard v-if="item.type === 'mediaAsset'" :width="item.width * canvasScale"
+                  :height="item.height * canvasScale" :media-id="item.mediaId" v-model="item.mediaGroup" />
+                <LiveBoard v-if="item.type === 'live'" :width="item.width * canvasScale"
+                  :height="item.height * canvasScale" :live-url="item.liveUrl" :play-audio="item.playAudio" />
+                <WebPageBoard v-if="item.type === 'webPage'" :width="item.width * canvasScale"
+                  :height="item.height * canvasScale" :url="item.url" />
+                <ClockBoard v-if="item.type === 'clock'" :width="item.width * canvasScale"
+                  :height="item.height * canvasScale" :format="item.format" />
+              </div>
+            </template>
+          </template>
+        </CanvasBoard>
+      </div>
+      <div class="preview-controls">
+        <el-button type="primary" @click="goBack" class="back-btn">返回</el-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, nextTick, computed } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { ElMessage } from 'element-plus';
+import { getItemProgram, listItemProgram } from '@/api/smsb/source/item_program';
+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 LiveBoard from './component/LiveBoard.vue';
+import WebPageBoard from './component/WebPageBoard.vue';
+import ClockBoard from './component/ClockBoard.vue';
+
+const router = useRouter();
+const route = useRoute();
+
+// 自动修正 id 类型,确保为 string 或 number
+const rawId = route.params.id;
+const id = ref<string | number>(Array.isArray(rawId) ? rawId[0] : rawId);
+
+// 节目信息
+const programName = ref('');
+const programResolution = ref('');
+
+// 画布缩放
+const previewCanvasRef = ref<HTMLElement | null>(null);
+const containerSize = ref({ width: 0, height: 0 });
+const canvasScale = ref(1);
+
+// 编辑器内容
+interface EditorContent {
+  elements: any[];
+}
+
+const editorContent = ref<EditorContent>({ elements: [] });
+const canvasItem = computed(() => {
+  return editorContent.value.elements.find((el) => el.type === 'canvas');
+});
+
+// 更新容器尺寸和缩放
+const updateContainerSize = () => {
+  if (previewCanvasRef.value) {
+    const container = previewCanvasRef.value;
+    containerSize.value = {
+      width: container.clientWidth,
+      height: container.clientHeight
+    };
+
+    if (canvasItem.value) {
+      const scaleX = (container.clientWidth - 40) / canvasItem.value.width;
+      const scaleY = (container.clientHeight - 40) / canvasItem.value.height;
+      canvasScale.value = Math.min(scaleX, scaleY, 1); // 限制最大缩放为1
+      console.log('Canvas scale updated:', {
+        scale: canvasScale.value,
+        container: containerSize.value,
+        canvas: { width: canvasItem.value.width, height: canvasItem.value.height }
+      });
+    }
+  }
+};
+
+// 获取节目详情
+const fetchProgramDetail = async () => {
+  try {
+    console.log('Fetching program detail for ID:', id.value);
+
+    // 使用列表接口获取节目数据,因为详情接口可能返回 null
+    const listRes = await listItemProgram({ programId: id.value });
+    console.log('Program list response:', listRes);
+
+    if (listRes.code === 200 && listRes.rows && listRes.rows.length > 0) {
+      const programData = listRes.rows[0];
+      programName.value = programData.name || '未命名节目';
+      programResolution.value = programData.resolutionRatio || '1920x1080';
+
+      // 尝试从 itemJsonStr 或 content 中获取节目内容
+      let contentStr = programData.itemJsonStr || programData.content;
+
+      if (!contentStr) {
+        console.warn('No content found in program data');
+        ElMessage.warning('该节目没有可预览的内容');
+        return;
+      }
+
+      try {
+        // 处理可能的转义字符
+        if (typeof contentStr === 'string') {
+          // 先尝试直接解析
+          try {
+            contentStr = JSON.parse(contentStr);
+          } catch (e) {
+            // 如果解析失败,可能是双重转义的 JSON 字符串
+            try {
+              contentStr = JSON.parse(JSON.parse(`"${contentStr}"`));
+            } catch (e2) {
+              console.error('Failed to parse content string:', e2);
+              throw new Error('内容格式不正确');
+            }
+          }
+        }
+
+        // 确保 elements 数组存在
+        if (!contentStr.elements || !Array.isArray(contentStr.elements)) {
+          contentStr.elements = [];
+        }
+
+        editorContent.value = contentStr;
+
+        // 确保有画布元素
+        if (!editorContent.value.elements.some((el) => el.type === 'canvas')) {
+          console.warn('No canvas element found in program content, adding default canvas');
+          editorContent.value.elements.unshift({
+            type: 'canvas',
+            width: 1920,
+            height: 1080,
+            bg: { type: 'color', value: '#ffffff' },
+            depth: 0
+          });
+        }
+
+        // 更新容器尺寸
+        nextTick(updateContainerSize);
+      } catch (e) {
+        console.error('Failed to parse program content:', e);
+        ElMessage.error('节目内容解析失败: ' + (e as Error).message);
+      }
+    } else {
+      throw new Error(listRes.msg || '获取节目详情失败,未找到对应节目');
+    }
+  } catch (error) {
+    console.error('Error fetching program detail:', error);
+    ElMessage.error('获取节目详情失败: ' + (error as Error).message);
+  }
+};
+
+// 返回上一页
+const goBack = () => {
+  router.go(-1);
+};
+
+onMounted(async () => {
+  await fetchProgramDetail();
+  window.addEventListener('resize', updateContainerSize);
+
+  // 添加页面标题
+  document.title = `预览 - ${programName.value || '节目预览'}`;
+
+  return () => {
+    window.removeEventListener('resize', updateContainerSize);
+  };
+});
+</script>
+
+<style scoped>
+.preview-program-layout {
+  display: flex;
+  height: 100vh;
+  background-color: #f0f2f5;
+  overflow: hidden;
+}
+
+.main-preview {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding: 20px;
+  box-sizing: border-box;
+  overflow: hidden;
+}
+
+.preview-canvas {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: #f5f5f5;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  overflow: hidden;
+  margin-bottom: 16px;
+}
+
+.preview-controls {
+  display: flex;
+  justify-content: center;
+  padding: 16px 0;
+  background-color: #fff;
+  border-top: 1px solid #eee;
+}
+
+.back-btn {
+  min-width: 120px;
+}
+</style>

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

@@ -46,7 +46,7 @@ const canvasStyle = computed(() => {
       justifyContent: 'center',
       fontSize: '20px',
       color: '#aaa',
-      borderRadius: '12px',
+      borderRadius: '0px',
       boxShadow: '0 2px 8px rgba(0,0,0,0.08)'
     };
   } else if (isImg) {
@@ -63,7 +63,7 @@ const canvasStyle = computed(() => {
       justifyContent: 'center',
       fontSize: '20px',
       color: '#aaa',
-      borderRadius: '12px',
+      borderRadius: '0px',
       boxShadow: '0 2px 8px rgba(0,0,0,0.08)'
     };
   } else if (imgUrl) {
@@ -80,7 +80,7 @@ const canvasStyle = computed(() => {
       justifyContent: 'center',
       fontSize: '20px',
       color: '#aaa',
-      borderRadius: '12px',
+      borderRadius: '0px',
       boxShadow: '0 2px 8px rgba(0,0,0,0.08)'
     };
   } else {
@@ -94,7 +94,7 @@ const canvasStyle = computed(() => {
       justifyContent: 'center',
       fontSize: '20px',
       color: '#aaa',
-      borderRadius: '12px',
+      borderRadius: '0px',
       boxShadow: '0 2px 8px rgba(0,0,0,0.08)'
     };
   }

+ 6 - 4
smsb-plus-ui/src/views/smsb/itemProgram/component/LiveBoard.vue

@@ -8,7 +8,8 @@
     justifyContent: 'center',
     background: '#f8fafc',
     boxSizing: 'border-box',
-    border: selected ? '2px solid #409eff' : '1px solid #ddd'
+    border: selected ? '2px solid #409eff' : '1px solid #ddd',
+    borderRadius: props.borderRadius + 'px'
   }">
     <div class="live-board-content">
       <div class="live-icon">
@@ -38,13 +39,15 @@ interface Props {
   liveUrl?: string;
   playAudio?: boolean;
   selected?: boolean;
+  borderRadius?: number;
 }
 const props = withDefaults(defineProps<Props>(), {
   width: 200,
   height: 120,
   liveUrl: '',
   playAudio: true,
-  selected: false
+  selected: false,
+  borderRadius: 0
 });
 const selected = computed(() => !!props.selected);
 const width = computed(() => props.width);
@@ -91,8 +94,7 @@ function select() {
   -moz-user-select: none;
   -ms-user-select: none;
   background: #f8fafc;
-  border-radius: 10px;
-  transition: border-color 0.2s;
+  transition: all 0.2s;
   position: relative;
   box-sizing: border-box;
   display: flex;

+ 18 - 11
smsb-plus-ui/src/views/smsb/itemProgram/component/MediaAssetBoard.vue

@@ -8,16 +8,19 @@
     justifyContent: 'center',
     overflow: 'hidden',
     flexDirection: 'column',
-    gap: '8px',
-    padding: '8px'
+    // gap: '8px',
+    // padding: '8px',
+    borderRadius: props.borderRadius + 'px'
   }" @click="$emit('click', $event)">
     <!-- 预览窗口 -->
-    <div v-if="mediaItems.length > 0" class="preview-container">
+    <div v-if="mediaItems.length > 0" class="preview-container" :style="{ borderRadius: props.borderRadius + 'px' }">
       <template v-for="(item, index) in mediaItems" :key="index">
-        <div v-show="currentMediaIndex === index" class="media-item" :class="{ 'active': currentMediaIndex === index }">
-          <img v-if="item.type === 1" :src="item.url" :alt="item.name" class="media-content" />
+        <div v-show="currentMediaIndex === index" class="media-item" :class="{ 'active': currentMediaIndex === index }"
+          :style="{ borderRadius: props.borderRadius + 'px' }">
+          <img v-if="item.type === 1" :src="item.url" :alt="item.name" class="media-content"
+            :style="{ borderRadius: props.borderRadius + 'px' }" />
           <video v-else-if="item.type === 2" :src="item.url" class="media-content" preload="metadata" muted
-            @loadeddata="onVideoLoaded"></video>
+            @loadeddata="onVideoLoaded" :style="{ borderRadius: props.borderRadius + 'px' }"></video>
         </div>
       </template>
 
@@ -62,11 +65,13 @@ const props = withDefaults(
     selected?: boolean;
     readonly?: boolean;
     modelValue?: any;
+    borderRadius?: number;
   }>(),
   {
     selected: false,
     readonly: false,
-    modelValue: null
+    modelValue: null,
+    borderRadius: 0
   }
 );
 
@@ -264,15 +269,14 @@ function onResizeMouseDown(dir: string, e: MouseEvent) {
 .media-asset-board-wrapper {
   background: #f9fbfd;
   border: 2px dashed #b3c7e6;
-  border-radius: 10px;
-  transition: border-color 0.2s;
+  transition: all 0.2s;
   position: relative;
   overflow: hidden;
   box-sizing: border-box;
 }
 
 .select-hint {
-  margin-top: 8px;
+  /* margin-top: 8px; */
   font-size: 12px;
   color: #909399;
 }
@@ -288,6 +292,7 @@ function onResizeMouseDown(dir: string, e: MouseEvent) {
   display: flex;
   align-items: center;
   justify-content: center;
+  overflow: hidden;
 }
 
 .media-item {
@@ -297,6 +302,7 @@ function onResizeMouseDown(dir: string, e: MouseEvent) {
   display: flex;
   align-items: center;
   justify-content: center;
+  border-radius: inherit;
   opacity: 0;
   transition: opacity 0.5s ease-in-out;
 }
@@ -309,6 +315,7 @@ function onResizeMouseDown(dir: string, e: MouseEvent) {
   max-width: 100%;
   max-height: 100%;
   object-fit: contain;
+  border-radius: inherit;
 }
 
 .media-asset-icon {
@@ -328,7 +335,7 @@ function onResizeMouseDown(dir: string, e: MouseEvent) {
   right: 0;
   background-color: rgba(0, 0, 0, 0.5);
   color: white;
-  padding: 4px 8px;
+  /* padding: 4px 8px; */
   font-size: 12px;
   text-align: center;
   white-space: nowrap;

+ 3 - 1
smsb-plus-ui/src/views/smsb/itemProgram/component/ScrollingTextBoard.vue

@@ -24,6 +24,7 @@ interface Props {
   height?: number;
   selected?: boolean;
   speed?: number; // 滚动速度,px/秒
+  borderRadius?: number;
 }
 const props = defineProps<Props>();
 const selected = computed(() => !!props.selected);
@@ -42,7 +43,8 @@ const textStyle = computed(() => ({
   justifyContent: props.align === 'left' ? 'flex-start' : props.align === 'right' ? 'flex-end' : 'center',
   wordBreak: 'break-all',
   overflow: 'hidden',
-  position: 'relative'
+  position: 'relative',
+  borderRadius: props.borderRadius ? `${props.borderRadius}px` : '0',
 }));
 const scrollStyle = ref<any>({ position: 'absolute', transform: 'translateX(0px)', whiteSpace: 'nowrap' });
 let reqId: number | null = null;

+ 3 - 0
smsb-plus-ui/src/views/smsb/itemProgram/component/TextBoard.vue

@@ -21,6 +21,7 @@ interface Props {
   width?: number;
   height?: number;
   selected?: boolean;
+  borderRadius?: number;
 }
 const props = defineProps<Props>();
 const selected = computed(() => !!props.selected);
@@ -35,6 +36,8 @@ const textStyle = computed(() => ({
   alignItems: 'center',
   justifyContent: props.align === 'left' ? 'flex-start' : props.align === 'right' ? 'flex-end' : 'center',
   userSelect: 'none',
+  borderRadius: props.borderRadius ? `${props.borderRadius}px` : '0',
+  overflow: 'hidden',
 }));
 
 function onResizeMouseDown(e: MouseEvent, dir: string) {

+ 7 - 4
smsb-plus-ui/src/views/smsb/itemProgram/component/WebPageBoard.vue

@@ -5,7 +5,8 @@
     position: 'relative',
     display: 'flex',
     alignItems: 'center',
-    justifyContent: 'center'
+    justifyContent: 'center',
+    borderRadius: props.borderRadius + 'px'
   }">
     <div class="web-page-icon">
       <svg width="32" height="32" viewBox="0 0 32 32" fill="none">
@@ -30,11 +31,13 @@ interface Props {
   height?: number;
   url?: string;
   selected?: boolean;
+  borderRadius?: number;
 }
 const props = withDefaults(defineProps<Props>(), {
   width: 120,
   height: 120,
-  url: ''
+  url: '',
+  borderRadius: 0
 });
 const selected = computed(() => !!props.selected);
 
@@ -72,9 +75,9 @@ function onResizeMouseDown(dir: string, e: MouseEvent) {
 .web-page-board-wrapper {
   background: #f9fbfd;
   border: 2px dashed #b3c7e6;
-  border-radius: 10px;
-  transition: border-color 0.2s;
+  transition: all 0.2s;
   position: relative;
+  overflow: hidden;
 }
 
 .web-page-board-wrapper.selected {

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

@@ -13,7 +13,8 @@ export const textPropNameMap = {
   x: '横坐标',
   y: '纵坐标',
   width: '宽度',
-  height: '高度'
+  height: '高度',
+  borderRadius: '圆角半径'
 };
 
 export const scrollingTextPropNameMap = {
@@ -26,7 +27,8 @@ export const scrollingTextPropNameMap = {
   x: '横坐标',
   y: '纵坐标',
   width: '宽度',
-  height: '高度'
+  height: '高度',
+  borderRadius: '圆角半径'
 };
 
 export const mediaAssetPropNameMap = {
@@ -34,7 +36,8 @@ export const mediaAssetPropNameMap = {
   width: '宽度',
   height: '高度',
   x: '横坐标',
-  y: '纵坐标'
+  y: '纵坐标',
+  borderRadius: '圆角半径'
 };
 
 export const webPagePropNameMap = {
@@ -42,7 +45,8 @@ export const webPagePropNameMap = {
   width: '宽度',
   height: '高度',
   x: '横坐标',
-  y: '纵坐标'
+  y: '纵坐标',
+  borderRadius: '圆角半径'
 };
 
 export const clockPropNameMap = {
@@ -50,7 +54,8 @@ export const clockPropNameMap = {
   width: '宽度',
   height: '高度',
   x: '横坐标',
-  y: '纵坐标'
+  y: '纵坐标',
+  borderRadius: '圆角半径'
 };
 
 export const livePropNameMap = {
@@ -59,5 +64,6 @@ export const livePropNameMap = {
   width: '宽度',
   height: '高度',
   x: '横坐标',
-  y: '纵坐标'
+  y: '纵坐标',
+  borderRadius: '圆角半径'
 };

+ 15 - 0
smsb-plus-ui/src/views/smsb/itemProgram/index.vue

@@ -79,6 +79,9 @@
               <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)"
                 v-hasPermi="['system:itemProgram:remove']"></el-button>
             </el-tooltip>
+            <el-tooltip content="预览" placement="top">
+              <el-button link type="primary" icon="View" @click="handlePreview(scope.row)"></el-button>
+            </el-tooltip>
           </template>
         </el-table-column>
       </el-table>
@@ -120,8 +123,11 @@ import { listItemProgram, getItemProgram, delItemProgram, addItemProgram, update
 import { getUser, optionSelect, UserVO } from '@/api/system/user';
 import { useUserStore } from '@/store/modules/user';
 import { ItemProgramVO, ItemProgramQuery, ItemProgramForm } from '@/api/smsb/source/item_program_type';
+import { useRouter } from 'vue-router';
+import { getCurrentInstance, reactive, toRefs } from 'vue';
 
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
+const router = useRouter();
 
 const itemProgramList = ref<ItemProgramVO[]>([]);
 const userNickNameMap = reactive<Record<string, string>>({});
@@ -287,6 +293,15 @@ const handleExport = () => {
   );
 };
 
+/** 预览按钮操作 */
+const handlePreview = (row: any) => {
+  console.log('Preview program:', row);
+  router.push({
+    name: 'PreviewItemProgram',
+    params: { id: row.programId }
+  });
+};
+
 onMounted(() => {
   getList();
 });