ソースを参照

fix: 修复装修部分组件兼容问题

叶静 1 週間 前
コミット
8cbbcd88c5

BIN
public/static/images/shop/decorate/title-1.png


+ 2 - 2
src/app/shop/admin/decorate/page/component/center/comp/goodsCard/setting.vue

@@ -107,7 +107,7 @@ async function loadGoodsFromIds() {
 
       // 调用商品列表API,传入ids参数
       const { code, data } = await api.goods.list({
-        ids: props.settingData.data.goodsIds.join(','),
+        ids: props.settingData.data.goodsIds,
         pageSize: 100 // 设置足够大的页面大小确保获取所有商品
       });
 
@@ -169,7 +169,7 @@ function initDefaultValues() {
     }
 
     // 通过goodsIds加载商品详情
-    loadGoodsFromIds();
+    // loadGoodsFromIds();
   }
 }
 

+ 206 - 2
src/app/shop/admin/decorate/page/component/center/comp/richtext/index.vue

@@ -1,9 +1,213 @@
 <template>
   <div class="richtext">
-    <div v-if="compData.data.richtext" v-html="compData.data.richtext.content"></div>
+    <!-- 显示富文本内容 -->
+    <div v-if="hasContent" class="richtext-content" v-html="compData.data.content"></div>
+
+    <!-- 默认占位符 -->
+    <div v-else class="richtext-placeholder">
+      <el-icon size="32">
+        <Document />
+      </el-icon>
+      <div class="placeholder-text">富文本内容</div>
+      <div class="placeholder-subtext">请在右侧设置中编辑富文本内容</div>
+    </div>
   </div>
 </template>
 
 <script setup>
-  const props = defineProps(['compData']);
+import { computed, watch } from 'vue';
+import { Document } from '@element-plus/icons-vue';
+
+const props = defineProps(['compData']);
+
+// 计算是否有内容
+const hasContent = computed(() => {
+  const content = props.compData?.data?.content;
+  console.log('富文本内容检查:', content);
+  return content && content.trim() && content !== '<p></p>' && content !== '<p><br></p>';
+});
+
+// 监听数据变化
+watch(() => props.compData?.data?.content, (newContent) => {
+  console.log('富文本内容变化:', newContent);
+}, { immediate: true });
 </script>
+
+<style lang="scss" scoped>
+.richtext {
+  min-height: 60px;
+
+  .richtext-content {
+    line-height: 1.6;
+    color: var(--el-text-color-primary);
+
+    // 富文本内容样式
+    :deep(h1),
+    :deep(h2),
+    :deep(h3),
+    :deep(h4),
+    :deep(h5),
+    :deep(h6) {
+      margin: 16px 0 8px 0;
+      font-weight: bold;
+      color: var(--el-text-color-primary);
+    }
+
+    :deep(h1) {
+      font-size: 28px;
+    }
+
+    :deep(h2) {
+      font-size: 24px;
+    }
+
+    :deep(h3) {
+      font-size: 20px;
+    }
+
+    :deep(h4) {
+      font-size: 18px;
+    }
+
+    :deep(h5) {
+      font-size: 16px;
+    }
+
+    :deep(h6) {
+      font-size: 14px;
+    }
+
+    :deep(p) {
+      margin: 8px 0;
+      line-height: 1.6;
+    }
+
+    :deep(ul),
+    :deep(ol) {
+      margin: 12px 0;
+      padding-left: 24px;
+    }
+
+    :deep(li) {
+      margin: 4px 0;
+      line-height: 1.5;
+    }
+
+    :deep(a) {
+      color: var(--el-color-primary);
+      text-decoration: underline;
+
+      &:hover {
+        color: var(--el-color-primary-dark-2);
+      }
+    }
+
+    :deep(img) {
+      max-width: 100%;
+      height: auto;
+      margin: 8px 0;
+      border-radius: 4px;
+    }
+
+    :deep(blockquote) {
+      margin: 16px 0;
+      padding: 12px 16px;
+      background: var(--el-fill-color-light);
+      border-left: 4px solid var(--el-color-primary);
+      border-radius: 4px;
+    }
+
+    :deep(code) {
+      background: var(--el-fill-color-light);
+      padding: 2px 4px;
+      border-radius: 3px;
+      font-family: 'Courier New', monospace;
+      font-size: 0.9em;
+    }
+
+    :deep(pre) {
+      background: var(--el-fill-color-light);
+      padding: 12px;
+      border-radius: 4px;
+      overflow-x: auto;
+
+      code {
+        background: none;
+        padding: 0;
+      }
+    }
+
+    :deep(table) {
+      width: 100%;
+      border-collapse: collapse;
+      margin: 16px 0;
+
+      th,
+      td {
+        border: 1px solid var(--el-border-color);
+        padding: 8px 12px;
+        text-align: left;
+      }
+
+      th {
+        background: var(--el-fill-color-light);
+        font-weight: bold;
+      }
+    }
+
+    :deep(hr) {
+      margin: 20px 0;
+      border: none;
+      border-top: 1px solid var(--el-border-color);
+    }
+
+    // 强调样式
+    :deep(strong),
+    :deep(b) {
+      font-weight: bold;
+    }
+
+    :deep(em),
+    :deep(i) {
+      font-style: italic;
+    }
+
+    :deep(u) {
+      text-decoration: underline;
+    }
+
+    :deep(del),
+    :deep(s) {
+      text-decoration: line-through;
+    }
+  }
+
+  .richtext-placeholder {
+    min-height: 120px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    color: var(--el-text-color-secondary);
+    background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
+    border-radius: 8px;
+
+    .el-icon {
+      margin-bottom: 12px;
+      opacity: 0.6;
+    }
+
+    .placeholder-text {
+      font-size: 16px;
+      font-weight: 500;
+      margin-bottom: 4px;
+      color: var(--el-text-color-primary);
+    }
+
+    .placeholder-subtext {
+      font-size: 12px;
+      color: var(--el-text-color-secondary);
+    }
+  }
+}
+</style>

+ 83 - 0
src/app/shop/admin/decorate/page/component/center/comp/richtext/richtext-editor.vue

@@ -0,0 +1,83 @@
+<template>
+  <el-container class="richtext-editor-container">
+    <el-main>
+      <el-form :model="form" ref="formRef" label-width="80px">
+        <el-form-item label="富文本内容">
+          <sa-editor v-model:content="form.content" :direct-upload="true" />
+        </el-form-item>
+      </el-form>
+    </el-main>
+
+    <el-footer class="sa-footer--submit">
+      <el-button @click="cancel">取消</el-button>
+      <el-button type="primary" @click="confirm">确定</el-button>
+    </el-footer>
+  </el-container>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted, nextTick } from 'vue';
+import { ElMessage } from 'element-plus';
+import saEditor from '@/sheep/components/sa-editor/sa-editor.vue';
+
+const emit = defineEmits(['modalCallBack']);
+const props = defineProps({
+  modal: {
+    type: Object,
+  },
+});
+
+const formRef = ref();
+const form = reactive({
+  content: '',
+});
+
+// 初始化数据
+onMounted(() => {
+  if (props.modal?.params?.content) {
+    nextTick(() => {
+      form.content = props.modal.params.content;
+    })
+  } else {
+    console.log('弹窗编辑器:没有接收到 modal.content');
+  }
+});
+
+// 确认保存
+const confirm = () => {
+  if (!form.content || !form.content.trim()) {
+    ElMessage.warning('请输入富文本内容');
+    return;
+  }
+
+  console.log('弹窗编辑器保存内容:', form.content);
+
+  emit('modalCallBack', {
+    event: 'confirm',
+    data: {
+      content: form.content,
+    }
+  });
+};
+
+// 取消
+const cancel = () => {
+  emit('modalCallBack', { event: 'cancel' });
+};
+</script>
+
+<style lang="scss" scoped>
+.richtext-editor-container {
+  .el-main {
+    max-height: 70vh;
+    overflow-y: auto;
+    padding: 20px;
+  }
+
+  .sa-footer--submit {
+    padding: 16px 20px;
+    border-top: 1px solid var(--el-border-color);
+    text-align: right;
+  }
+}
+</style>

+ 208 - 25
src/app/shop/admin/decorate/page/component/center/comp/richtext/setting.vue

@@ -2,15 +2,30 @@
   <div class="setting">
     <template v-if="tabType == 'data'">
       <div class="card">
-        <div class="title">富文本样式</div>
+        <div class="title">富文本内容</div>
         <div class="content">
-          <el-form-item label="富文本">
-            <el-input v-model="settingData.data.title">
-              <template #append>
-                <span class="cursor-pointer" @click="onSelect">选择</span>
-              </template>
-            </el-input>
+          <el-form-item label="富文本内容">
+            <div class="richtext-preview-container">
+              <!-- 内容预览 -->
+              <div class="content-preview" v-if="settingData.data.content" v-html="settingData.data.content"></div>
+              <div class="content-placeholder" v-else>
+                <el-icon size="24">
+                  <Document />
+                </el-icon>
+                <span>暂无富文本内容</span>
+              </div>
+
+              <!-- 编辑按钮 -->
+              <div class="edit-actions">
+                <el-button type="primary" @click="openEditor" icon="Edit">编辑富文本内容</el-button>
+                <el-button v-if="settingData.data.content" @click="clearContent" icon="Delete">清空内容</el-button>
+              </div>
+            </div>
           </el-form-item>
+
+          <div class="form-tip">
+            点击"编辑富文本内容"按钮在弹窗中编辑,支持完整的富文本编辑功能
+          </div>
         </div>
       </div>
     </template>
@@ -18,23 +33,191 @@
 </template>
 
 <script setup>
-  import { useModal } from '@/sheep/hooks';
-  import RichtextSelect from '@/app/shop/admin/data/richtext/select.vue';
-  const props = defineProps(['settingData', 'tabType']);
-
-  function onSelect() {
-    useModal(
-      RichtextSelect,
-      {
-        title: '选择富文本',
-      },
-      {
-        confirm: (res) => {
-          props.settingData.data.id = res.data.id;
-          props.settingData.data.title = res.data.title;
-          props.settingData.data.richtext = res.data;
-        },
-      },
-    );
+import { ref, onMounted } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import { Document, Edit, Delete } from '@element-plus/icons-vue';
+import { useModal } from '@/sheep/hooks';
+import RichtextEditor from './richtext-editor.vue';
+
+const props = defineProps(['settingData', 'tabType']);
+
+// 初始化默认内容
+onMounted(() => {
+  if (!props.settingData.data.content) {
+    props.settingData.data.content = '';
   }
+});
+
+// 打开富文本编辑器
+const openEditor = () => {
+  console.log('打开编辑器,当前内容:', props.settingData.data.content);
+
+  useModal(
+    RichtextEditor,
+    {
+      title: '编辑富文本内容',
+      width: '80%',
+      content: props.settingData.data.content || '',
+    },
+    {
+      confirm: (result) => {
+        console.log('接收到编辑器返回的数据:', result);
+        const content = result.data?.content || result.content;
+        props.settingData.data.content = content;
+        console.log('更新后的内容:', props.settingData.data.content);
+        ElMessage.success('富文本内容已更新');
+      },
+    }
+  );
+};
+
+// 清空内容
+const clearContent = () => {
+  ElMessageBox.confirm('确定要清空富文本内容吗?', '提示', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'warning',
+  }).then(() => {
+    props.settingData.data.content = '';
+    ElMessage.success('内容已清空');
+  }).catch(() => {
+    // 用户取消
+  });
+};
 </script>
+
+<style lang="scss" scoped>
+.richtext-preview-container {
+  border: 1px solid var(--el-border-color);
+  border-radius: 6px;
+  overflow: hidden;
+  background: var(--el-fill-color-lighter);
+
+  .content-preview {
+    padding: 16px;
+    min-height: 120px;
+    max-height: 200px;
+    overflow-y: auto;
+    background: white;
+    line-height: 1.6;
+    font-size: 14px;
+
+    :deep(h1),
+    :deep(h2),
+    :deep(h3),
+    :deep(h4),
+    :deep(h5),
+    :deep(h6) {
+      margin: 12px 0 8px 0;
+      font-weight: bold;
+      color: var(--el-text-color-primary);
+    }
+
+    :deep(h1) {
+      font-size: 20px;
+    }
+
+    :deep(h2) {
+      font-size: 18px;
+    }
+
+    :deep(h3) {
+      font-size: 16px;
+    }
+
+    :deep(p) {
+      margin: 8px 0;
+      line-height: 1.6;
+    }
+
+    :deep(ul),
+    :deep(ol) {
+      margin: 8px 0;
+      padding-left: 20px;
+    }
+
+    :deep(li) {
+      margin: 4px 0;
+    }
+
+    :deep(a) {
+      color: var(--el-color-primary);
+      text-decoration: underline;
+    }
+
+    :deep(img) {
+      max-width: 100%;
+      height: auto;
+      margin: 8px 0;
+      border-radius: 4px;
+    }
+
+    :deep(blockquote) {
+      margin: 12px 0;
+      padding: 8px 12px;
+      background: var(--el-fill-color-light);
+      border-left: 3px solid var(--el-color-primary);
+      border-radius: 4px;
+    }
+
+    :deep(code) {
+      background: var(--el-fill-color-light);
+      padding: 2px 4px;
+      border-radius: 3px;
+      font-family: 'Courier New', monospace;
+      font-size: 0.9em;
+    }
+
+    :deep(table) {
+      width: 100%;
+      border-collapse: collapse;
+      margin: 12px 0;
+
+      th,
+      td {
+        border: 1px solid var(--el-border-color);
+        padding: 6px 8px;
+        text-align: left;
+        font-size: 13px;
+      }
+
+      th {
+        background: var(--el-fill-color-light);
+        font-weight: bold;
+      }
+    }
+  }
+
+  .content-placeholder {
+    padding: 40px 16px;
+    text-align: center;
+    color: var(--el-text-color-secondary);
+    background: white;
+
+    .el-icon {
+      margin-bottom: 8px;
+      opacity: 0.6;
+    }
+
+    span {
+      font-size: 14px;
+    }
+  }
+
+  .edit-actions {
+    padding: 12px 16px;
+    background: var(--el-fill-color-light);
+    border-top: 1px solid var(--el-border-color);
+    display: flex;
+    gap: 8px;
+    justify-content: flex-start;
+  }
+}
+
+.form-tip {
+  margin-top: 8px;
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+  line-height: 1.4;
+}
+</style>

+ 80 - 77
src/app/shop/admin/decorate/page/component/center/comp/titleBlock/select.vue

@@ -3,11 +3,8 @@
     <el-main>
       <el-row :gutter="16">
         <el-col :xs="12" :sm="8" :md="8" :lg="6" :xl="6" v-for="sl in styleList" :key="sl">
-          <div
-            class="style-item sa-flex sa-row-center"
-            :class="sl.src == props.modal.params.src ? 'style-item--active' : ''"
-            @click="changeStyle(sl)"
-          >
+          <div class="style-item sa-flex sa-row-center"
+            :class="sl.src == props.modal.params.src ? 'style-item--active' : ''" @click="changeStyle(sl)">
             <sa-image :url="sl.src"></sa-image>
             <div class="title sa-flex sa-row-center">标题栏</div>
             <div class="check-icon sa-flex sa-row-center">
@@ -26,90 +23,96 @@
 </template>
 
 <script>
-  export default {
-    name: 'TitleBlockSelect',
-  };
+export default {
+  name: 'TitleBlockSelect',
+};
 </script>
 
 <script setup>
-  import { computed } from 'vue';
-  import { checkUrl } from '@/sheep/utils/checkUrlSuffix';
+import { computed } from 'vue';
+import { checkUrl } from '@/sheep/utils/checkUrlSuffix';
 
-  const emit = defineEmits(['modalCallBack']);
-  const props = defineProps(['modal']);
+const emit = defineEmits(['modalCallBack']);
+const props = defineProps(['modal']);
 
-  const activeStyle = computed(() => styleList.find((s) => s.src == props.modal.params.src));
-  const styleList = [
-    {
-      type: 1,
-      src: checkUrl('/static/img/shop/decorate/title-1.png'),
-      location: 'left',
-      skew: 0,
-      title: {
-        text: '标题栏111',
-        textColor: '',
-        textFontSize: 14,
-        other: [],
-      },
-      subtitle: {
-        text: '标题栏',
-        textColor: '',
-        textFontSize: 12,
-        other: [],
-      },
-      more: {
-        show: 1,
-        path: '',
-      },
+const activeStyle = computed(() => styleList.find((s) => s.src == props.modal.params.src));
+const styleList = [
+  {
+    type: 1,
+    src: checkUrl('/static/images/shop/decorate/title-1.png'),
+    location: 'left',
+    skew: 0,
+    title: {
+      text: '标题栏111',
+      textColor: '',
+      textFontSize: 14,
+      other: [],
     },
-  ];
-  function changeStyle(url) {
-    props.modal.params.src = url.src;
-  }
-  // 表单关闭时提交
-  async function confirm() {
-    emit('modalCallBack', { event: 'confirm', data: activeStyle.value });
-  }
+    subtitle: {
+      text: '标题栏',
+      textColor: '',
+      textFontSize: 12,
+      other: [],
+    },
+    more: {
+      show: 1,
+      path: '',
+    },
+  },
+];
+function changeStyle(url) {
+  props.modal.params.src = url.src;
+}
+// 表单关闭时提交
+async function confirm() {
+  emit('modalCallBack', { event: 'confirm', data: activeStyle.value });
+}
 </script>
 
 <style lang="scss" scoped>
-  .title-block-select {
-    .style-item {
-      height: 120px;
-      border: 1px solid var(--sa-border);
-      border-radius: 4px;
-      position: relative;
-      .sa-image {
-        width: 100%;
-      }
-      .title {
-        position: absolute;
-        top: 0;
-        right: 0;
-        bottom: 0;
-        left: 0;
-      }
+.title-block-select {
+  .style-item {
+    height: 120px;
+    border: 1px solid var(--sa-border);
+    border-radius: 4px;
+    position: relative;
+
+    .sa-image {
+      width: 100%;
+    }
+
+    .title {
+      position: absolute;
+      top: 0;
+      right: 0;
+      bottom: 0;
+      left: 0;
+    }
+
+    .check-icon {
+      display: none;
+    }
+
+    &--active {
+      border-color: var(--el-color-primary);
+
       .check-icon {
-        display: none;
-      }
-      &--active {
-        border-color: var(--el-color-primary);
-        .check-icon {
-          display: flex;
-          width: 16px;
-          height: 16px;
-          border-radius: 50%;
-          background: var(--el-color-primary);
-          color: #fff;
-          position: absolute;
-          top: -7px;
-          right: -7px;
-          z-index: 10;
-          .el-icon {
-            font-size: 14px;
-          }
+        display: flex;
+        width: 16px;
+        height: 16px;
+        border-radius: 50%;
+        background: var(--el-color-primary);
+        color: #fff;
+        position: absolute;
+        top: -7px;
+        right: -7px;
+        z-index: 10;
+
+        .el-icon {
+          font-size: 14px;
         }
       }
     }
   }
+}
 </style>

+ 134 - 16
src/app/shop/admin/decorate/page/component/center/comp/videoPlayer/index.vue

@@ -1,31 +1,149 @@
 <template>
-  <div
-    class="video-player"
-    :style="{
-      height: compData.style.height + 'px',
-    }"
-  >
-    <video v-if="!compData.data.src" controls>
-      <source :src="compData.data.videoUrl" />
-    </video>
-    <sa-image :url="compData.data.src" fit="fill" :suffix="null" />
+  <div class="video-player" :style="{
+    height: compData.style.height + 'px',
+  }">
+    <!-- 当有视频时显示视频播放器 -->
+    <div v-if="compData.data.videoUrl" class="video-container">
+      <video :src="compData.data.videoUrl" :poster="compData.data.src" controls preload="metadata"
+        class="video-element">
+        您的浏览器不支持视频播放。
+      </video>
+    </div>
+
+    <!-- 当只有封面图片时显示封面 -->
+    <div v-else-if="compData.data.src" class="cover-container">
+      <sa-image :url="compData.data.src" fit="cover" :suffix="null" class="cover-image" />
+      <div class="no-video-tip">
+        <el-icon size="32">
+          <VideoCamera />
+        </el-icon>
+        <div class="tip-text">请上传视频</div>
+      </div>
+    </div>
+
+    <!-- 默认占位符 -->
+    <div v-else class="placeholder-container">
+      <el-icon size="48">
+        <VideoCamera />
+      </el-icon>
+      <div class="placeholder-text">视频播放器</div>
+      <div class="placeholder-subtext">请在右侧设置中上传视频</div>
+    </div>
   </div>
 </template>
 
 <script setup>
-  const props = defineProps(['compData']);
+import { VideoCamera } from '@element-plus/icons-vue';
+
+const props = defineProps(['compData']);
 </script>
 
 <style lang="scss" scoped>
-  .video-player {
-    height: 200px;
-    background: #f3f3f3;
-    .sa-image {
+.video-player {
+  height: 200px;
+  background: #f3f3f3;
+  border-radius: 8px;
+  overflow: hidden;
+  position: relative;
+
+  .video-container {
+    width: 100%;
+    height: 100%;
+    position: relative;
+
+    .video-element {
+      width: 100%;
       height: 100%;
+      object-fit: cover;
+      background: #000;
     }
-    video {
+  }
+
+  .cover-container {
+    width: 100%;
+    height: 100%;
+    position: relative;
+
+    .cover-image {
       width: 100%;
       height: 100%;
     }
+
+    .no-video-tip {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      background: rgba(0, 0, 0, 0.7);
+      color: white;
+      padding: 16px;
+      border-radius: 8px;
+      text-align: center;
+      backdrop-filter: blur(4px);
+
+      .tip-text {
+        margin-top: 8px;
+        font-size: 14px;
+        font-weight: 500;
+      }
+    }
+  }
+
+  .placeholder-container {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    color: var(--el-text-color-secondary);
+    background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
+
+    .el-icon {
+      margin-bottom: 12px;
+      opacity: 0.6;
+    }
+
+    .placeholder-text {
+      font-size: 16px;
+      font-weight: 500;
+      margin-bottom: 4px;
+      color: var(--el-text-color-primary);
+    }
+
+    .placeholder-subtext {
+      font-size: 12px;
+      color: var(--el-text-color-secondary);
+    }
+  }
+
+  // 响应式设计
+  @media (max-width: 768px) {
+    .no-video-tip {
+      padding: 12px;
+
+      .el-icon {
+        font-size: 24px;
+      }
+
+      .tip-text {
+        font-size: 12px;
+      }
+    }
+
+    .placeholder-container {
+      .el-icon {
+        font-size: 36px;
+      }
+
+      .placeholder-text {
+        font-size: 14px;
+      }
+
+      .placeholder-subtext {
+        font-size: 11px;
+      }
+    }
   }
+}
 </style>

+ 17 - 21
src/app/shop/admin/decorate/page/component/center/comp/videoPlayer/setting.vue

@@ -4,18 +4,20 @@
       <div class="card">
         <div class="title">内容设置</div>
         <div class="content">
-          <el-form-item label="视频链接">
-            <el-input v-model="settingData.data.videoUrl">
-              <template #append>
-                <span class="cursor-pointer" @click="selectVideo">选择</span>
-              </template>
-            </el-input>
+          <el-form-item label="上传视频">
+            <div class="sa-flex">
+              <sa-upload-video v-model="settingData.data.videoUrl" :max-count="1"
+                :accept="['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm']" :max-size="100" :direct-upload="true" :size="120"
+                placeholder="点击上传视频" />
+            </div>
+            <div class="form-tip">支持 MP4、AVI、MOV、WMV、FLV、WebM 格式,文件大小不超过100MB</div>
           </el-form-item>
           <el-form-item label="视频封面">
             <div class="sa-flex">
               <sa-upload-image v-model="settingData.data.src" :max-count="1" :accept="['jpg', 'jpeg', 'png']"
-                :max-size="5" :direct-upload="true" :size="100" />
+                :max-size="5" :direct-upload="true" :size="100" placeholder="上传视频封面" />
             </div>
+            <div class="form-tip">视频封面图片,建议尺寸:16:9 比例</div>
           </el-form-item>
         </div>
       </div>
@@ -24,20 +26,14 @@
 </template>
 
 <script setup>
-import { useFile } from '@/sheep/components/sa-file/sa-file-modal.vue';
-
 const props = defineProps(['settingData', 'tabType']);
+</script>
 
-function selectVideo() {
-  useFile(
-    {
-      fileType: 'video',
-    },
-    {
-      confirm: (data) => {
-        props.settingData.data.videoUrl = data.url;
-      },
-    },
-  );
+<style lang="scss" scoped>
+.form-tip {
+  margin-top: 4px;
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+  line-height: 1.4;
 }
-</script>
+</style>

+ 2 - 2
src/app/shop/admin/decorate/page/component/center/page/setting.vue

@@ -67,11 +67,11 @@
     <div class="card">
       <div class="title sa-flex">
         头部内容
-        <div class="warning">
+        <!-- <div class="warning">
           可切换到
           {{ platformType == 'WechatMiniProgram' ? '其他' : '小程序' }}
           平台设置独立样式
-        </div>
+        </div> -->
       </div>
       <div class="content">
         <table class="navbar-map">

+ 1 - 1
src/app/shop/admin/decorate/page/component/right/index.vue

@@ -183,7 +183,7 @@ watch(
 <style lang="scss" scoped>
 .page-right {
   flex-shrink: 0;
-  width: 344px;
+  width: 400px;
   height: 100%;
   background: var(--sa-background-assist);
   box-shadow: 0px 0px 0.24rem rgb(0 0 0 / 16%);

+ 22 - 22
src/app/shop/admin/decorate/page/data.js

@@ -54,16 +54,16 @@ export const systemList = [
 
 // 平台列表
 export const platformList = [
-  {
-    type: 'WechatMiniProgram',
-    label: '微信小程序',
-    color: '#6F74E9',
-  },
-  {
-    type: 'WechatOfficialAccount',
-    label: '微信公众号',
-    color: '#07C160',
-  },
+  // {
+  //   type: 'WechatMiniProgram',
+  //   label: '微信小程序',
+  //   color: '#6F74E9',
+  // },
+  // {
+  //   type: 'WechatOfficialAccount',
+  //   label: '微信公众号',
+  //   color: '#07C160',
+  // },
   {
     type: 'H5',
     label: 'H5',
@@ -310,7 +310,7 @@ export function cloneComponent(type, theme = 'orange') {
       type: 'noticeBlock',
       data: {
         mode: 1,
-        src: checkUrl('/static/img/shop/decorate/notice-1.png'),
+        src: checkUrl('/static/images/shop/decorate/notice-1.png'),
         title: {
           text: '',
           color: '#111111',
@@ -604,7 +604,7 @@ export function cloneComponent(type, theme = 'orange') {
     titleBlock: {
       type: 'titleBlock',
       data: {
-        src: checkUrl('/static/img/shop/decorate/title-1.png'),
+        src: checkUrl('/static/images/shop/decorate/title-1.png'),
         location: 'left', // left=居左 center=居中
         skew: 0,
         title: {
@@ -716,8 +716,8 @@ export function cloneComponent(type, theme = 'orange') {
     richtext: {
       type: 'richtext',
       data: {
-        id: '',
-        title: '',
+        title: '富文本内容',
+        content: '<p>请输入富文本内容...</p>',
       },
       style: {
         background: {
@@ -839,13 +839,13 @@ export function handleTempData(data) {
         t.data.goodsList = [];
       }
     } else if (t.type == 'richtext') {
-      const { error, data } = await dataApi.richtext.select(
-        {
-          search: JSON.stringify({ id: [t.data.id, 'in'] }),
-        },
-        'find',
-      );
-      t.data.richtext = error === 0 ? data : [];
+      // 富文本组件现在直接使用 content 字段,无需额外处理
+      if (!t.data.content) {
+        t.data.content = '<p>请输入富文本内容...</p>';
+      }
+      if (!t.data.title) {
+        t.data.title = '富文本内容';
+      }
     }
   });
 
@@ -862,7 +862,7 @@ export function handleSubmitData(data) {
       });
       delete t.data.goodsList;
     } else if (t.type == 'richtext') {
-      delete t.data.richtext;
+      // 富文本组件保留 content 和 title 字段,无需删除任何数据
     }
   });
 

+ 458 - 0
src/sheep/components/sa-uploader/sa-upload-video.global.vue

@@ -0,0 +1,458 @@
+<template>
+  <div class="sa-upload-video">
+    <div class="upload-container sa-flex sa-flex-wrap">
+      <!-- 已上传的视频列表 -->
+      <div v-if="videoList.length > 0" class="sa-flex sa-flex-wrap">
+        <div v-for="(video, index) in videoList" :key="video" class="video-item mr-2px"
+          :style="{ width: size + 'px', height: size + 'px' }">
+          <div class="video-preview" :style="{ width: size + 'px', height: size + 'px' }">
+            <video :src="video" :style="{ width: '100%', height: '100%', objectFit: 'cover' }" 
+              @click="previewVideo(video, index)" muted>
+            </video>
+            <div class="video-play-icon" @click="previewVideo(video, index)">
+              <el-icon size="24">
+                <VideoPlay />
+              </el-icon>
+            </div>
+          </div>
+          <div class="video-mask">
+            <el-icon @click="previewVideo(video, index)" :title="t('common.preview')" size="24">
+              <ZoomIn />
+            </el-icon>
+            <el-icon @click="removeVideo(index)" :title="t('common.delete')" size="24">
+              <Delete />
+            </el-icon>
+          </div>
+        </div>
+      </div>
+
+      <!-- 上传按钮 -->
+      <div v-if="!maxCount || videoList.length < maxCount" class="upload-wrapper"
+        :style="{ width: size + 'px', height: size + 'px' }">
+        <div class="upload-trigger" :class="{ 'is-uploading': uploading }"
+          :style="{ width: size + 'px', height: size + 'px' }" @click="handleUploadClick">
+          <el-icon class="upload-icon" size="24">
+            <VideoCamera />
+          </el-icon>
+          <div class="upload-text">{{ placeholder || t('common.uploadVideo') }}</div>
+        </div>
+
+        <!-- 自定义loading遮罩 -->
+        <div v-if="uploading" class="upload-loading">
+          <div class="loading-spinner"></div>
+          <div class="loading-text">{{ t('common.uploading') }}</div>
+        </div>
+      </div>
+
+      <!-- 隐藏的文件输入框 -->
+      <input ref="fileInputRef" type="file" :accept="acceptString" :multiple="multiple" style="display: none"
+        @change="handleFileSelect" />
+    </div>
+
+    <!-- 提示信息 -->
+    <div v-if="showTip" class="upload-tip">
+      <span v-if="maxCount">{{ t('common.maxUpload', { count: maxCount }) }},</span>
+      <span v-if="accept && accept.length">{{ t('common.supportFormats', { formats: accept.join('、') }) }},</span>
+      <span v-if="maxSize">{{ t('common.maxFileSize', { size: maxSize }) }}</span>
+    </div>
+
+    <!-- 视频预览弹窗 -->
+    <el-dialog v-model="previewVisible" title="视频预览" width="80%" :before-close="closePreview">
+      <div class="video-preview-container">
+        <video v-if="previewVideoUrl" :src="previewVideoUrl" controls style="width: 100%; height: auto;">
+          您的浏览器不支持视频播放。
+        </video>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { ref, computed, watch } from 'vue';
+import { ElMessage, ElDialog } from 'element-plus';
+import { VideoCamera, VideoPlay, ZoomIn, Delete } from '@element-plus/icons-vue';
+import adminApi from '@/app/admin/api';
+
+export default {
+  name: 'SaUploadVideo',
+  components: {
+    VideoCamera,
+    VideoPlay,
+    ZoomIn,
+    Delete,
+    ElDialog,
+  },
+};
+</script>
+
+<script setup>
+import { useI18n } from 'vue-i18n';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  modelValue: {
+    type: Array,
+    default: () => [],
+  },
+  // 是否直传,默认为true
+  directUpload: {
+    type: Boolean,
+    default: true,
+  },
+  // 最大上传数量
+  maxCount: {
+    type: Number,
+    default: 1,
+  },
+  // 支持的文件格式
+  accept: {
+    type: Array,
+    default: () => ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'],
+  },
+  // 最大文件大小(MB)
+  maxSize: {
+    type: Number,
+    default: 50,
+  },
+  // 是否支持多选
+  multiple: {
+    type: Boolean,
+    default: false,
+  },
+  // 视频预览尺寸
+  size: {
+    type: Number,
+    default: 100,
+  },
+  // 占位符文本
+  placeholder: {
+    type: String,
+    default: '',
+  },
+  // 是否显示提示信息
+  showTip: {
+    type: Boolean,
+    default: true,
+  },
+});
+
+const emit = defineEmits(['update:modelValue', 'change', 'upload-success', 'upload-error']);
+
+// 响应式数据
+const fileInputRef = ref();
+const uploading = ref(false);
+const previewVisible = ref(false);
+const previewVideoUrl = ref('');
+
+// 计算属性
+const videoList = computed({
+  get: () => {
+    const value = props.modelValue;
+    if (Array.isArray(value)) {
+      return value;
+    } else if (typeof value === 'string' && value) {
+      return [value];
+    }
+    return [];
+  },
+  set: (newValue) => {
+    if (props.maxCount === 1) {
+      emit('update:modelValue', newValue.length > 0 ? newValue[0] : '');
+    } else {
+      emit('update:modelValue', newValue);
+    }
+    emit('change', newValue);
+  },
+});
+
+const acceptString = computed(() => {
+  return props.accept.map(type => `.${type}`).join(',');
+});
+
+// 方法
+const handleUploadClick = () => {
+  if (uploading.value) return;
+  fileInputRef.value?.click();
+};
+
+const handleFileSelect = async (event) => {
+  const files = Array.from(event.target.files);
+  if (files.length === 0) return;
+
+  // 验证文件
+  for (const file of files) {
+    if (!validateFile(file)) {
+      return;
+    }
+  }
+
+  // 上传文件
+  await uploadFiles(files);
+  
+  // 清空input
+  event.target.value = '';
+};
+
+const validateFile = (file) => {
+  // 检查文件类型
+  const fileExtension = file.name.split('.').pop().toLowerCase();
+  if (!props.accept.includes(fileExtension)) {
+    ElMessage.error(`不支持的文件格式,请上传 ${props.accept.join('、')} 格式的视频`);
+    return false;
+  }
+
+  // 检查文件大小
+  const fileSizeMB = file.size / 1024 / 1024;
+  if (fileSizeMB > props.maxSize) {
+    ElMessage.error(`文件大小不能超过 ${props.maxSize}MB`);
+    return false;
+  }
+
+  // 检查数量限制
+  if (props.maxCount && videoList.value.length >= props.maxCount) {
+    ElMessage.error(`最多只能上传 ${props.maxCount} 个视频`);
+    return false;
+  }
+
+  return true;
+};
+
+const uploadFiles = async (files) => {
+  uploading.value = true;
+  
+  try {
+    const uploadPromises = files.map(file => uploadSingleFile(file));
+    const results = await Promise.all(uploadPromises);
+    
+    const successResults = results.filter(result => result.success);
+    if (successResults.length > 0) {
+      const newUrls = successResults.map(result => result.url);
+      videoList.value = [...videoList.value, ...newUrls];
+      emit('upload-success', newUrls);
+      ElMessage.success(`成功上传 ${successResults.length} 个视频`);
+    }
+    
+    const failedResults = results.filter(result => !result.success);
+    if (failedResults.length > 0) {
+      emit('upload-error', failedResults);
+      ElMessage.error(`${failedResults.length} 个视频上传失败`);
+    }
+  } catch (error) {
+    console.error('上传视频失败:', error);
+    ElMessage.error('上传视频失败,请重试');
+    emit('upload-error', error);
+  } finally {
+    uploading.value = false;
+  }
+};
+
+const uploadSingleFile = async (file) => {
+  try {
+    const formData = new FormData();
+    formData.append('file', file);
+    
+    const response = await adminApi.file.upload({ group: 'video', savelog: 1 }, formData);
+    
+    if (response.data) {
+      return {
+        success: true,
+        url: response.data,
+        file: file,
+      };
+    } else {
+      return {
+        success: false,
+        error: '上传失败',
+        file: file,
+      };
+    }
+  } catch (error) {
+    return {
+      success: false,
+      error: error.message || '上传失败',
+      file: file,
+    };
+  }
+};
+
+const removeVideo = (index) => {
+  const newList = [...videoList.value];
+  newList.splice(index, 1);
+  videoList.value = newList;
+};
+
+const previewVideo = (videoUrl, index) => {
+  previewVideoUrl.value = videoUrl;
+  previewVisible.value = true;
+};
+
+const closePreview = () => {
+  previewVisible.value = false;
+  previewVideoUrl.value = '';
+};
+
+// 监听props变化
+watch(() => props.modelValue, (newValue) => {
+  // 当外部值变化时,确保内部状态同步
+}, { immediate: true });
+</script>
+
+<style lang="scss" scoped>
+.sa-upload-video {
+  .upload-container {
+    gap: 8px;
+  }
+
+  .video-item {
+    position: relative;
+    border: 1px solid var(--el-border-color);
+    border-radius: 6px;
+    overflow: hidden;
+    background: #fafafa;
+
+    .video-preview {
+      position: relative;
+      
+      video {
+        border-radius: 6px;
+      }
+      
+      .video-play-icon {
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+        background: rgba(0, 0, 0, 0.6);
+        color: white;
+        border-radius: 50%;
+        width: 40px;
+        height: 40px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        cursor: pointer;
+        transition: all 0.3s;
+        
+        &:hover {
+          background: rgba(0, 0, 0, 0.8);
+        }
+      }
+    }
+
+    .video-mask {
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      background: rgba(0, 0, 0, 0.5);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      gap: 8px;
+      opacity: 0;
+      transition: opacity 0.3s;
+
+      .el-icon {
+        color: white;
+        cursor: pointer;
+        padding: 4px;
+        border-radius: 4px;
+        transition: background-color 0.3s;
+
+        &:hover {
+          background-color: rgba(255, 255, 255, 0.2);
+        }
+      }
+    }
+
+    &:hover .video-mask {
+      opacity: 1;
+    }
+  }
+
+  .upload-wrapper {
+    position: relative;
+
+    .upload-trigger {
+      border: 2px dashed var(--el-border-color);
+      border-radius: 6px;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      cursor: pointer;
+      transition: all 0.3s;
+      background: #fafafa;
+
+      &:hover {
+        border-color: var(--el-color-primary);
+        background: var(--el-color-primary-light-9);
+      }
+
+      &.is-uploading {
+        cursor: not-allowed;
+        opacity: 0.6;
+      }
+
+      .upload-icon {
+        color: var(--el-text-color-secondary);
+        margin-bottom: 8px;
+      }
+
+      .upload-text {
+        font-size: 12px;
+        color: var(--el-text-color-regular);
+        text-align: center;
+        line-height: 1.2;
+      }
+    }
+
+    .upload-loading {
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      background: rgba(255, 255, 255, 0.9);
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      border-radius: 6px;
+
+      .loading-spinner {
+        width: 20px;
+        height: 20px;
+        border: 2px solid var(--el-color-primary-light-8);
+        border-top: 2px solid var(--el-color-primary);
+        border-radius: 50%;
+        animation: spin 1s linear infinite;
+        margin-bottom: 8px;
+      }
+
+      .loading-text {
+        font-size: 12px;
+        color: var(--el-text-color-regular);
+      }
+    }
+  }
+
+  .upload-tip {
+    margin-top: 8px;
+    font-size: 12px;
+    color: var(--el-text-color-secondary);
+    line-height: 1.4;
+  }
+
+  .video-preview-container {
+    text-align: center;
+  }
+}
+
+@keyframes spin {
+  0% { transform: rotate(0deg); }
+  100% { transform: rotate(360deg); }
+}
+</style>