ソースを参照

feat: 装修功能初始化

叶静 1 週間 前
コミット
fd6990a8ce
100 ファイル変更4175 行追加0 行削除
  1. BIN
      public/static/images/shop/decorate/avatar.png
  2. BIN
      public/static/images/shop/decorate/black.png
  3. BIN
      public/static/images/shop/decorate/blue.png
  4. BIN
      public/static/images/shop/decorate/coupon.png
  5. BIN
      public/static/images/shop/decorate/couponCard.png
  6. BIN
      public/static/images/shop/decorate/couponCardStyle.png
  7. BIN
      public/static/images/shop/decorate/floatMenu.png
  8. BIN
      public/static/images/shop/decorate/golden.png
  9. BIN
      public/static/images/shop/decorate/goodsCard.png
  10. BIN
      public/static/images/shop/decorate/goodsShelves.png
  11. BIN
      public/static/images/shop/decorate/green.png
  12. BIN
      public/static/images/shop/decorate/groupon.png
  13. BIN
      public/static/images/shop/decorate/guidePage.png
  14. BIN
      public/static/images/shop/decorate/header-WechatMiniProgram.png
  15. BIN
      public/static/images/shop/decorate/header-WechatOfficialAccount.png
  16. BIN
      public/static/images/shop/decorate/header-android.png
  17. BIN
      public/static/images/shop/decorate/header-diypage.png
  18. BIN
      public/static/images/shop/decorate/header-ios.png
  19. BIN
      public/static/images/shop/decorate/hotzone.png
  20. BIN
      public/static/images/shop/decorate/imageBanner.png
  21. BIN
      public/static/images/shop/decorate/imageBlock.png
  22. BIN
      public/static/images/shop/decorate/imageCube.png
  23. BIN
      public/static/images/shop/decorate/ios-bar.png
  24. BIN
      public/static/images/shop/decorate/lineBlock.png
  25. BIN
      public/static/images/shop/decorate/livePlayer.png
  26. BIN
      public/static/images/shop/decorate/menuButton.png
  27. BIN
      public/static/images/shop/decorate/menuGrid.png
  28. BIN
      public/static/images/shop/decorate/menuList.png
  29. BIN
      public/static/images/shop/decorate/mplive-1.png
  30. BIN
      public/static/images/shop/decorate/mplive-2.png
  31. BIN
      public/static/images/shop/decorate/mplive-3.png
  32. BIN
      public/static/images/shop/decorate/mplive.png
  33. BIN
      public/static/images/shop/decorate/noticeBlock.png
  34. BIN
      public/static/images/shop/decorate/orange.png
  35. BIN
      public/static/images/shop/decorate/orderCard.png
  36. BIN
      public/static/images/shop/decorate/orderCardStyle.png
  37. BIN
      public/static/images/shop/decorate/page.png
  38. BIN
      public/static/images/shop/decorate/picker.png
  39. BIN
      public/static/images/shop/decorate/popupImage.png
  40. BIN
      public/static/images/shop/decorate/preview_bg.png
  41. BIN
      public/static/images/shop/decorate/purple.png
  42. BIN
      public/static/images/shop/decorate/qrcode.png
  43. BIN
      public/static/images/shop/decorate/red.png
  44. BIN
      public/static/images/shop/decorate/richtext.png
  45. BIN
      public/static/images/shop/decorate/score.png
  46. BIN
      public/static/images/shop/decorate/scoreGoods.png
  47. BIN
      public/static/images/shop/decorate/searchBlock.png
  48. BIN
      public/static/images/shop/decorate/seckill.png
  49. BIN
      public/static/images/shop/decorate/splashScreen.png
  50. BIN
      public/static/images/shop/decorate/subscribeWechatOfficialAccount.png
  51. BIN
      public/static/images/shop/decorate/tabbar.png
  52. BIN
      public/static/images/shop/decorate/titleBlock.png
  53. BIN
      public/static/images/shop/decorate/userCard.png
  54. BIN
      public/static/images/shop/decorate/videoPlayer.png
  55. BIN
      public/static/images/shop/decorate/walletCard.png
  56. BIN
      public/static/images/shop/decorate/walletCardStyle.png
  57. BIN
      public/static/images/shop/decorate/yellow.png
  58. 200 0
      src/app/shop/admin/data/page/select.vue
  59. 86 0
      src/app/shop/admin/data/page/select_new.vue
  60. 95 0
      src/app/shop/admin/data/richtext/select.vue
  61. 126 0
      src/app/shop/admin/decorate/decorate.service.js
  62. 172 0
      src/app/shop/admin/decorate/designer/index.vue
  63. 178 0
      src/app/shop/admin/decorate/designer/preview.vue
  64. 94 0
      src/app/shop/admin/decorate/page/component/center/basic/floatMenu/index.vue
  65. 60 0
      src/app/shop/admin/decorate/page/component/center/basic/floatMenu/setting.vue
  66. 7 0
      src/app/shop/admin/decorate/page/component/center/basic/guidePage/index.vue
  67. 25 0
      src/app/shop/admin/decorate/page/component/center/basic/guidePage/setting.vue
  68. 24 0
      src/app/shop/admin/decorate/page/component/center/basic/index.vue
  69. 53 0
      src/app/shop/admin/decorate/page/component/center/basic/popupImage/index.vue
  70. 46 0
      src/app/shop/admin/decorate/page/component/center/basic/popupImage/setting.vue
  71. 7 0
      src/app/shop/admin/decorate/page/component/center/basic/splashScreen/index.vue
  72. 32 0
      src/app/shop/admin/decorate/page/component/center/basic/splashScreen/setting.vue
  73. 95 0
      src/app/shop/admin/decorate/page/component/center/basic/tabbar/index.vue
  74. 96 0
      src/app/shop/admin/decorate/page/component/center/basic/tabbar/setting.vue
  75. 99 0
      src/app/shop/admin/decorate/page/component/center/common/dc-color-picker.vue
  76. 166 0
      src/app/shop/admin/decorate/page/component/center/common/dc-goods-select.vue
  77. 108 0
      src/app/shop/admin/decorate/page/component/center/common/dc-list.vue
  78. 71 0
      src/app/shop/admin/decorate/page/component/center/common/dc-slider.vue
  79. 80 0
      src/app/shop/admin/decorate/page/component/center/common/dc-text-color.vue
  80. 54 0
      src/app/shop/admin/decorate/page/component/center/common/dc-url.vue
  81. 206 0
      src/app/shop/admin/decorate/page/component/center/comp/coupon/index.vue
  82. 85 0
      src/app/shop/admin/decorate/page/component/center/comp/coupon/setting.vue
  83. 13 0
      src/app/shop/admin/decorate/page/component/center/comp/couponCard/index.vue
  84. 201 0
      src/app/shop/admin/decorate/page/component/center/comp/goodsCard/index.vue
  85. 119 0
      src/app/shop/admin/decorate/page/component/center/comp/goodsCard/setting.vue
  86. 118 0
      src/app/shop/admin/decorate/page/component/center/comp/goodsShelves/index.vue
  87. 83 0
      src/app/shop/admin/decorate/page/component/center/comp/goodsShelves/setting.vue
  88. 197 0
      src/app/shop/admin/decorate/page/component/center/comp/groupon/index.vue
  89. 151 0
      src/app/shop/admin/decorate/page/component/center/comp/groupon/setting.vue
  90. 246 0
      src/app/shop/admin/decorate/page/component/center/comp/hotzone/edit.vue
  91. 49 0
      src/app/shop/admin/decorate/page/component/center/comp/hotzone/index.vue
  92. 51 0
      src/app/shop/admin/decorate/page/component/center/comp/hotzone/setting.vue
  93. 102 0
      src/app/shop/admin/decorate/page/component/center/comp/imageBanner/index.vue
  94. 76 0
      src/app/shop/admin/decorate/page/component/center/comp/imageBanner/setting.vue
  95. 27 0
      src/app/shop/admin/decorate/page/component/center/comp/imageBlock/index.vue
  96. 26 0
      src/app/shop/admin/decorate/page/component/center/comp/imageBlock/setting.vue
  97. 73 0
      src/app/shop/admin/decorate/page/component/center/comp/imageCube/index.vue
  98. 260 0
      src/app/shop/admin/decorate/page/component/center/comp/imageCube/setting.vue
  99. 94 0
      src/app/shop/admin/decorate/page/component/center/comp/index.vue
  100. 24 0
      src/app/shop/admin/decorate/page/component/center/comp/lineBlock/index.vue

BIN
public/static/images/shop/decorate/avatar.png


BIN
public/static/images/shop/decorate/black.png


BIN
public/static/images/shop/decorate/blue.png


BIN
public/static/images/shop/decorate/coupon.png


BIN
public/static/images/shop/decorate/couponCard.png


BIN
public/static/images/shop/decorate/couponCardStyle.png


BIN
public/static/images/shop/decorate/floatMenu.png


BIN
public/static/images/shop/decorate/golden.png


BIN
public/static/images/shop/decorate/goodsCard.png


BIN
public/static/images/shop/decorate/goodsShelves.png


BIN
public/static/images/shop/decorate/green.png


BIN
public/static/images/shop/decorate/groupon.png


BIN
public/static/images/shop/decorate/guidePage.png


BIN
public/static/images/shop/decorate/header-WechatMiniProgram.png


BIN
public/static/images/shop/decorate/header-WechatOfficialAccount.png


BIN
public/static/images/shop/decorate/header-android.png


BIN
public/static/images/shop/decorate/header-diypage.png


BIN
public/static/images/shop/decorate/header-ios.png


BIN
public/static/images/shop/decorate/hotzone.png


BIN
public/static/images/shop/decorate/imageBanner.png


BIN
public/static/images/shop/decorate/imageBlock.png


BIN
public/static/images/shop/decorate/imageCube.png


BIN
public/static/images/shop/decorate/ios-bar.png


BIN
public/static/images/shop/decorate/lineBlock.png


BIN
public/static/images/shop/decorate/livePlayer.png


BIN
public/static/images/shop/decorate/menuButton.png


BIN
public/static/images/shop/decorate/menuGrid.png


BIN
public/static/images/shop/decorate/menuList.png


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


BIN
public/static/images/shop/decorate/mplive-2.png


BIN
public/static/images/shop/decorate/mplive-3.png


BIN
public/static/images/shop/decorate/mplive.png


BIN
public/static/images/shop/decorate/noticeBlock.png


BIN
public/static/images/shop/decorate/orange.png


BIN
public/static/images/shop/decorate/orderCard.png


BIN
public/static/images/shop/decorate/orderCardStyle.png


BIN
public/static/images/shop/decorate/page.png


BIN
public/static/images/shop/decorate/picker.png


BIN
public/static/images/shop/decorate/popupImage.png


BIN
public/static/images/shop/decorate/preview_bg.png


BIN
public/static/images/shop/decorate/purple.png


BIN
public/static/images/shop/decorate/qrcode.png


BIN
public/static/images/shop/decorate/red.png


BIN
public/static/images/shop/decorate/richtext.png


BIN
public/static/images/shop/decorate/score.png


BIN
public/static/images/shop/decorate/scoreGoods.png


BIN
public/static/images/shop/decorate/searchBlock.png


BIN
public/static/images/shop/decorate/seckill.png


BIN
public/static/images/shop/decorate/splashScreen.png


BIN
public/static/images/shop/decorate/subscribeWechatOfficialAccount.png


BIN
public/static/images/shop/decorate/tabbar.png


BIN
public/static/images/shop/decorate/titleBlock.png


BIN
public/static/images/shop/decorate/userCard.png


BIN
public/static/images/shop/decorate/videoPlayer.png


BIN
public/static/images/shop/decorate/walletCard.png


BIN
public/static/images/shop/decorate/walletCardStyle.png


BIN
public/static/images/shop/decorate/yellow.png


+ 200 - 0
src/app/shop/admin/data/page/select.vue

@@ -0,0 +1,200 @@
+<template>
+  <el-container class="page-select-view">
+    <el-header class="sa-header">
+      <div class="sa-title">
+        <span>选择页面</span>
+      </div>
+    </el-header>
+    <el-main class="sa-p-0">
+      <el-container>
+        <el-aside>
+          <div class="left">
+            <div class="group" :class="currentGroupIndex == i ? 'is-active' : ''" v-for="(g, i) in pageGroups" :key="i"
+              @click="currentGroupIndex = i">
+              <div class="name">{{ g.group }}</div>
+            </div>
+          </div>
+        </el-aside>
+        <div class="right">
+          <div class="group" v-for="(g, i) in pageGroups" :key="i" v-show="currentGroupIndex === i">
+            <div class="group-title">{{ g.group }}</div>
+            <div class="link sa-flex sa-flex-wrap">
+              <div class="item" :class="selectedPage.path == page.path ? 'item-active' : ''" v-for="page in g.children"
+                :key="page.path" @click="selectPage(page)">
+                {{ page.name }}
+              </div>
+            </div>
+          </div>
+        </div>
+      </el-container>
+    </el-main>
+    <el-footer class="sa-footer--submit">
+      <el-button type="primary" @click="confirm">确定</el-button>
+    </el-footer>
+  </el-container>
+</template>
+
+<script setup>
+import { ref } from 'vue';
+
+const emit = defineEmits(['modalCallBack']);
+const props = defineProps({
+  modal: Object,
+});
+
+const currentGroupIndex = ref(0);
+const selectedPage = ref({});
+
+// 根据系统实际模块配置的页面路由
+const pageGroups = [
+  {
+    group: '商城页面',
+    children: [
+      { name: '首页', path: '/pages/index/index' },
+      { name: '商品分类', path: '/pages/index/category' },
+      { name: '购物车', path: '/pages/index/cart' },
+      { name: '商品列表', path: '/pages/goods/list' },
+      { name: '商品详情', path: '/pages/goods/detail' },
+      { name: '商品搜索', path: '/pages/goods/search' },
+    ],
+  },
+  {
+    group: '用户中心',
+    children: [
+      { name: '个人中心', path: '/pages/user/index' },
+      { name: '个人资料', path: '/pages/user/profile' },
+      { name: '我的订单', path: '/pages/order/list' },
+      { name: '订单详情', path: '/pages/order/detail' },
+      { name: '收货地址', path: '/pages/user/address' },
+      { name: '我的收藏', path: '/pages/user/collect' },
+      { name: '浏览足迹', path: '/pages/user/history' },
+      { name: '账户余额', path: '/pages/user/wallet' },
+    ],
+  },
+  {
+    group: '营销活动',
+    children: [
+      { name: '优惠券列表', path: '/pages/coupon/list' },
+      { name: '优惠券详情', path: '/pages/coupon/detail' },
+      { name: '拼团活动', path: '/pages/marketing/group' },
+    ],
+  },
+  {
+    group: '内容页面',
+    children: [
+      { name: '公告列表', path: '/pages/content/notification' },
+      { name: '公告详情', path: '/pages/content/notification-detail' },
+      { name: '广告页面', path: '/pages/content/adv' },
+      { name: '帮助中心', path: '/pages/content/help' },
+      { name: '问答列表', path: '/pages/content/qa' },
+      { name: '富文本页面', path: '/pages/public/richtext' },
+    ],
+  },
+  {
+    group: '其他',
+    children: [
+      { name: '客服中心', path: '/pages/chat/index' },
+      { name: '关于我们', path: '/pages/public/about' },
+      { name: '自定义页面', path: '/pages/index/page' },
+    ],
+  },
+];
+
+function selectPage(page) {
+  selectedPage.value = { ...page };
+}
+
+function confirm() {
+  if (!selectedPage.value.path) {
+    return;
+  }
+  emit('modalCallBack', {
+    event: 'confirm',
+    data: selectedPage.value,
+  });
+}
+</script>
+
+<style lang="scss" scoped>
+.page-select-view {
+  height: 600px;
+
+  .el-main {
+    display: flex;
+  }
+
+  .el-aside {
+    --el-aside-width: 140px;
+    border-right: 1px solid var(--sa-border);
+    padding: 20px;
+  }
+
+  .left {
+    .group {
+      height: 32px;
+      line-height: 32px;
+      border-radius: 4px;
+      font-size: 14px;
+      font-weight: 400;
+      color: var(--sa-subtitle);
+      margin-bottom: 4px;
+      cursor: pointer;
+      padding: 0 12px;
+
+      &:hover {
+        color: var(--el-color-primary);
+        background: var(--t-bg-hover);
+      }
+
+      &.is-active {
+        color: var(--el-color-primary);
+        background: var(--t-bg-active);
+      }
+    }
+  }
+
+  .right {
+    flex: 1;
+    padding: 20px;
+    overflow-y: auto;
+
+    .group {
+      .group-title {
+        line-height: 16px;
+        font-size: 14px;
+        font-weight: 600;
+        color: var(--sa-subtitle);
+        margin: 0 0 12px 0;
+      }
+
+      .link {
+        .item {
+          padding: 0 16px;
+          height: 32px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          border: 1px solid var(--sa-border);
+          border-radius: 4px;
+          margin: 0 12px 12px 0;
+          font-size: 14px;
+          font-weight: 400;
+          color: var(--sa-font);
+          cursor: pointer;
+
+          &:hover {
+            color: var(--el-color-primary);
+            background: var(--t-bg-hover);
+          }
+
+          &.item-active {
+            color: var(--el-color-primary);
+            background: var(--t-bg-active);
+            border-color: var(--el-color-primary);
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 86 - 0
src/app/shop/admin/data/page/select_new.vue

@@ -0,0 +1,86 @@
+<template>
+  <el-container class="page-select-view">
+    <el-header class="sa-header">
+      <div class="sa-title">
+        <span>选择页面</span>
+      </div>
+    </el-header>
+    <el-main v-loading="loading">
+      <el-table :data="table.data" @selection-change="changeSelection" class="sa-table" stripe>
+        <template #empty>
+          <sa-empty />
+        </template>
+        <el-table-column type="selection" width="48" align="center" :selectable="checkSelectable"></el-table-column>
+        <el-table-column prop="id" label="ID" min-width="80"></el-table-column>
+        <el-table-column prop="title" label="页面标题" min-width="200"></el-table-column>
+        <el-table-column label="状态" min-width="100" align="center">
+          <template #default="scope">
+            <el-tag :type="scope.row.status ? 'success' : 'info'" size="small">
+              {{ scope.row.status ? '启用' : '禁用' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="updateTime" label="更新时间" min-width="160"></el-table-column>
+      </el-table>
+    </el-main>
+    <el-footer class="sa-footer--submit">
+      <el-button type="primary" @click="confirm">确定</el-button>
+    </el-footer>
+  </el-container>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from 'vue';
+import { api as decorateApi } from '@/app/shop/admin/decorate/decorate.service';
+
+const emit = defineEmits(['modalCallBack']);
+const props = defineProps({
+  modal: Object,
+});
+
+const loading = ref(false);
+const table = reactive({
+  data: [],
+  selected: [],
+});
+
+async function getData() {
+  loading.value = true;
+  const { code, data } = await decorateApi.page({
+    page: 1,
+    size: 1000,
+  });
+  if (code === '200') {
+    table.data = data.list || [];
+  }
+  loading.value = false;
+}
+
+function changeSelection(row) {
+  table.selected = row;
+}
+
+function checkSelectable(row) {
+  return row.status === true;
+}
+
+function confirm() {
+  if (table.selected.length === 0) {
+    return;
+  }
+  emit('modalCallBack', {
+    event: 'confirm',
+    data: table.selected[0],
+  });
+}
+
+onMounted(() => {
+  getData();
+});
+</script>
+
+<style lang="scss" scoped>
+.page-select-view {
+  height: 600px;
+}
+</style>

+ 95 - 0
src/app/shop/admin/data/richtext/select.vue

@@ -0,0 +1,95 @@
+<template>
+  <el-container class="richtext-select-view">
+    <el-header class="sa-header">
+      <div class="sa-title">
+        <span>编辑富文本内容</span>
+      </div>
+    </el-header>
+    <el-main>
+      <el-form :model="form" ref="formRef">
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="form.title" placeholder="请输入标题(可选)" maxlength="100" show-word-limit />
+        </el-form-item>
+        <el-form-item label="富文本内容" prop="content">
+          <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 } 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: Object,
+});
+
+const formRef = ref(null);
+const form = reactive({
+  title: '',
+  content: '',
+});
+
+// 初始化数据(如果是编辑模式)
+function init() {
+  if (props.modal?.params?.data) {
+    form.title = props.modal.params.data.title || '';
+    form.content = props.modal.params.data.content || '';
+  }
+}
+
+function cancel() {
+  emit('modalCallBack', {
+    event: 'cancel',
+  });
+}
+
+function confirm() {
+  if (!form.content || form.content.trim() === '') {
+    ElMessage.warning('请输入富文本内容');
+    return;
+  }
+
+  // 返回富文本数据
+  emit('modalCallBack', {
+    event: 'confirm',
+    data: {
+      id: Date.now(), // 生成一个临时 ID
+      title: form.title,
+      content: form.content,
+    },
+  });
+}
+
+onMounted(() => {
+  init();
+});
+</script>
+
+<style lang="scss" scoped>
+.richtext-select-view {
+  height: 700px;
+
+  .el-main {
+    max-height: calc(100% - 120px);
+    overflow-y: auto;
+    padding: 20px;
+  }
+
+  .el-form-item {
+    margin-bottom: 20px;
+  }
+
+  :deep(.sa-editor) {
+    min-height: 400px;
+  }
+}
+</style>

+ 126 - 0
src/app/shop/admin/decorate/decorate.service.js

@@ -0,0 +1,126 @@
+import Content from '@/sheep/layouts/content.vue';
+import { request } from '@/sheep/request';
+import { baseURL } from '@/sheep/config';
+
+const route = {
+  path: 'decorate',
+  name: 'shop.admin.decorate',
+  component: Content,
+  meta: {
+    title: '店铺装修',
+  },
+  children: [
+    {
+      path: 'template',
+      name: 'shop.admin.decorate.template',
+      component: () => import('./template/index.vue'),
+      meta: {
+        title: '店铺模板',
+      },
+    },
+    {
+      path: 'page',
+      name: 'shop.admin.decorate.page',
+      component: () => import('./page/index.vue'),
+      meta: {
+        title: '页面装修',
+      },
+    },
+    {
+      path: 'designer',
+      name: 'shop.admin.decorate.designer',
+      component: () => import('./designer/index.vue'),
+      meta: {
+        title: '设计师模板',
+      },
+    },
+  ],
+};
+
+const api = {
+  // 装修页面列表(分页)
+  page: (params) =>
+    request({
+      url: 'mall/diy/page',
+      method: 'GET',
+      params,
+    }),
+  // 新增装修页面
+  save: (data) =>
+    request({
+      url: 'mall/diy/save',
+      method: 'POST',
+      data,
+      options: {
+        showSuccessMessage: true,
+      },
+    }),
+  // 装修页面详情
+  info: (id) =>
+    request({
+      url: `mall/diy/info/${id}`,
+      method: 'GET',
+    }),
+  // 更新装修页面
+  update: (id, data) =>
+    request({
+      url: `mall/diy/update/${id}`,
+      method: 'PATCH',
+      data,
+      options: {
+        showSuccessMessage: true,
+      },
+    }),
+  // 修改状态(删除)
+  changeStatus: (id) =>
+    request({
+      url: `mall/diy/changeStatus/${id}`,
+      method: 'DELETE',
+      options: {
+        showSuccessMessage: true,
+      },
+    }),
+  // 兼容旧接口
+  template: {
+    select: (params) => api.page(params),
+    create: (data) => api.save(data),
+    update: (id, data) => api.update(id, data),
+    delete: (id) => api.changeStatus(id),
+  },
+  // 获取页面详情(兼容旧接口)
+  getPage: (id) => api.info(id),
+  // 更新页面(兼容旧接口)
+  updatePage: (id, type, data) => api.update(id, data),
+  // 设计师模板
+  designer: {
+    list: () =>
+      request({
+        url: 'https://api.sheepjs.com/api/designer',
+        method: 'GET',
+      }),
+    use: async (id) => {
+      const { error, data } = await request({
+        url: 'https://api.sheepjs.com/api/designer/' + id,
+        method: 'GET',
+      });
+      if (error === 0) {
+        request({
+          url: 'shop/admin/decorate/designer/use',
+          method: 'POST',
+          data,
+          options: {
+            showSuccessMessage: true,
+          },
+        });
+      }
+    },
+  },
+  getWxacode: (path) =>
+    `${baseURL}/shop/api/third/wechat/wxacode?platform=miniProgram&payload=${encodeURIComponent(
+      JSON.stringify({
+        path,
+      }),
+    )}`,
+};
+
+export { route, api };

+ 172 - 0
src/app/shop/admin/decorate/designer/index.vue

@@ -0,0 +1,172 @@
+<template>
+  <el-container class="designer-view panel-block">
+    <el-header class="sa-header">
+      <div class="sa-title sa-flex sa-row-between">
+        <span>设计师模板</span>
+        <div>
+          <el-button class="sa-button-refresh" icon="RefreshRight" @click="getData()"></el-button>
+        </div>
+      </div>
+    </el-header>
+    <el-main class="sa-p-0 sa-m-0" v-loading="state.loading">
+      <div v-if="state.list.length > 0" class="designer-wrap sa-flex sa-flex-wrap">
+        <template v-for="item in state.list" :key="item">
+          <div class="item">
+            <el-carousel
+              trigger="click"
+              height="480px"
+              :autoplay="false"
+              :loop="false"
+              indicator-position="none"
+            >
+              <el-carousel-item v-for="page in item.page" :key="page">
+                <sa-image :url="page.image" :suffix="{}"></sa-image>
+              </el-carousel-item>
+            </el-carousel>
+            <div class="footer">
+              <div class="name">{{ item.name }}</div>
+              <div class="platform sa-flex">
+                <div class="label">支持平台:</div>
+                <div v-if="item.platform">
+                  <sa-icon
+                    class="sa-m-r-8"
+                    v-for="pl in item.platform"
+                    :key="pl"
+                    :icon="'sa-shop-decorate-' + pl"
+                    size="20"
+                    :style="{
+                      color: platformList.filter((pf) => {
+                        return pf.type == pl;
+                      })[0].color,
+                    }"
+                  />
+                </div>
+              </div>
+              <div class="memo sa-flex">
+                <div class="label">备注:</div>
+                <div>{{ item.memo }}</div>
+              </div>
+              <div class="oper sa-flex sa-row-right">
+                <el-button
+                  v-auth="'shop.admin.decorate.designer.use'"
+                  class="is-link"
+                  type="primary"
+                  size="small"
+                  @click="onUse(item.id)"
+                >
+                  使用
+                </el-button>
+              </div>
+            </div>
+          </div>
+        </template>
+      </div>
+      <sa-empty v-if="state.list.length == 0" />
+    </el-main>
+  </el-container>
+</template>
+
+<script setup>
+import { onMounted, reactive } from 'vue';
+import { useModal } from '@/sheep/hooks';
+import PagePreview from './preview.vue';
+import { api } from '../decorate.service';
+import { platformList } from '../page/data';
+
+const state = reactive({
+  loading: false,
+  list: [],
+});
+
+async function getData() {
+  state.loading = true;
+  const { error, data } = await api.designer.list();
+  error === 0 && (state.list = data);
+  state.loading = false;
+}
+
+async function onUse(id) {
+  api.designer.use(id);
+}
+
+function onPreview(item) {
+  useModal(PagePreview, {
+    title: '预览',
+    data: item,
+    class: 'preview-dialog',
+  });
+}
+
+onMounted(() => {
+  getData();
+});
+</script>
+<style lang="scss" scoped>
+.designer-view {
+  .designer-wrap {
+    padding: 10px var(--sa-padding);
+    .item {
+      position: relative;
+      width: 246px;
+      height: 480px;
+      border: 1px solid var(--sa-space);
+      box-shadow: 0 0 4px rgba(89, 89, 89, 0.2);
+      box-sizing: border-box;
+      border-radius: 4px;
+      margin-bottom: var(--sa-padding);
+      overflow: hidden;
+      margin-right: 20px;
+      img {
+        width: 100%;
+      }
+      &:hover {
+        transform: scale(1.02);
+        box-shadow: 0 4px 16px rgba(89, 89, 89, 0.24);
+        .footer {
+          opacity: 1;
+        }
+      }
+      :deep() {
+        .image-slot {
+          height: 200px;
+        }
+      }
+      .footer {
+        position: absolute;
+        bottom: 0;
+        width: 100%;
+        height: fit-content;
+        padding: 0 12px;
+        background: var(--sa-background-assist);
+        transition: all 0.5s;
+        opacity: 0;
+        .name {
+          padding: 12px 0 4px;
+          color: var(--sa-title);
+          font-size: 16px;
+        }
+        .platform {
+          height: 28px;
+          font-size: 14px;
+          color: var(--sa-subtitle);
+        }
+        .memo,
+        .update-time {
+          height: 20px;
+          font-size: 12px;
+          color: var(--sa-subfont);
+        }
+        .label {
+          flex-shrink: 0;
+        }
+        .oper {
+          height: 36px;
+          .el-button + .el-button {
+            margin-left: 8px;
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 178 - 0
src/app/shop/admin/decorate/designer/preview.vue

@@ -0,0 +1,178 @@
+<template>
+  <el-container class="page-preview">
+    <el-main>
+      <el-row :gutter="20">
+        <el-col class="sa-col-12" :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
+          <div
+            v-if="
+              props.modal.params.data.type == 'diypage' ||
+              state.templateData.platform.includes('H5') ||
+              state.templateData.platform.includes('WechatOfficialAccount')
+            "
+            class="left sa-flex-col sa-col-center"
+          >
+            <div class="preview-title">此为预览效果,实际效果请扫码查看</div>
+            <div class="web-preview">
+              <div v-if="isShowIframe" class="web-preview-msg">
+                <span v-html="isShowIframe"></span>
+              </div>
+              <iframe v-else id="preview" :src="url.H5" frameborder="1" height="600px"></iframe>
+            </div>
+          </div>
+        </el-col>
+        <el-col class="sa-col-12" :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
+          <div class="name">{{ state.templateData.name }}</div>
+          <template
+            v-if="
+              props.modal.params.data.type == 'diypage' || state.templateData.platform.length > 0
+            "
+          >
+            <div class="platform sa-m-b-20">
+              <sa-icon
+                class="sa-m-r-8"
+                v-for="pl in state.templateData.platform"
+                :key="pl"
+                :icon="'sa-shop-decorate-' + pl"
+                :style="{
+                  color: platformList.filter((pf) => {
+                    return pf.type == pl;
+                  })[0].color,
+                }"
+                size="20"
+              />
+            </div>
+            <div
+              v-if="
+                props.modal.params.data.type == 'diypage' ||
+                state.templateData.platform.includes('H5') ||
+                state.templateData.platform.includes('WechatOfficialAccount')
+              "
+              class="h5"
+            >
+              <qrcode-vue :value="url.H5" :size="132" level="H" />
+              <div class="tip sa-m-t-12">微信扫描二维码即可预览</div>
+            </div>
+            <div
+              v-if="
+                props.modal.params.data.type == 'diypage' ||
+                state.templateData.platform.includes('WechatMiniProgram')
+              "
+              class="wechat"
+            >
+              <sa-image class="" :url="url.WechatMiniProgram" size="132"></sa-image>
+              <div class="tip sa-m-t-12">微信扫描小程序即可预览</div>
+            </div>
+          </template>
+          <div class="copyright">杭州创斯维科技Shopro版权所有 Copyright 2020-2022</div>
+        </el-col>
+      </el-row>
+    </el-main>
+  </el-container>
+</template>
+<script setup>
+  import { computed, onMounted, reactive } from 'vue';
+  import { api as configApi } from '@/app/shop/admin/config/config.service';
+  import { api } from '../decorate.service';
+  import { platformList } from '../page/data';
+  import QrcodeVue from 'qrcode.vue';
+
+  const props = defineProps(['modal']);
+
+  const state = reactive({
+    templateData: props.modal.params.data,
+    domain: '',
+  });
+
+  async function getConfig() {
+    const { error, data } = await configApi.basic();
+    error === 0 && (state.domain = data.domain);
+  }
+
+  const url = computed(() => {
+    return {
+      H5: `${props.modal.params.data.h5Url}`,
+      WechatMiniProgram: `${props.modal.params.data.wxacode}`,
+    };
+  });
+
+  const isShowIframe = computed(() => {
+    if (window.location.protocol == 'https:' && url.value.H5.split('://')[0] == 'http') {
+      return '您的商城前端域名ssl未开启,<br/>请扫码预览';
+    } else {
+      if (!state.domain) {
+        return '请在商城配置设置您的前端域名';
+      }
+    }
+  });
+
+  onMounted(() => {
+    getConfig();
+  });
+</script>
+
+<style lang="scss">
+  .sa-dialog.preview-dialog {
+    max-height: 76vh;
+    margin: 12vh auto;
+    @media only screen and (max-width: 768px) {
+      max-height: 100vh;
+      margin: 0;
+    }
+  }
+</style>
+
+<style lang="scss" scoped>
+  .page-preview {
+    text-align: center;
+    .el-main {
+      --el-main-padding: 20px 20px 0 20px;
+    }
+    .left {
+      width: 100%;
+      margin-bottom: 20px;
+    }
+    .preview-title {
+      color: var(--sa-subtitle);
+      margin-bottom: 12px;
+    }
+    .web-preview {
+      width: 300px;
+      height: 594px;
+      background: url('/static/images/shop/decorate/preview_bg.png');
+      padding: 18px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      position: relative;
+      color: #434343;
+      #preview {
+        border: none;
+        margin: 0 auto;
+        width: 100%;
+        height: 100%;
+        border-radius: 26px;
+      }
+    }
+    .name {
+      font-size: 18px;
+      color: var(--sa-title);
+      margin-bottom: 12px;
+    }
+    .copyright {
+      font-size: 12px;
+      color: var(--sa-subfont);
+      margin-bottom: 12px;
+    }
+    .h5,
+    .wechat {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      .tip {
+        font-size: 14px;
+        color: var(--sa-font);
+        margin-bottom: 24px;
+      }
+    }
+  }
+</style>

+ 94 - 0
src/app/shop/admin/decorate/page/component/center/basic/floatMenu/index.vue

@@ -0,0 +1,94 @@
+<template>
+  <div class="float-menu" :class="!isFold ? 'mask' : ''" v-if="compData.show">
+    <div class="float-menu-wrap" :class="compData.mode == 2 ? 'horizontal' : 'vertical'">
+      <template v-if="!isFold">
+        <div class="float-menu-item" v-for="item in compData.list" :key="item">
+          <sa-image :url="item.src" size="26" :suffix="null" />
+          <div v-if="compData.isText" class="text" :style="titleStyle(item.title)">
+            {{ item.title.text }}
+          </div>
+        </div>
+      </template>
+      <div
+        class="float-menu-button sa-flex sa-row-center"
+        :class="!isFold ? 'fold' : ''"
+        :style="buttonStyle"
+        @click="isFold = !isFold"
+      >
+        <el-icon><CloseBold /></el-icon>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { computed, ref } from 'vue';
+  import { themeColor } from '../../../../data.js';
+
+  const props = defineProps(['compData', 'allData']);
+
+  const isFold = ref(false);
+
+  const buttonStyle = computed(() => ({
+    background: themeColor[props.allData.theme || 'orange'].color1,
+  }));
+
+  function titleStyle(title) {
+    return {
+      color: title.color || '#eee',
+    };
+  }
+</script>
+
+<style lang="scss" scoped>
+  .float-menu {
+    height: 100%;
+    // position: relative;
+    &.mask {
+      background: rgb(153, 153, 153);
+    }
+    .float-menu-wrap {
+      position: absolute;
+      right: 30px;
+      bottom: 50px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      .float-menu-item {
+        margin-right: 10px;
+        margin-bottom: 0;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+      }
+      .text {
+        line-height: 12px;
+        font-size: 12px;
+        margin-top: 4px;
+      }
+      .float-menu-button {
+        width: 42px;
+        height: 42px;
+        font-size: 20px;
+        border-radius: 50%;
+        color: #fff;
+        transition: all 0.3s linear;
+        cursor: pointer;
+        transform: rotateZ(135deg);
+        -webkit-transform: rotateZ(135deg);
+        &.fold {
+          transform: rotateZ(0deg);
+          -webkit-transform: rotateZ(0deg);
+        }
+      }
+      &.vertical {
+        flex-direction: column;
+        .float-menu-item {
+          margin-right: 0;
+          margin-bottom: 10px;
+        }
+      }
+    }
+  }
+</style>

+ 60 - 0
src/app/shop/admin/decorate/page/component/center/basic/floatMenu/setting.vue

@@ -0,0 +1,60 @@
+<template>
+  <div class="setting">
+    <div class="card">
+      <div class="title">展示图标</div>
+      <div class="content">
+        <el-form-item label="状态">
+          <el-radio-group v-model="settingData.show">
+            <el-radio :label="0">关闭</el-radio>
+            <el-radio :label="1">开启</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="悬浮样式">
+          <el-radio-group v-model="settingData.mode">
+            <el-radio :label="1">垂直</el-radio>
+            <el-radio :label="2">水平</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="显示名称">
+          <el-radio-group v-model="settingData.isText">
+            <el-radio :label="0">不显示</el-radio>
+            <el-radio :label="1">显示</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </div>
+    </div>
+    <dc-list
+      v-model="settingData.list"
+      :itemProp="{ src: '', url: '', title: { text: '', color: '' } }"
+    >
+      <template #title>功能图标</template>
+      <template #listItem="{ element }">
+        <el-form-item label="按钮图片">
+          <div class="sa-flex">
+            <sa-uploader v-model="element.src" fileType="image"></sa-uploader>
+            <span class="tip">建议尺寸:80*80</span>
+          </div>
+        </el-form-item>
+        <el-form-item label="按钮名称">
+          <dc-text-color
+            v-model="element.title"
+            maxlength="4"
+            showWordLimit
+            placeholder="请输入按钮名称"
+          ></dc-text-color>
+        </el-form-item>
+        <el-form-item label="按钮链接">
+          <dc-url v-model="element.url"></dc-url>
+        </el-form-item>
+      </template>
+    </dc-list>
+  </div>
+</template>
+
+<script setup>
+  import dcList from '../../common/dc-list.vue';
+  import dcUrl from '../../common/dc-url.vue';
+  import dcTextColor from '../../common/dc-text-color.vue';
+
+  const props = defineProps(['settingData']);
+</script>

+ 7 - 0
src/app/shop/admin/decorate/page/component/center/basic/guidePage/index.vue

@@ -0,0 +1,7 @@
+<template>
+  <div>引导页--{{ compData }}</div>
+</template>
+
+<script setup>
+  const props = defineProps(['compData']);
+</script>

+ 25 - 0
src/app/shop/admin/decorate/page/component/center/basic/guidePage/setting.vue

@@ -0,0 +1,25 @@
+<template>
+  <div class="setting">
+    <div class="card">
+      <div class="title sa-flex">
+        引导页面
+        <div class="warning">用户首次启动应用时才会显示</div>
+      </div>
+      <div class="content">
+        <el-form-item label="状态">
+          <el-radio-group v-model="settingData.status">
+            <el-radio :label="false">关闭</el-radio>
+            <el-radio :label="true">开启</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="上传图片">
+          <sa-uploader v-model="settingData.list" :multiple="true" fileType="image"></sa-uploader>
+        </el-form-item>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  const props = defineProps(['settingData']);
+</script>

+ 24 - 0
src/app/shop/admin/decorate/page/component/center/basic/index.vue

@@ -0,0 +1,24 @@
+<template>
+  <component :is="type" :compData="compData" :allData="allData" />
+</template>
+
+<script>
+  // import splashScreen from "./splashScreen/index.vue";
+  // import guidePage from "./guidePage/index.vue";
+  import tabbar from './tabbar/index.vue';
+  import floatMenu from './floatMenu/index.vue';
+  import popupImage from './popupImage/index.vue';
+  export default {
+    components: {
+      // splashScreen,
+      // guidePage,
+      tabbar,
+      floatMenu,
+      popupImage,
+    },
+  };
+</script>
+
+<script setup>
+  const props = defineProps(['type', 'compData', 'allData']);
+</script>

+ 53 - 0
src/app/shop/admin/decorate/page/component/center/basic/popupImage/index.vue

@@ -0,0 +1,53 @@
+<template>
+  <div class="popup-image">
+    <div
+      class="popup-image-item"
+      v-for="(item, index) in compData.list"
+      :key="item"
+      :style="itemStyle(index)"
+      @click="currentIndex = index"
+    >
+      <sa-image :url="item.src" fit="contain" :suffix="null" />
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { ref } from 'vue';
+
+  const props = defineProps(['compData']);
+
+  const currentIndex = ref(-1);
+
+  function itemStyle(index) {
+    return {
+      'margin-right': `-${146 + index * 20}px`,
+      'margin-bottom': `-${227 + index * 20}px`,
+      'z-index': currentIndex.value == index ? 200 : index,
+    };
+  }
+</script>
+
+<style lang="scss" scoped>
+  .popup-image {
+    height: 100%;
+    background: rgb(153, 153, 153);
+    // position: relative;
+    .popup-image-item {
+      width: 292px;
+      height: 454px;
+      position: absolute;
+      right: 50%;
+      bottom: 50%;
+      margin-right: -146px;
+      margin-bottom: -227px;
+      border: 1px solid var(--sa-border);
+      background: #fff;
+      border-radius: 4px;
+      .sa-image {
+        width: 100%;
+        height: 100%;
+      }
+    }
+  }
+</style>

+ 46 - 0
src/app/shop/admin/decorate/page/component/center/basic/popupImage/setting.vue

@@ -0,0 +1,46 @@
+<template>
+  <div class="setting">
+    <!-- <div class="card">
+      <div class="title">样式</div>
+      <div class="content">
+        <el-form-item label="样式">
+          <el-radio-group v-model="settingData.mode">
+            <el-radio :label="1">1</el-radio>
+            <el-radio :label="2">2</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </div>
+    </div> -->
+    <dc-list
+      v-model="settingData.list"
+      :itemProp="{
+        src: '',
+        url: '',
+        show: 1,
+      }"
+    >
+      <template #title>展示图标</template>
+      <template #listItem="{ element }">
+        <el-form-item label="广告图">
+          <sa-uploader v-model="element.src" fileType="image"></sa-uploader>
+        </el-form-item>
+        <el-form-item label="选择链接">
+          <dc-url v-model="element.url"></dc-url>
+        </el-form-item>
+        <el-form-item label="显示次数">
+          <el-radio-group v-model="element.show">
+            <el-radio :label="1">仅显示一次</el-radio>
+            <el-radio :label="2">应用启动显示</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </template>
+    </dc-list>
+  </div>
+</template>
+
+<script setup>
+  import dcList from '../../common/dc-list.vue';
+  import dcUrl from '../../common/dc-url.vue';
+
+  const props = defineProps(['settingData']);
+</script>

+ 7 - 0
src/app/shop/admin/decorate/page/component/center/basic/splashScreen/index.vue

@@ -0,0 +1,7 @@
+<template>
+  <div>启动页--{{ compData }}</div>
+</template>
+
+<script setup>
+  const props = defineProps(['compData']);
+</script>

+ 32 - 0
src/app/shop/admin/decorate/page/component/center/basic/splashScreen/setting.vue

@@ -0,0 +1,32 @@
+<template>
+  <div class="setting">
+    <div class="card">
+      <div class="title">启动设置</div>
+      <div class="content">
+        <el-form-item label="状态">
+          <el-radio-group v-model="settingData.status">
+            <el-radio :label="false">关闭</el-radio>
+            <el-radio :label="true">开启</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="上传图片">
+          <sa-uploader v-model="settingData.src" fileType="image"></sa-uploader>
+        </el-form-item>
+        <el-form-item label="跳过时间">
+          <el-input v-model="settingData.countdown">
+            <template #append>秒</template>
+          </el-input>
+        </el-form-item>
+        <el-form-item label="链接">
+          <dc-url v-model="settingData.url"></dc-url>
+        </el-form-item>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import dcUrl from '../../common/dc-url.vue';
+
+  const props = defineProps(['settingData']);
+</script>

+ 95 - 0
src/app/shop/admin/decorate/page/component/center/basic/tabbar/index.vue

@@ -0,0 +1,95 @@
+<template>
+  <div class="tabbar">
+    <div class="tabbar-wrap sa-flex" :style="wrapStyle()">
+      <div
+        class="tabbar-item"
+        :class="
+          compData.list.length % 2 == 1 &&
+          compData.mode == 2 &&
+          cindex == ((compData.list.length % 2) + compData.list.length) / 2 - 1
+            ? 'center-item'
+            : ''
+        "
+        v-for="(cd, cindex) in compData.list"
+        :key="cd"
+        :style="{
+          width: 100 / compData.list.length + '%',
+        }"
+      >
+        <div class="image-wrap">
+          <sa-image
+            :url="cindex == 0 ? cd.activeIcon : cd.inactiveIcon"
+            :size="26"
+            :suffix="null"
+          ></sa-image>
+        </div>
+        <div
+          class="text"
+          :style="{
+            color: cindex == 0 ? compData.activeColor : compData.inactiveColor,
+          }"
+        >
+          {{ cd.text }}
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { checkUrl } from '@/sheep/utils/checkUrlSuffix';
+
+  const props = defineProps(['compData']);
+
+  function wrapStyle() {
+    return {
+      background:
+        props.compData.background.type == 'color'
+          ? props.compData.background.bgColor
+          : props.compData.background.bgImage
+          ? 'url(' + checkUrl(props.compData.background.bgImage) + ')'
+          : props.compData.background.bgImage,
+      'background-size': '100% 58px',
+      'background-repeat': 'no-repeat',
+    };
+  }
+</script>
+
+<style lang="scss" scoped>
+  .tabbar {
+    height: 100%;
+    // position: relative;
+
+    .tabbar-wrap {
+      position: absolute;
+      right: 0;
+      bottom: 0;
+      left: 0;
+      height: 58px;
+    }
+    .tabbar-item {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      font-size: 12px;
+      &.center-item {
+        .image-wrap {
+          width: 49px;
+          height: 49px;
+          border-radius: 50%;
+          background: #f00;
+          margin-bottom: 4px;
+          background: linear-gradient(123deg, #fe8c00 0%, #ff6000 100%);
+          box-shadow: 0 9px 13px 5px rgba(254, 129, 0, 0.22);
+          display: flex;
+          align-items: center;
+          justify-content: center;
+        }
+        .text {
+          display: none;
+        }
+      }
+    }
+  }
+</style>

+ 96 - 0
src/app/shop/admin/decorate/page/component/center/basic/tabbar/setting.vue

@@ -0,0 +1,96 @@
+<template>
+  <div class="setting">
+    <template v-if="tabType == 'data'">
+      <div class="card">
+        <div class="title">导航样式</div>
+        <div class="content">
+          <el-form-item label="选择样式">
+            <el-radio-group class="custom-radio-button" v-model="settingData.mode">
+              <el-radio-button :label="1">
+                <sa-icon icon="sa-shop-decorate-tabbar-mode-1" />
+              </el-radio-button>
+              <el-radio-button :label="2">
+                <sa-icon icon="sa-shop-decorate-tabbar-mode-2" />
+              </el-radio-button>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item label="导航风格">
+            <el-radio-group v-model="settingData.layout">
+              <el-radio :label="1">文字+图片</el-radio>
+              <el-radio :label="2">文字</el-radio>
+              <el-radio :label="3">图片</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item label="默认颜色">
+            <dc-color-picker v-model="settingData.inactiveColor"></dc-color-picker>
+          </el-form-item>
+          <el-form-item label="选中颜色">
+            <dc-color-picker v-model="settingData.activeColor"></dc-color-picker>
+          </el-form-item>
+        </div>
+      </div>
+      <dc-list
+        v-model="settingData.list"
+        :itemProp="{
+          inactiveIcon: '',
+          activeIcon: '',
+          url: '',
+          text: '',
+        }"
+      >
+        <template #title>图标设置</template>
+        <template #listItem="{ element }">
+          <template v-if="settingData.layout == 1 || settingData.layout == 3">
+            <el-form-item label="默认图片">
+              <div class="sa-flex">
+                <sa-uploader v-model="element.inactiveIcon" fileType="image"></sa-uploader>
+                <span class="tip">建议尺寸:44*44</span>
+              </div>
+            </el-form-item>
+            <el-form-item label="选中图片">
+              <div class="sa-flex">
+                <sa-uploader v-model="element.activeIcon" fileType="image"></sa-uploader>
+                <span class="tip">建议尺寸:44*44</span>
+              </div>
+            </el-form-item>
+          </template>
+          <el-form-item v-if="settingData.layout == 1 || settingData.layout == 2" label="文字">
+            <el-input v-model="element.text"></el-input>
+          </el-form-item>
+          <el-form-item label="选择链接">
+            <dc-url v-model="element.url"></dc-url>
+          </el-form-item>
+        </template>
+      </dc-list>
+    </template>
+    <template v-if="tabType == 'style'">
+      <div class="card">
+        <div class="title">组件样式</div>
+        <div class="content">
+          <el-form-item label="导航背景">
+            <el-radio-group v-model="settingData.background.type">
+              <el-radio label="color">纯色</el-radio>
+              <el-radio label="image">图片</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item v-if="settingData.background.type == 'color'" label="选择颜色">
+            <dc-color-picker v-model="settingData.background.bgColor"></dc-color-picker>
+          </el-form-item>
+          <el-form-item v-if="settingData.background.type == 'image'" label="选择图片">
+            <div class="sa-flex">
+              <sa-uploader v-model="settingData.background.bgImage" fileType="image"></sa-uploader>
+            </div>
+          </el-form-item>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup>
+  import dcList from '../../common/dc-list.vue';
+  import dcUrl from '../../common/dc-url.vue';
+  import dcColorPicker from '../../common/dc-color-picker.vue';
+
+  const props = defineProps(['settingData', 'tabType']);
+</script>

+ 99 - 0
src/app/shop/admin/decorate/page/component/center/common/dc-color-picker.vue

@@ -0,0 +1,99 @@
+<template>
+  <div class="dc-color-picker sa-flex">
+    <img class="empty" src="/static/images/shop/decorate/picker.png" />
+    <el-color-picker v-model="color" @change="changeColor" />
+    <el-input v-model="color" @input="changeColor"></el-input>
+    <el-checkbox
+      v-if="isShow"
+      class="sa-m-l-16"
+      v-model="flag"
+      :true-label="1"
+      :false-label="0"
+      @change="changeShow"
+    />
+  </div>
+</template>
+<script>
+  export default {
+    name: 'dc-color-picker',
+  };
+</script>
+<script setup>
+  import { ref, watch } from 'vue';
+  const emit = defineEmits(['update:modelValue', 'update:show']);
+  const props = defineProps({
+    modelValue: {
+      type: String,
+      default: '',
+    },
+    show: {
+      type: Number,
+      default: 0,
+    },
+    isShow: {
+      type: Boolean,
+      default: false,
+    },
+  });
+
+  const color = ref(props.modelValue || '');
+  watch(
+    () => props.modelValue,
+    () => {
+      color.value = props.modelValue;
+    },
+  );
+
+  function changeColor() {
+    emit('update:modelValue', color.value ? color.value : '');
+  }
+
+  const flag = ref(props.show || '');
+  watch(
+    () => props.show,
+    () => {
+      flag.value = props.show;
+    },
+  );
+
+  function changeShow() {
+    emit('update:show', flag.value);
+  }
+</script>
+<style lang="scss" scoped>
+  .dc-color-picker {
+    .empty {
+      width: 32px;
+      height: 32px;
+      border-radius: 4px 0 0 4px;
+      position: absolute;
+      top: 0;
+      right: 0;
+      bottom: 0;
+      left: 0;
+    }
+    :deep() {
+      .el-color-picker__trigger {
+        display: flex;
+        padding: 0;
+        border-radius: 4px 0 0 4px;
+        border-right: none;
+        overflow: hidden;
+        .el-color-picker__color {
+          border: none;
+        }
+        .el-color-picker__empty {
+          display: none;
+        }
+      }
+    }
+    :deep() {
+      .el-input {
+        flex: 1;
+        .el-input__wrapper {
+          border-radius: 0 4px 4px 0;
+        }
+      }
+    }
+  }
+</style>

+ 166 - 0
src/app/shop/admin/decorate/page/component/center/common/dc-goods-select.vue

@@ -0,0 +1,166 @@
+<template>
+  <div class="dc-goods-select">
+    <div class="card">
+      <div class="title">
+        <slot name="title">商品选择</slot>
+      </div>
+      <div class="content">
+        <sa-draggable class="sa-flex sa-flex-wrap" v-model="listData" item-key="element" :animation="300"
+          @end="updateItem">
+          <template #item="{ element, index }">
+            <div class="goods-item">
+              <sa-image :url="type == 'goods' ? element.image : element.feeds_img" size="44"></sa-image>
+              <div class="goods-delete" @click="deleteGoods(index)">
+                <el-icon>
+                  <Delete />
+                </el-icon>
+              </div>
+            </div>
+          </template>
+        </sa-draggable>
+        <slot name="add">
+          <el-button class="add-button" icon="Plus" @click="addGoods">添加</el-button>
+        </slot>
+      </div>
+    </div>
+  </div>
+</template>
+<script>
+export default {
+  name: 'dc-goods-select',
+};
+</script>
+<script setup>
+/**
+ * @property {Array} modelValue - 商品列表
+ * @property {Boolean} multiple - 是否多选
+ * @property {Number} max - 多选最大
+ */
+import { ref, watch } from 'vue';
+import SaDraggable from 'vuedraggable';
+import { useModal } from '@/sheep/hooks';
+import GoodsSelect from '@/app/shop/admin/goods/goods/select.vue';
+
+const emit = defineEmits(['update:modelValue']);
+const props = defineProps({
+  modelValue: {
+    type: Array,
+    default: [],
+  },
+  multiple: {
+    type: Boolean,
+    default: false,
+  },
+  max: {
+    type: Number,
+    default: 0,
+  },
+  type: {
+    type: String,
+    default: 'goods',
+  },
+});
+
+const listData = ref(props.modelValue || []);
+watch(
+  () => props.modelValue,
+  () => {
+    listData.value = props.modelValue;
+  },
+);
+
+function addGoods() {
+  let ids = [];
+  listData.value.forEach((i) => {
+    ids.push(i.id);
+  });
+  if (props.type == 'goods') {
+    useModal(
+      GoodsSelect,
+      {
+        title: '选择商品',
+        multiple: props.multiple,
+        max: props.max,
+        ids,
+      },
+      {
+        confirm: (res) => {
+          listData.value.length = 0;
+          if (props.multiple) {
+            res.data.forEach((element) => {
+              listData.value.push(element);
+            });
+          } else {
+            listData.value.push(res.data);
+          }
+        },
+      },
+    );
+  } else if (props.type == 'mplive') {
+    useModal(
+      MpliveSelect,
+      {
+        title: '选择直播间',
+      },
+      {
+        confirm: (res) => {
+          listData.value.length = 0;
+          // if (props.multiple) {
+          res.data.forEach((element) => {
+            listData.value.push(element);
+          });
+          // } else {
+          //   listData.value.push(res.data);
+          // }
+        },
+      },
+    );
+  }
+
+}
+function deleteGoods(index) {
+  listData.value.splice(index, 1);
+}
+function updateItem() {
+  emit('update:modelValue', listData.value);
+}
+</script>
+<style lang="scss" scoped>
+.dc-goods-select {
+  .content {
+    padding: 0 20px;
+
+    .goods-item {
+      margin: 0 8px 16px 0;
+      position: relative;
+      width: 44px;
+      height: 44px;
+
+      &:hover {
+        .goods-delete {
+          display: flex;
+        }
+      }
+
+      .goods-delete {
+        position: absolute;
+        top: 0;
+        right: 0;
+        bottom: 0;
+        left: 0;
+        font-size: 16px;
+        color: var(--sa-basic-mask-color);
+        background: var(--sa-basic-mask-background);
+        display: none;
+        align-items: center;
+        justify-content: center;
+        cursor: pointer;
+      }
+    }
+
+    .add-button {
+      margin: 0 0 16px 0 !important;
+    }
+  }
+}
+</style>

+ 108 - 0
src/app/shop/admin/decorate/page/component/center/common/dc-list.vue

@@ -0,0 +1,108 @@
+<template>
+  <div class="dc-list card">
+    <div class="title">
+      <slot name="title">数据</slot>
+    </div>
+    <div class="content">
+      <sa-draggable
+        v-model="listData"
+        item-key="element"
+        :animation="300"
+        handle=".sortable-drag"
+        @end="updateItem"
+      >
+        <template #item="{ element, index }">
+          <div class="list-item">
+            <div class="list-item-posi sa-flex">
+              <sa-icon class="sortable-drag" icon="sa-round" />
+              <slot name="deleteIcon">
+                <span class="list-delete" @click="deleteItem(index)">删除</span>
+              </slot>
+            </div>
+            <slot name="listItem" :element="element"></slot>
+          </div>
+        </template>
+      </sa-draggable>
+      <slot name="add" v-if="listData?.length != leng">
+        <el-button class="add-button" icon="Plus" @click="addItem">添加</el-button>
+      </slot>
+    </div>
+  </div>
+</template>
+<script>
+  export default {
+    name: 'dc-list',
+  };
+</script>
+<script setup>
+  import { ref, watch } from 'vue';
+  import SaDraggable from 'vuedraggable';
+  import { cloneDeep } from 'lodash';
+
+  const emit = defineEmits(['update:modelValue']);
+  const props = defineProps(['modelValue', 'itemProp', 'leng']);
+
+  const listData = ref(props.modelValue || []);
+  watch(
+    () => props.modelValue,
+    () => {
+      listData.value = props.modelValue;
+    },
+  );
+
+  function addItem() {
+    listData.value.push(cloneDeep(props.itemProp));
+  }
+  function deleteItem(index) {
+    listData.value.splice(index, 1);
+  }
+  function updateItem() {
+    emit('update:modelValue', listData.value);
+  }
+</script>
+<style lang="scss" scoped>
+  .dc-list {
+    .title {
+      margin-bottom: 0;
+    }
+    .list-item {
+      padding: 8px 0;
+      border-bottom: 1px solid var(--sa-space);
+      background: var(--sa-background-assist);
+      &:last-of-type {
+        border-bottom: none;
+        // margin-bottom: 16px;
+      }
+      &:hover {
+        background: var(--t-bg-hover);
+        :deep() {
+          .list-item-posi {
+            & > * {
+              display: block;
+            }
+          }
+        }
+      }
+      :deep() {
+        .list-item-posi {
+          padding: 0 4px 16px;
+          height: 32px;
+          & > * {
+            display: none;
+          }
+
+          .sortable-drag {
+            font-size: 16px;
+            margin-right: 8px;
+            cursor: pointer;
+          }
+
+          .list-delete {
+            color: #ff4d4f;
+            cursor: pointer;
+          }
+        }
+      }
+    }
+  }
+</style>

+ 71 - 0
src/app/shop/admin/decorate/page/component/center/common/dc-slider.vue

@@ -0,0 +1,71 @@
+<template>
+  <div class="dc-slider sa-flex">
+    <el-slider v-model="sliderValue" :show-tooltip="false" @input="changeSlider"></el-slider>
+    <el-input v-model="sliderInput" type="number" @input="changeInput">
+      <template #suffix>
+        <span class="el-input__icon">PX</span>
+      </template>
+    </el-input>
+  </div>
+</template>
+<script>
+  export default {
+    name: 'dc-slider',
+  };
+</script>
+<script setup>
+  import { onUpdated, ref } from 'vue';
+
+  const emit = defineEmits(['update:modelValue']);
+  const props = defineProps({
+    modelValue: {
+      type: Number,
+      default: 0,
+    },
+    mult: {
+      type: Number,
+      default: 1,
+    },
+  });
+
+  const sliderValue = ref(props.modelValue / props.mult);
+  function changeSlider(sv) {
+    sliderInput.value = Number(sv * props.mult);
+    emit('update:modelValue', Number(sv * props.mult));
+  }
+
+  const sliderInput = ref(props.modelValue);
+  function changeInput(si) {
+    sliderValue.value = Number(si / props.mult);
+    emit('update:modelValue', Number(si));
+  }
+
+  onUpdated(() => {
+    sliderValue.value = props.modelValue / props.mult;
+    sliderInput.value = props.modelValue;
+  });
+</script>
+<style lang="scss" scoped>
+  .dc-slider {
+    .el-slider {
+      flex: 1;
+    }
+    :deep() {
+      .el-slider {
+        --el-slider-height: 4px;
+        --el-slider-button-size: 12px;
+      }
+      .el-input {
+        width: 70px;
+        margin-left: 16px;
+      }
+      input::-webkit-outer-spin-button,
+      input::-webkit-inner-spin-button {
+        -webkit-appearance: none !important;
+      }
+      input[type='number'] {
+        -moz-appearance: textfield;
+      }
+    }
+  }
+</style>

+ 80 - 0
src/app/shop/admin/decorate/page/component/center/common/dc-text-color.vue

@@ -0,0 +1,80 @@
+<template>
+  <div class="dc-text-color sa-flex">
+    <el-input
+      v-model="modelValue.text"
+      :placeholder="placeholder"
+      :maxlength="maxlength"
+      :show-word-limit="showWordLimit"
+    ></el-input>
+    <div class="color sa-flex">
+      <img class="empty" src="/static/images/shop/decorate/picker.png" />
+      <el-color-picker v-model="modelValue.color" />
+    </div>
+  </div>
+</template>
+
+<script setup>
+  const emit = defineEmits(['update:modelValue', 'props']);
+  const props = defineProps({
+    modelValue: {
+      type: Object,
+    },
+    placeholder: {
+      type: String,
+      default: '请输入',
+    },
+    maxlength: {
+      type: [String, Number],
+      default: '',
+    },
+    showWordLimit: {
+      type: Boolean,
+      default: false,
+    },
+  });
+
+  function changeColor() {
+    emit('update:modelValue', props.modelValue);
+  }
+</script>
+<style lang="scss" scoped>
+  .dc-text-color {
+    width: 196px;
+    :deep() {
+      .el-input {
+        flex: 1;
+        .el-input__wrapper {
+          border-radius: 4px 0 0 4px;
+        }
+      }
+    }
+    :deep() {
+      .el-color-picker__trigger {
+        display: flex;
+        padding: 0;
+        border-radius: 0 4px 4px 0;
+        border-left: none;
+        overflow: hidden;
+        .el-color-picker__color {
+          border: none;
+        }
+        .el-color-picker__empty {
+          display: none;
+        }
+      }
+    }
+    .color {
+      position: relative;
+      .empty {
+        width: 32px;
+        height: 32px;
+        border-radius: 0 4px 4px 0;
+        position: absolute;
+        top: 0;
+        right: 0;
+        bottom: 0;
+        left: 0;
+      }
+    }
+  }
+</style>

+ 54 - 0
src/app/shop/admin/decorate/page/component/center/common/dc-url.vue

@@ -0,0 +1,54 @@
+<template>
+  <div class="dc-url sa-flex">
+    <el-input v-model="path" :placeholder="placeholder" @input="changeurl">
+      <template #append>
+        <span class="cursor-pointer" @click="selecturl">选择</span>
+      </template>
+    </el-input>
+  </div>
+</template>
+<script>
+  export default {
+    name: 'dc-url',
+  };
+</script>
+<script setup>
+  import { ref, watch } from 'vue';
+  import { useModal } from '@/sheep/hooks';
+  import PageSelect from '@/app/shop/admin/data/page/select.vue';
+
+  const emit = defineEmits(['update:modelValue']);
+  const props = defineProps({
+    modelValue: {
+      type: String,
+      default: '',
+    },
+    placeholder: {
+      type: String,
+      default: '请输入或选择',
+    },
+  });
+
+  const path = ref(props.modelValue);
+  watch(
+    () => props.modelValue,
+    () => {
+      path.value = props.modelValue;
+    },
+  );
+
+  function changeurl() {
+    emit('update:modelValue', path.value);
+  }
+  function selecturl() {
+    useModal(
+      PageSelect,
+      { title: '选择链接' },
+      {
+        confirm: (res) => {
+          emit('update:modelValue', res.data.path);
+        },
+      },
+    );
+  }
+</script>

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

@@ -0,0 +1,206 @@
+<template>
+  <div class="coupon">
+    <div class="coupon-wrap" :style="wrapStyle()">
+      <template v-for="(item, index) in compData.data.couponList" :key="item">
+        <div class="item sa-flex" :style="itemStyle()">
+          <template v-if="compData.data.mode == 1">
+            <div
+              v-if="index < 2"
+              class="coupon-1-item sa-flex sa-row-between"
+              :style="itemWrapStyle()"
+            >
+              <div>
+                <div class="amount">
+                  {{item.type=='discount'?Number(item.amount).toFixed(0):item.amount}}<span>{{item.type=='discount'?'折':'元'}}</span>
+                </div>
+                <div class="amount-text sa-m-t-6">{{ item.amount_text }}</div>
+                <!-- <div>有效期:</div> -->
+                <div class="time">
+                  有效期:{{item.get_start_time.split(' ')[0]}} 至 {{item.get_end_time.split(' ')[0]}}
+                </div>
+              </div>
+              <div>
+                <div class="btn" :style="btnStyle()">立即领取</div>
+                <div class="stock sa-m-t-12">仅剩{{ item.stock }}张</div>
+              </div>
+            </div>
+          </template>
+          <div
+            v-if="compData.data.mode == 2"
+            class="coupon-2-item sa-flex sa-row-between"
+            :style="itemWrapStyle()"
+          >
+            <div>
+              <div class="amount">
+                {{item.type=='discount'?Number(item.amount).toFixed(0):item.amount}}<span>{{item.type=='discount'?'折':'元'}}</span>
+              </div>
+              <div class="amount-text sa-m-t-6">{{ item.amount_text }}</div>
+              <div class="stock sa-m-t-6">仅剩:{{ item.stock }}张</div>
+            </div>
+            <div class="btn" :style="btnStyle()">立即领取</div>
+          </div>
+          <div v-if="compData.data.mode == 3" class="coupon-3-item" :style="itemWrapStyle()">
+            <div class="amount sa-m-t-24">
+              {{item.type=='discount'?Number(item.amount).toFixed(0):item.amount}}<span>{{item.type=='discount'?'折':'元'}}</span>
+            </div>
+            <div class="amount-text sa-m-t-10">{{ item.amount_text }}</div>
+            <div class="btn sa-m-t-10" :style="btnStyle()">立即领取</div>
+          </div>
+        </div>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { checkUrl } from '@/sheep/utils/checkUrlSuffix';
+
+  const props = defineProps(['compData']);
+
+  const w = {
+    1: '90%',
+    2: '50%',
+    3: '33.3%',
+  };
+
+  function wrapStyle() {
+    return {
+      display: 'flex',
+      'flex-wrap': 'nowrap',
+      margin: `-${props.compData.data.space / 2}px`,
+    };
+  }
+
+  function itemStyle() {
+    return {
+      width: `${w[props.compData.data.mode]}`,
+      padding: `${props.compData.data.space / 2}px`,
+      'flex-shrink': 0,
+    };
+  }
+  function itemWrapStyle() {
+    return {
+      color: props.compData.data.fill.color,
+      background:
+        (props.compData.data.fill.bgImage
+          ? 'url(' + checkUrl(props.compData.data.fill.bgImage) + ')'
+          : props.compData.data.fill.bgImage) + '100% no-repeat',
+      'background-size': '100% 100%',
+    };
+  }
+
+  function btnStyle() {
+    return {
+      color: props.compData.data.button.color,
+      background: `${props.compData.data.button.bgColor}`,
+    };
+  }
+</script>
+<style lang="scss" scoped>
+  .coupon {
+    .coupon-1-item {
+      width: 100%;
+      height: 78px;
+      padding: 0 10px 0 20px;
+      flex-shrink: 0;
+      line-height: 1;
+      .amount {
+        font-size: 24px;
+        span {
+          font-size: 12px;
+          margin-left: 2px;
+        }
+      }
+      .amount-text {
+        font-size: 12px;
+        font-weight: 500;
+      }
+      .time{
+        font-size: 12px;
+        margin-top: 4px;
+      }
+      .btn {
+        height: 24px;
+        line-height: 24px;
+        padding: 0 10px;
+        text-align: center;
+        border-radius: 12px;
+        font-size: 12px;
+        font-weight: 500;
+      }
+      .stock {
+        font-size: 12px;
+        font-weight: 500;
+        text-align: center;
+      }
+    }
+    .coupon-2-item {
+      width: 100%;
+      height: 84px;
+      padding: 0 10px 0 20px;
+      line-height: 1;
+      .amount {
+        font-size: 24px;
+        span {
+          font-size: 12px;
+          margin-left: 2px;
+        }
+      }
+      .amount-text {
+        font-size: 12px;
+        font-weight: 500;
+      }
+      .stock {
+        font-size: 12px;
+        font-weight: 500;
+      }
+      .btn {
+        width: 20px;
+        height: fit-content;
+        // line-height: 24px;
+        padding: 4px 0;
+        text-align: center;
+        border-radius: 10px;
+        font-size: 12px;
+        font-weight: 500;
+        // writing-mode: vertical-lr;
+        display: flex;
+        flex-direction: column;
+      }
+    }
+    .coupon-3-item {
+      width: 100%;
+      height: 146px;
+      border-radius: 4px;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      .amount {
+        font-size: 25px;
+        font-weight: 600;
+        span {
+          font-size: 12px;
+          font-weight: 500;
+          margin-left: 2px;
+        }
+      }
+      .amount-text {
+        width: 80px;
+        height: 36px;
+        line-height: 18px;
+        text-align: center;
+        font-size: 12px;
+        font-weight: 500;
+      }
+      .btn {
+        height: 24px;
+        line-height: 24px;
+        padding: 0 10px;
+        text-align: center;
+        border-radius: 12px;
+        font-size: 12px;
+        font-weight: 500;
+      }
+    }
+  }
+</style>

+ 85 - 0
src/app/shop/admin/decorate/page/component/center/comp/coupon/setting.vue

@@ -0,0 +1,85 @@
+<template>
+  <div class="coupon-setting setting">
+    <template v-if="tabType == 'data'">
+      <dc-list v-model="settingData.data.couponList">
+        <template #title>优惠券选择</template>
+        <template #listItem="{ element }">
+          <el-form-item label-width="28px">
+            <div class="name sa-m-b-4">
+              {{ element.name }}
+            </div>
+            <div class="amount-text">
+              {{ element.amount_text }}
+            </div>
+          </el-form-item>
+        </template>
+        <template #add>
+          <el-button class="add-button" icon="Plus" @click="onAdd">添加</el-button>
+        </template>
+      </dc-list>
+      <div class="card">
+        <div class="title">优惠券样式</div>
+        <div class="content">
+          <el-form-item label="选择风格">
+            <el-radio-group class="custom-radio-button" v-model="settingData.data.mode">
+              <el-radio-button :label="1">
+                <sa-icon icon="sa-shop-decorate-coupon-mode-1" />
+              </el-radio-button>
+              <el-radio-button :label="2">
+                <sa-icon icon="sa-shop-decorate-coupon-mode-2" />
+              </el-radio-button>
+              <el-radio-button :label="3">
+                <sa-icon icon="sa-shop-decorate-coupon-mode-3" />
+              </el-radio-button>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item label="背景图片">
+            <sa-uploader v-model="settingData.data.fill.bgImage" fileType="image"></sa-uploader>
+          </el-form-item>
+          <el-form-item label="文字颜色">
+            <dc-color-picker v-model="settingData.data.fill.color"></dc-color-picker>
+          </el-form-item>
+          <el-form-item label="按钮背景">
+            <dc-color-picker v-model="settingData.data.button.bgColor"></dc-color-picker>
+          </el-form-item>
+          <el-form-item label="按钮文字">
+            <dc-color-picker v-model="settingData.data.button.color"></dc-color-picker>
+          </el-form-item>
+          <el-form-item label="间距">
+            <dc-slider v-model="settingData.data.space"></dc-slider>
+          </el-form-item>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup>
+import { useModal } from '@/sheep/hooks';
+import dcColorPicker from '../../common/dc-color-picker.vue';
+import dcList from '../../common/dc-list.vue';
+import dcSlider from '../../common/dc-slider.vue';
+
+const props = defineProps(['settingData', 'tabType']);
+
+function onAdd() {
+  console.warn('CouponSelect component not available');
+}
+</script>
+<style lang="scss" scoped>
+.coupon-setting {
+  .name {
+    line-height: 16px;
+    font-size: 12px;
+    font-weight: 400;
+    color: var(--sa-title);
+  }
+
+  .amount-text {
+    line-height: 16px;
+    font-size: 12px;
+    font-weight: 500;
+    color: var(--sa-subfont);
+  }
+}
+</style>

+ 13 - 0
src/app/shop/admin/decorate/page/component/center/comp/couponCard/index.vue

@@ -0,0 +1,13 @@
+<template>
+  <div class="coupon-card">
+    <img src="/static/images/shop/decorate/couponCardStyle.png" />
+  </div>
+</template>
+
+<style lang="scss" scoped>
+  .coupon-card {
+    img {
+      width: 100%;
+    }
+  }
+</style>

+ 201 - 0
src/app/shop/admin/decorate/page/component/center/comp/goodsCard/index.vue

@@ -0,0 +1,201 @@
+<template>
+  <div class="goods-card">
+    <div :class="[`goods-card-wrap-${compData.data.mode}`]" :style="wrapStyle">
+      <template v-for="goods in compData.data.goodsList" :key="goods">
+        <div class="item" :style="itemStyle">
+          <div class="item-wrap" :style="itemWrapStyle">
+            <sa-image :url="goods.image" :radius="0" :suffix="null"></sa-image>
+            <div class="desc">
+              <div>
+                <div
+                  v-if="compData.data.goodsFields.title.show"
+                  class="title"
+                  :class="`sa-table-line-${compData.data.mode == 3 ? '2' : '1'}`"
+                  :style="{
+                    color: compData.data.goodsFields.title.color,
+                  }"
+                >
+                  {{ goods.title }}
+                </div>
+                <div
+                  v-if="compData.data.goodsFields.subtitle.show"
+                  class="subtitle sa-table-line-1 sa-m-b-4"
+                  :style="{
+                    color: compData.data.goodsFields.subtitle.color,
+                  }"
+                >
+                  {{ goods.subtitle }}
+                </div>
+                <el-scrollbar class="promos">
+                  <div class="sa-flex sa-m-b-8">
+                    <div class="promo-tag" v-for="item in goods.promos" :key="item">
+                      <span>{{ item.title }}</span>
+                    </div>
+                  </div>
+                </el-scrollbar>
+              </div>
+              <div>
+                <div class="sa-flex sa-m-b-8">
+                  <div
+                    v-if="compData.data.goodsFields.price.show"
+                    class="price"
+                    :style="{
+                      color: compData.data.goodsFields.price.color,
+                    }"
+                  >
+                    ¥{{ goods.price[0] }}
+                  </div>
+                  <s
+                    v-if="compData.data.goodsFields.original_price.show"
+                    class="original-price sa-m-l-4"
+                    :style="{
+                      color: compData.data.goodsFields.original_price.color,
+                    }"
+                    >¥{{ goods.original_price }}</s
+                  >
+                </div>
+                <div
+                  v-if="compData.data.goodsFields.sales.show"
+                  class="sales"
+                  :style="{
+                    color: compData.data.goodsFields.sales.color,
+                  }"
+                >
+                  已售{{ goods.sales }}件
+                </div>
+              </div>
+              <div
+                class="button"
+                :style="{
+                  background:
+                    compData.data.buyNowStyle.mode == 1
+                      ? 'linear-gradient(90deg,' +
+                        compData.data.buyNowStyle.color1 +
+                        ',' +
+                        compData.data.buyNowStyle.color2 +
+                        ')'
+                      : compData.data.buyNowStyle.src
+                      ? 'url(' + checkUrl(compData.data.buyNowStyle.src) + ')'
+                      : compData.data.buyNowStyle.src,
+                }"
+              >
+                {{ compData.data.buyNowStyle.mode == 1 ? compData.data.buyNowStyle.text : '' }}
+              </div>
+            </div>
+          </div>
+        </div>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue';
+import { checkUrl } from '@/sheep/utils/checkUrlSuffix';
+
+const props = defineProps(['compData']);
+
+const wrapStyle = computed(() => ({
+  display: 'flex',
+  'flex-wrap': 'wrap',
+  margin: `-${props.compData.data.space / 2}px`,
+}));
+
+const itemStyle = computed(() => ({
+  width: `${props.compData.data.mode == 2 ? '50%' : '100%'}`,
+  padding: `${props.compData.data.space / 2}px`,
+  'flex-shrink': 0,
+}));
+
+const itemWrapStyle = computed(() => ({
+  'border-radius': `${props.compData.data.borderRadiusTop}px ${props.compData.data.borderRadiusTop}px ${props.compData.data.borderRadiusBottom}px ${props.compData.data.borderRadiusBottom}px`,
+}));
+</script>
+<style lang="scss" scoped>
+.goods-card {
+  .item-wrap {
+    background: #fff;
+    // box-shadow: 0 5px 21px 0 rgba(234, 234, 234, 0.46);
+    position: relative;
+    overflow: hidden;
+    font-size: 12px;
+    .tag {
+      position: absolute;
+      top: 0;
+      left: 0;
+      z-index: 2;
+      .sa-image {
+        width: 36px !important;
+        height: 22px !important;
+      }
+    }
+    .desc {
+      padding: 10px;
+      flex: 1;
+      .title {
+        font-size: 14px;
+      }
+      .promos {
+        height: fit-content;
+      }
+      .promo-tag {
+        flex-shrink: 0;
+        height: 18px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        border-radius: 4px;
+        border: 1px solid #f00;
+        padding: 0 4px;
+        font-size: 12px;
+        color: #f00;
+        margin-right: 4px;
+      }
+
+      .promo-tag:last-of-type {
+        margin-right: 0;
+      }
+    }
+    .button {
+      position: absolute;
+      right: 10px;
+      bottom: 10px;
+      height: 24px;
+      line-height: 24px;
+      font-size: 12px;
+      color: #fff;
+      padding: 0 12px;
+      border-radius: 12.5px;
+      background: linear-gradient(90deg, #fe8900, #ff5e00);
+      background-size: 100% 100% !important;
+    }
+  }
+  .goods-card-wrap-1 {
+    .sa-image {
+      width: 100%;
+      height: 140px;
+    }
+  }
+  .goods-card-wrap-2 {
+    .sa-image {
+      width: 100%;
+      height: 140px;
+    }
+  }
+  .goods-card-wrap-3 {
+    .item-wrap {
+      display: flex;
+      height: 140px;
+      .sa-image {
+        width: 140px;
+        height: 140px;
+      }
+      .desc {
+        display: flex;
+        flex-direction: column;
+        justify-content: space-between;
+      }
+    }
+  }
+}
+</style>

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

@@ -0,0 +1,119 @@
+<template>
+  <div class="setting">
+    <template v-if="tabType == 'data'">
+      <dc-goods-select v-model="settingData.data.goodsList" :multiple="true"></dc-goods-select>
+      <div class="card">
+        <div class="title">商品样式</div>
+        <div class="content">
+          <el-form-item label="选择风格">
+            <el-radio-group class="custom-radio-button" v-model="settingData.data.mode">
+              <el-radio-button :label="1">
+                <sa-icon icon="sa-shop-decorate-goodsCard-mode-1" />
+              </el-radio-button>
+              <el-radio-button :label="2">
+                <sa-icon icon="sa-shop-decorate-goodsCard-mode-3" />
+              </el-radio-button>
+              <el-radio-button :label="3">
+                <sa-icon icon="sa-shop-decorate-goodsCard-mode-2" />
+              </el-radio-button>
+            </el-radio-group>
+          </el-form-item>
+          <template v-for="(item, field) in settingData.data.goodsFields" :key="field">
+            <template v-if="fieldLabel[field]">
+              <el-form-item :label="fieldLabel[field]">
+                <dc-color-picker
+                  v-model="item.color"
+                  v-model:show="item.show"
+                  :isShow="true"
+                ></dc-color-picker>
+              </el-form-item>
+            </template>
+          </template>
+        </div>
+      </div>
+      <div class="card">
+        <div class="title">商品角标</div>
+        <div class="content">
+          <el-form-item label="角标选择">
+            <el-radio-group v-model="settingData.data.tagStyle.show">
+              <el-radio :label="0">不显示</el-radio>
+              <el-radio :label="1">显示</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item v-if="settingData.data.tagStyle.show" label="上传图片">
+            <div class="sa-flex">
+              <sa-uploader v-model="settingData.data.tagStyle.src" fileType="image"></sa-uploader>
+              <div class="tip">建议尺寸:36*22</div>
+            </div>
+          </el-form-item>
+        </div>
+      </div>
+      <div class="card">
+        <div class="title">加购设置</div>
+        <div class="content">
+          <el-form-item label="加购按钮">
+            <el-radio-group v-model="settingData.data.buyNowStyle.mode">
+              <el-radio :label="1">文字</el-radio>
+              <el-radio :label="2">图片</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <template v-if="settingData.data.buyNowStyle.mode == 1">
+            <el-form-item label="文字">
+              <el-input v-model="settingData.data.buyNowStyle.text"></el-input>
+            </el-form-item>
+            <el-form-item label="背景1">
+              <dc-color-picker v-model="settingData.data.buyNowStyle.color1"></dc-color-picker>
+            </el-form-item>
+            <el-form-item label="背景2">
+              <dc-color-picker v-model="settingData.data.buyNowStyle.color2"></dc-color-picker>
+            </el-form-item>
+          </template>
+          <el-form-item v-if="settingData.data.buyNowStyle.mode == 2" label="图片">
+            <div class="sa-flex"
+              ><sa-uploader
+                v-model="settingData.data.buyNowStyle.src"
+                fileType="image"
+              ></sa-uploader>
+              <div class="tip">建议尺寸:56*56</div>
+            </div>
+          </el-form-item>
+        </div>
+      </div>
+      <div class="card">
+        <div class="title">样式</div>
+        <div class="content">
+          <el-form-item label="上圆角">
+            <dc-slider v-model="settingData.data.borderRadiusTop"></dc-slider>
+          </el-form-item>
+          <el-form-item label="下圆角">
+            <dc-slider v-model="settingData.data.borderRadiusBottom"></dc-slider>
+          </el-form-item>
+          <el-form-item label="间距">
+            <dc-slider v-model="settingData.data.space"></dc-slider>
+          </el-form-item>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup>
+  import { computed } from 'vue';
+  import dcColorPicker from '../../common/dc-color-picker.vue';
+  import dcGoodsSelect from '../../common/dc-goods-select.vue';
+  import dcList from '../../common/dc-list.vue';
+  import dcSlider from '../../common/dc-slider.vue';
+
+  const props = defineProps(['settingData', 'tabType']);
+
+  const fieldLabel = computed(() => {
+    return {
+      title: '商品标题',
+      subtitle: '副标题',
+      price: '商品价格',
+      original_price: '原价',
+      sales: '销量',
+      stock: '库存',
+    };
+  });
+</script>

+ 118 - 0
src/app/shop/admin/decorate/page/component/center/comp/goodsShelves/index.vue

@@ -0,0 +1,118 @@
+<template>
+  <div class="goods-sheleves">
+    <div :class="[`goods-wrap-${compData.data.mode}`]" :style="wrapStyle()">
+      <template v-for="good in compData.data.goodsList" :key="good">
+        <div class="item" :style="itemStyle()">
+          <div class="item-wrap">
+            <div class="tag" v-if="compData.data.tagStyle.show">
+              <sa-image :url="compData.data.tagStyle.src" radius="0" :suffix="null"></sa-image>
+            </div>
+            <sa-image :url="good.image" size="64" :suffix="null"></sa-image>
+            <div class="desc">
+              <div
+                v-if="compData.data.goodsFields.title.show"
+                class="title sa-m-b-8 sa-table-line-1"
+                :style="{
+                  color: compData.data.goodsFields.title.color,
+                }"
+              >
+                {{ good.title }}
+              </div>
+              <div
+                v-if="compData.data.goodsFields.price.show"
+                class="price"
+                :style="{
+                  color: compData.data.goodsFields.price.color,
+                }"
+              >
+                ¥{{ good.price[0] }}
+              </div>
+            </div>
+          </div>
+        </div>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  const props = defineProps(['compData']);
+
+  const w = {
+    1: '50%',
+    2: '33.3%',
+    3: '32%',
+  };
+
+  function wrapStyle() {
+    return {
+      display: 'flex',
+      'flex-wrap': `${props.compData.data.mode == 3 ? 'nowrap' : 'wrap'}`,
+      margin: `-${props.compData.data.space / 2}px`,
+    };
+  }
+
+  function itemStyle() {
+    return {
+      width: `${w[props.compData.data.mode]}`,
+      padding: `${props.compData.data.space / 2}px`,
+      'flex-shrink': 0,
+      'border-top-left-radius': `${props.compData.data.borderRadiusTop}px`,
+      'border-top-right-radius': `${props.compData.data.borderRadiusTop}px`,
+      'border-bottom-left-radius': `${props.compData.data.borderRadiusBottom}px`,
+      'border-bottom-right-radius': `${props.compData.data.borderRadiusBottom}px`,
+      overflow: 'hidden',
+    };
+  }
+</script>
+
+<style lang="scss" scoped>
+  .goods-sheleves {
+    .goods-wrap-1,
+    .goods-wrap-2,
+    .goods-wrap-3 {
+      .item-wrap {
+        background: #fff;
+        // box-shadow: 0 5px 21px 0 rgba(234, 234, 234, 0.46);
+        border-radius: 4px;
+        position: relative;
+        .tag {
+          position: absolute;
+          top: 0;
+          left: 0;
+          z-index: 2;
+          .sa-image {
+            width: 36px !important;
+            height: 22px !important;
+          }
+        }
+        .sa-image {
+          width: 100% !important;
+          height: 110px !important;
+        }
+        .desc {
+          padding: 10px;
+          flex: 1;
+          .name {
+            font-size: 13px;
+          }
+          .price {
+            font-size: 12px;
+          }
+        }
+      }
+    }
+
+    .goods-wrap-1 {
+      .item-wrap {
+        display: flex;
+        height: 64px;
+        font-size: 12px;
+        .sa-image {
+          width: 64px !important;
+          height: 100% !important;
+        }
+      }
+    }
+  }
+</style>

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

@@ -0,0 +1,83 @@
+<template>
+  <div class="setting">
+    <template v-if="tabType == 'data'">
+      <dc-goods-select v-model="settingData.data.goodsList" :multiple="true"></dc-goods-select>
+      <div class="card">
+        <div class="title">商品样式</div>
+        <div class="content">
+          <el-form-item label="选择风格">
+            <el-radio-group class="custom-radio-button" v-model="settingData.data.mode">
+              <el-radio-button :label="1">
+                <sa-icon icon="sa-shop-decorate-goodsShelves-mode-1" />
+              </el-radio-button>
+              <el-radio-button :label="2">
+                <sa-icon icon="sa-shop-decorate-goodsShelves-mode-2" />
+              </el-radio-button>
+              <el-radio-button :label="3">
+                <sa-icon icon="sa-shop-decorate-goodsShelves-mode-3" />
+              </el-radio-button>
+            </el-radio-group>
+          </el-form-item>
+          <template v-for="(item, field) in settingData.data.goodsFields" :key="field">
+            <template v-if="fieldLabel[field]">
+              <el-form-item :label="fieldLabel[field]">
+                <dc-color-picker
+                  v-model="item.color"
+                  v-model:show="item.show"
+                  :isShow="true"
+                ></dc-color-picker>
+              </el-form-item>
+            </template>
+          </template>
+        </div>
+      </div>
+      <div class="card">
+        <div class="title">商品角标</div>
+        <div class="content">
+          <el-form-item label="角标选择">
+            <el-radio-group v-model="settingData.data.tagStyle.show">
+              <el-radio :label="0">不显示</el-radio>
+              <el-radio :label="1">显示</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item v-if="settingData.data.tagStyle.show" label="上传图片">
+            <div class="sa-flex">
+              <sa-uploader v-model="settingData.data.tagStyle.src" fileType="image"></sa-uploader>
+              <div class="tip">建议尺寸:36*22</div>
+            </div>
+          </el-form-item>
+        </div>
+      </div>
+      <div class="card">
+        <div class="title">样式</div>
+        <div class="content">
+          <el-form-item label="上圆角">
+            <dc-slider v-model="settingData.data.borderRadiusTop"></dc-slider>
+          </el-form-item>
+          <el-form-item label="下圆角">
+            <dc-slider v-model="settingData.data.borderRadiusBottom"></dc-slider>
+          </el-form-item>
+          <el-form-item label="间距">
+            <dc-slider v-model="settingData.data.space"></dc-slider>
+          </el-form-item>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup>
+  import { computed } from 'vue';
+  import dcColorPicker from '../../common/dc-color-picker.vue';
+  import dcGoodsSelect from '../../common/dc-goods-select.vue';
+  import dcSlider from '../../common/dc-slider.vue';
+
+  const props = defineProps(['settingData', 'tabType']);
+
+  const fieldLabel = computed(() => {
+    return {
+      title: '商品标题',
+      price: '商品价格',
+    };
+  });
+</script>

+ 197 - 0
src/app/shop/admin/decorate/page/component/center/comp/groupon/index.vue

@@ -0,0 +1,197 @@
+<template>
+  <div class="groupon">
+    <div :class="[`goods-wrap-${compData.data.mode}`]" :style="wrapStyle">
+      <template v-for="goods in compData.data.goodsList" :key="goods">
+        <div class="item" :style="itemStyle">
+          <div class="item-wrap" :style="itemWrapStyle">
+            <sa-image :url="goods.image" radius="0" :suffix="null"></sa-image>
+            <div v-if="compData.data.tagStyle.show" class="tag">
+              <sa-image :url="compData.data.tagStyle.src" radius="0" :suffix="null"></sa-image>
+            </div>
+            <div v-if="compData.data.mode == 1" class="desc">
+              <div
+                v-if="compData.data.goodsFields.title.show"
+                class="title sa-table-line-1 sa-m-b-6"
+                :style="{
+                  color: compData.data.goodsFields.title.color,
+                }"
+              >
+                {{ goods.title }}
+              </div>
+              <div
+                v-if="compData.data.goodsFields.price.show"
+                class="price"
+                :style="{
+                  color: compData.data.goodsFields.price.color,
+                }"
+              >
+                ¥{{ goods.price[0] }}
+              </div>
+            </div>
+            <div v-if="compData.data.mode == 2" class="desc">
+              <div>
+                <div
+                  v-if="compData.data.goodsFields.title.show"
+                  class="title sa-table-line-2 sa-m-b-4"
+                  :style="{
+                    color: compData.data.goodsFields.title.color,
+                  }"
+                >
+                  {{ goods.title }}
+                </div>
+                <div
+                  v-if="compData.data.goodsFields.subtitle.show"
+                  class="subtitle sa-table-line-1"
+                  :style="{
+                    color: compData.data.goodsFields.subtitle.color,
+                  }"
+                >
+                  {{ goods.subtitle }}
+                </div>
+              </div>
+              <div>
+                <div class="price sa-m-b-12">
+                  <span
+                    v-if="compData.data.goodsFields.price.show"
+                    :style="{
+                      color: compData.data.goodsFields.price.color,
+                    }"
+                    >¥{{ goods.price[0] }}</span
+                  >
+                  <s
+                    v-if="compData.data.goodsFields.original_price.show"
+                    class="original-price sa-m-l-4"
+                    :style="{
+                      color: compData.data.goodsFields.original_price.color,
+                    }"
+                  >
+                    ¥{{ goods.original_price }}
+                  </s>
+                </div>
+                <div class="sa-flex">
+                  <div
+                    v-if="compData.data.goodsFields.sales.show"
+                    class="sales"
+                    :style="{ color: compData.data.goodsFields.sales.color }"
+                  >
+                    已售{{ goods.sales }}|</div
+                  >
+                  <div class="stock">库存{{ goods.stock }}</div>
+                </div>
+              </div>
+              <div
+                class="button"
+                :style="{
+                  background:
+                    compData.data.buyNowStyle.mode == 1
+                      ? 'linear-gradient(90deg,' +
+                        compData.data.buyNowStyle.color1 +
+                        ',' +
+                        compData.data.buyNowStyle.color2 +
+                        ')'
+                      : compData.data.buyNowStyle.src
+                      ? 'url(' + checkUrl(compData.data.buyNowStyle.src) + ')'
+                      : compData.data.buyNowStyle.src,
+                }"
+              >
+                {{ compData.data.buyNowStyle.mode == 1 ? compData.data.buyNowStyle.text : '' }}
+              </div>
+            </div>
+          </div>
+        </div>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue';
+import { checkUrl } from '@/sheep/utils/checkUrlSuffix';
+
+const props = defineProps(['compData']);
+
+const wrapStyle = computed(() => ({
+  display: 'flex',
+  'flex-wrap': 'wrap',
+  margin: `-${props.compData.data.space / 2}px`,
+}));
+
+const itemStyle = computed(() => ({
+  width: `${props.compData.data.mode == 1 ? '33.3%' : '100%'}`,
+  padding: `${props.compData.data.space / 2}px`,
+  'flex-shrink': 0,
+}));
+
+const itemWrapStyle = computed(() => ({
+  'border-radius': `${props.compData.data.borderRadiusTop}px ${props.compData.data.borderRadiusTop}px ${props.compData.data.borderRadiusBottom}px ${props.compData.data.borderRadiusBottom}px`,
+}));
+</script>
+
+<style lang="scss" scoped>
+.groupon {
+  .item-wrap {
+    background: #fff;
+    // box-shadow: 0 5px 21px 0 rgba(234, 234, 234, 0.46);
+    position: relative;
+    overflow: hidden;
+    font-size: 12px;
+    .tag {
+      position: absolute;
+      top: 0;
+      left: 0;
+      z-index: 2;
+      .sa-image {
+        width: 36px !important;
+        height: 22px !important;
+      }
+    }
+    .desc {
+      padding: 10px;
+      flex: 1;
+      .title {
+        font-size: 14px;
+      }
+      .price {
+        font-size: 14px;
+      }
+      .stock {
+        color: #c4c4c4;
+      }
+    }
+    .button {
+      position: absolute;
+      right: 10px;
+      bottom: 10px;
+      height: 24px;
+      line-height: 24px;
+      font-size: 12px;
+      color: #fff;
+      padding: 0 12px;
+      border-radius: 12.5px;
+      background: linear-gradient(90deg, #fe8900, #ff5e00);
+      background-size: 100% 100% !important;
+    }
+  }
+  .goods-wrap-1 {
+    .sa-image {
+      width: 100%;
+      height: 110px;
+    }
+  }
+  .goods-wrap-2 {
+    .item-wrap {
+      display: flex;
+      height: 130px;
+      .sa-image {
+        width: 130px;
+        height: 100%;
+      }
+      .desc {
+        display: flex;
+        flex-direction: column;
+        justify-content: space-between;
+      }
+    }
+  }
+}
+</style>

+ 151 - 0
src/app/shop/admin/decorate/page/component/center/comp/groupon/setting.vue

@@ -0,0 +1,151 @@
+<template>
+  <div class="groupon-setting setting">
+    <template v-if="tabType == 'data'">
+      <dc-list v-model="settingData.data.activityList" :leng="1">
+        <template #title>选择活动</template>
+        <template #deleteIcon>
+          <span class="list-delete" @click="deleteItem()">删除</span>
+        </template>
+        <template #listItem="{ element }">
+          <el-form-item label-width="28px">
+            <div class="name sa-m-b-4">
+              {{ element.title }}
+            </div>
+            <div class="amount-text"> {{ settingData.data?.goodsList.length }} 件商品 </div>
+          </el-form-item>
+        </template>
+        <template #add>
+          <el-button class="add-button" icon="Plus" @click="onAdd">添加</el-button>
+        </template>
+      </dc-list>
+      <div class="card">
+        <div class="title">商品样式</div>
+        <div class="content">
+          <el-form-item label="选择风格">
+            <el-radio-group class="custom-radio-button" v-model="settingData.data.mode">
+              <el-radio-button :label="1">
+                <sa-icon icon="sa-shop-decorate-mode-1" />
+              </el-radio-button>
+              <el-radio-button :label="2">
+                <sa-icon icon="sa-shop-decorate-mode-2" />
+              </el-radio-button>
+            </el-radio-group>
+          </el-form-item>
+          <template v-for="(item, field) in settingData.data.goodsFields" :key="field">
+            <template v-if="fieldLabel[field]">
+              <el-form-item :label="fieldLabel[field]">
+                <dc-color-picker v-model="item.color" v-model:show="item.show" :isShow="true"></dc-color-picker>
+              </el-form-item>
+            </template>
+          </template>
+        </div>
+      </div>
+      <template v-if="settingData.data.mode == 2">
+        <div class="card">
+          <div class="title">加购设置</div>
+          <div class="content">
+            <el-form-item label="加购按钮">
+              <el-radio-group v-model="settingData.data.buyNowStyle.mode">
+                <el-radio :label="1">文字</el-radio>
+                <el-radio :label="2">图片</el-radio>
+              </el-radio-group>
+            </el-form-item>
+            <template v-if="settingData.data.buyNowStyle.mode == 1">
+              <el-form-item label="文字">
+                <el-input v-model="settingData.data.buyNowStyle.text"></el-input>
+              </el-form-item>
+              <el-form-item label="背景1">
+                <dc-color-picker v-model="settingData.data.buyNowStyle.color1"></dc-color-picker>
+              </el-form-item>
+              <el-form-item label="背景2">
+                <dc-color-picker v-model="settingData.data.buyNowStyle.color2"></dc-color-picker>
+              </el-form-item>
+            </template>
+            <el-form-item v-if="settingData.data.buyNowStyle.mode == 2" label="图片">
+              <sa-uploader v-model="settingData.data.buyNowStyle.src" fileType="image"></sa-uploader>
+            </el-form-item>
+          </div>
+        </div>
+      </template>
+      <div class="card">
+        <div class="title">商品角标</div>
+        <div class="content">
+          <el-form-item label="角标选择">
+            <el-radio-group v-model="settingData.data.tagStyle.show">
+              <el-radio :label="0">不显示</el-radio>
+              <el-radio :label="1">显示</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item v-if="settingData.data.tagStyle.show == 1" label="上传图片">
+            <div class="sa-flex">
+              <sa-uploader v-model="settingData.data.tagStyle.src" fileType="image"></sa-uploader>
+              <span class="tip">建议尺寸:36*22</span>
+            </div>
+          </el-form-item>
+        </div>
+      </div>
+      <div class="card">
+        <div class="title">样式</div>
+        <div class="content">
+          <el-form-item label="上圆角">
+            <dc-slider v-model="settingData.data.borderRadiusTop"></dc-slider>
+          </el-form-item>
+          <el-form-item label="下圆角">
+            <dc-slider v-model="settingData.data.borderRadiusBottom"></dc-slider>
+          </el-form-item>
+          <el-form-item label="间距">
+            <dc-slider v-model="settingData.data.space"></dc-slider>
+          </el-form-item>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue';
+import { api as goodsApi } from '@/app/shop/admin/goods/goods.service';
+import { useModal } from '@/sheep/hooks';
+import dcColorPicker from '../../common/dc-color-picker.vue';
+import dcList from '../../common/dc-list.vue';
+import dcSlider from '../../common/dc-slider.vue';
+
+const props = defineProps(['settingData', 'tabType']);
+
+const fieldLabel = computed(() =>
+  props.settingData.data.mode == 1
+    ? {
+      title: '标题',
+      price: '价格',
+    }
+    : {
+      title: '商品标题',
+      subtitle: '副标题',
+      price: '商品价格',
+      original_price: '原价',
+      sales: '销量',
+    },
+);
+
+function deleteItem() {
+  props.settingData.data.activityList = [];
+  props.settingData.data.goodsList = [];
+}
+</script>
+<style lang="scss" scoped>
+.groupon-setting {
+  .name {
+    line-height: 16px;
+    font-size: 12px;
+    font-weight: 400;
+    color: var(--sa-title);
+  }
+
+  .amount-text {
+    line-height: 16px;
+    font-size: 12px;
+    font-weight: 500;
+    color: var(--sa-subfont);
+  }
+}
+</style>

+ 246 - 0
src/app/shop/admin/decorate/page/component/center/comp/hotzone/edit.vue

@@ -0,0 +1,246 @@
+<template>
+  <el-container class="hotzone">
+    <el-main class="sa-flex sa-row-center">
+      <div ref="mapRef" class="mapContent" data-type="content">
+        <img ref="srcRef" :src="checkUrl(modal.params.data.src)" />
+        <!-- <sa-image
+          :url="modal.params.data.src"
+          fit="fill"
+          radius="0"
+          :suffix="null"
+        ></sa-image> -->
+        <template v-for="(item, index) in state.mapList">
+          <div
+            v-if="!item.show"
+            class="map-item sa-flex sa-row-center"
+            :style="{
+              width: `${item.width}px`,
+              height: `${item.height}px`,
+              top: `${item.top}px`,
+              left: `${item.left}px`,
+            }"
+            data-type="item"
+            @mousedown="mousedown($event, index)"
+            @mouseup="mouseup($event, index)"
+            @click.self="onSelect(index)"
+            @dblclick="onLink(index)"
+          >
+            {{ item.name }}
+            <div class="delete" @click.stop="onDelete(index)">
+              <el-icon><Close /></el-icon>
+            </div>
+            <div class="coor" data-type="scale"></div>
+          </div>
+        </template>
+      </div>
+    </el-main>
+    <el-footer class="sa-footer--submit">
+      <el-button @click="onAdd">添加热区</el-button>
+      <el-button @click="onSave">保存</el-button>
+    </el-footer>
+  </el-container>
+</template>
+<script setup>
+  import { onMounted, reactive, ref } from 'vue';
+  import { useModal } from '@/sheep/hooks';
+  import PageSelect from '@/app/shop/admin/data/page/select.vue';
+  import { ElMessage } from 'element-plus';
+  import { checkUrl } from '@/sheep/utils/checkUrlSuffix';
+
+  const props = defineProps(['modal']);
+  const emit = defineEmits(['modalCallBack']);
+
+  // 内容部分宽高
+  let offsetWidth;
+  let offsetHeight;
+  let dragFlag = false;
+
+  const state = reactive({
+    mapList: [],
+    currentIndex: null,
+  });
+
+  const srcRef = ref();
+
+  function onAdd() {
+    state.mapList.push({
+      width: 200,
+      height: 200,
+      top: 0,
+      left: 0,
+      name: '双击选择链接',
+      url: '',
+    });
+    state.currentIndex = state.mapList.length - 1;
+  }
+
+  function onDelete(index) {
+    state.mapList[index].show = true;
+  }
+
+  function onSelect(index) {
+    state.currentIndex = index;
+  }
+
+  function onLink(index) {
+    useModal(
+      PageSelect,
+      { title: '选择链接', from: 'shop' },
+      {
+        confirm: (res) => {
+          state.mapList[index].name = res.data.name;
+          state.mapList[index].url = res.data.path;
+        },
+      },
+    );
+  }
+
+  function mousedown(event, index) {
+    offsetWidth = srcRef.value.width || 750;
+    offsetHeight = srcRef.value.height;
+
+    dragFlag = true;
+    state.currentIndex = index;
+
+    event = event || window.event;
+    var _target = event.target;
+    var x = event.clientX - _target.offsetLeft;
+    var y = event.clientY - _target.offsetTop;
+
+    if (event.preventDefault) {
+      event.preventDefault();
+    } else {
+      event.returnValue = false;
+    }
+    document.onmousemove = (event) => {
+      event = event || window.event;
+      if (event.target.dataset.type) {
+        if (dragFlag) {
+          var left = event.clientX - x;
+          var top = event.clientY - y;
+
+          if (_target.dataset.type === 'item') {
+            if (left <= 0) {
+              left = 0;
+            } else if (left > offsetWidth - _target.offsetWidth) {
+              left = offsetWidth - _target.offsetWidth;
+            }
+            if (top <= 0) {
+              top = 0;
+            } else if (top > offsetHeight - _target.offsetHeight) {
+              top = offsetHeight - _target.offsetHeight;
+            }
+
+            state.mapList[state.currentIndex].left = left;
+            state.mapList[state.currentIndex].top = top;
+          }
+
+          _target.style.left = left + 'px';
+          _target.style.top = top + 'px';
+
+          if (_target.dataset.type === 'scale') {
+            let width = _target.offsetLeft + _target.clientWidth;
+            let height = _target.offsetTop + _target.clientHeight;
+
+            if (width + state.mapList[state.currentIndex].left > offsetWidth) {
+              width = offsetWidth - state.mapList[state.currentIndex].left;
+            }
+            if (height + state.mapList[state.currentIndex].top > offsetHeight) {
+              height = offsetHeight - state.mapList[state.currentIndex].top;
+            }
+
+            state.mapList[state.currentIndex].width = width;
+            state.mapList[state.currentIndex].height = height;
+
+            _target.style.left = width - _target.offsetWidth + 'px';
+            _target.style.top = height - _target.offsetHeight + 'px';
+          }
+        }
+      } else {
+        dragFlag = false;
+      }
+    };
+  }
+  function mouseup(e) {
+    document.onmousemove = null;
+    dragFlag = false;
+  }
+
+  function onSave() {
+    let arr = state.mapList.filter((item) => !item.show);
+    let flag = false;
+    arr.forEach((item) => {
+      if (!item.url) {
+        flag = true;
+      }
+    });
+    if (flag) {
+      ElMessage({
+        message: '请选择链接',
+        type: 'warning',
+      });
+      return false;
+    }
+    emit('modalCallBack', {
+      event: 'confirm',
+      data: arr,
+    });
+  }
+
+  onMounted(() => {
+    state.mapList = JSON.parse(JSON.stringify(props.modal.params.data.list));
+  });
+</script>
+
+<style lang="scss" scoped>
+  .hotzone {
+    .mapContent {
+      width: 750px;
+      position: relative;
+      // border: 1px solid var(--sa-border);
+      img {
+        width: 750px !important;
+        pointer-events: none;
+      }
+    }
+    .map-item {
+      position: absolute;
+      border: 1px solid var(--el-color-primary);
+      cursor: move;
+      background: var(--t-bg-active);
+      opacity: 0.8;
+      color: var(--el-color-primary);
+      font-size: 12px;
+      .delete {
+        display: none;
+      }
+      .coor {
+        position: absolute;
+        right: 0;
+        bottom: 0;
+        z-index: 11;
+        width: 10px;
+        height: 10px;
+        background: var(--el-color-primary);
+        cursor: se-resize;
+      }
+
+      &:hover {
+        .delete {
+          display: block;
+          position: absolute;
+          top: -1px;
+          right: -1px;
+          width: 16px;
+          height: 16px;
+          background: var(--el-color-primary);
+          border-radius: 0 0 0 100%;
+          cursor: pointer;
+          font-size: 12px;
+          color: #ffffff;
+          text-align: right;
+        }
+      }
+    }
+  }
+</style>

+ 49 - 0
src/app/shop/admin/decorate/page/component/center/comp/hotzone/index.vue

@@ -0,0 +1,49 @@
+<template>
+  <div
+    class="hotzone"
+    :style="{
+      height: compData.style.height + 'px',
+      'border-top-left-radius': compData.data.borderRadiusTop + 'px',
+      'border-top-right-radius': compData.data.borderRadiusTop + 'px',
+      'border-bottom-left-radius': compData.data.borderRadiusBottom + 'px',
+      'border-bottom-right-radius': compData.data.borderRadiusBottom + 'px',
+    }"
+  >
+    <sa-image :url="compData.data.src" fit="fill" radius="0" :suffix="null"></sa-image>
+    <div
+      class="map-item sa-flex sa-row-center"
+      v-for="item in compData.data.list"
+      :style="{
+        width: `${item.width / 2}px`,
+        height: `${item.height / 2}px`,
+        top: `${item.top / 2}px`,
+        left: `${item.left / 2}px`,
+      }"
+    >
+      {{ item.name }}
+    </div>
+  </div>
+</template>
+
+<script setup>
+  const props = defineProps(['compData']);
+</script>
+
+<style lang="scss" scoped>
+  .hotzone {
+    overflow: hidden;
+    position: reactive;
+    .sa-image {
+      width: 100%;
+      min-height: 30px;
+    }
+    .map-item {
+      position: absolute;
+      border: 1px solid var(--el-color-primary);
+      background: var(--t-bg-active);
+      opacity: 0.8;
+      color: var(--el-color-primary);
+      font-size: 12px;
+    }
+  }
+</style>

+ 51 - 0
src/app/shop/admin/decorate/page/component/center/comp/hotzone/setting.vue

@@ -0,0 +1,51 @@
+<template>
+  <div class="setting">
+    <template v-if="tabType == 'data'">
+      <div class="card">
+        <div class="title">添加图片</div>
+        <div class="content">
+          <el-form-item label="上传图片">
+            <div class="sa-flex">
+              <sa-uploader v-model="settingData.data.src" fileType="image"></sa-uploader>
+              <div class="tip">建议宽度:750</div>
+            </div>
+          </el-form-item>
+          <el-button
+            v-if="settingData.data.src"
+            class="add-button"
+            icon="Plus"
+            @click="onSetHotzone"
+            >设置热区</el-button
+          >
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup>
+  import { watch } from 'vue';
+  import { useModal } from '@/sheep/hooks';
+  import Edit from './edit.vue';
+
+  const props = defineProps(['settingData', 'tabType']);
+
+  watch(
+    () => props.settingData.data.src,
+    () => {
+      props.settingData.data.list = [];
+    },
+  );
+
+  function onSetHotzone() {
+    useModal(
+      Edit,
+      { title: '设置热区', data: props.settingData.data },
+      {
+        confirm: (res) => {
+          props.settingData.data.list = res.data;
+        },
+      },
+    );
+  }
+</script>

+ 102 - 0
src/app/shop/admin/decorate/page/component/center/comp/imageBanner/index.vue

@@ -0,0 +1,102 @@
+<template>
+  <div class="image-banner">
+    <div class="image-banner-wrap" :style="wrapStyle()">
+      <div class="banner-item" :style="itemStyle()">
+        <template v-if="compData.data.mode == 1 || compData.data.mode == 2">
+          <sa-image
+            v-if="compData.data.list.length > 0"
+            :url="compData.data.list[0].src"
+            fit="fill"
+            radius="0"
+            :suffix="null"
+            :style="image1Style()"
+          ></sa-image>
+        </template>
+        <template v-if="compData.data.mode == 2">
+          <sa-image
+            class="banner-right"
+            v-if="compData.data.list.length > 1"
+            :url="compData.data.list[1].src"
+            fit="fill"
+            radius="0"
+            :suffix="null"
+            :style="{
+              height: compData.style.height - 100 + 'px',
+              'margin-top': '-' + (compData.style.height - 100) / 2 + 'px',
+              'border-radius': 0,
+              'border-top-left-radius': compData.data.borderRadiusTop + 'px',
+              'border-top-right-radius': compData.data.borderRadiusTop + 'px',
+              'border-bottom-left-radius': compData.data.borderRadiusBottom + 'px',
+              'border-bottom-right-radius': compData.data.borderRadiusBottom + 'px',
+            }"
+          ></sa-image>
+        </template>
+        <div class="indicator">
+          <sa-icon
+            :icon="'sa-shop-decorate-imageBanner-indicator-' + compData.data.indicator"
+            size="30"
+          />
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  const props = defineProps(['compData']);
+
+  function wrapStyle() {
+    return {
+      margin: `-${props.compData.data.space / 2}px`,
+    };
+  }
+
+  function itemStyle() {
+    return {
+      padding: `${props.compData.data.space / 2}px`,
+      height: `${props.compData.style.height - props.compData.style.padding}px`,
+    };
+  }
+
+  function image1Style() {
+    return {
+      'border-radius': 0,
+      'border-top-left-radius': props.compData.data.borderRadiusTop + 'px',
+      'border-top-right-radius': props.compData.data.borderRadiusTop + 'px',
+      'border-bottom-left-radius': props.compData.data.borderRadiusBottom + 'px',
+      'border-bottom-right-radius': props.compData.data.borderRadiusBottom + 'px',
+    };
+  }
+</script>
+
+<style lang="scss" scoped>
+  .image-banner {
+    height: fit-content !important;
+    .banner-item {
+      position: relative;
+      overflow: hidden;
+    }
+    .sa-image {
+      border-radius: 0;
+      height: 100%;
+    }
+    .indicator {
+      position: absolute;
+      bottom: 0;
+      left: 50%;
+      margin-left: -15px;
+      :deep() {
+        .sa-icon {
+          color: #fff;
+        }
+      }
+    }
+    .banner-right {
+      width: 375px;
+      height: 100px;
+      position: absolute;
+      top: 50%;
+      right: -364px;
+    }
+  }
+</style>

+ 76 - 0
src/app/shop/admin/decorate/page/component/center/comp/imageBanner/setting.vue

@@ -0,0 +1,76 @@
+<template>
+  <div class="setting">
+    <template v-if="tabType == 'data'">
+      <div class="card">
+        <div class="title">样式设置</div>
+        <div class="content">
+          <!-- <el-form-item label="选择样式">
+            <el-radio-group class="custom-radio-button" v-model="settingData.data.mode">
+              <el-radio-button :label="1">
+                <sa-icon icon="sa-shop-decorate-imageBanner-mode-1" />
+              </el-radio-button>
+              <el-radio-button :label="2">
+                <sa-icon icon="sa-shop-decorate-imageBanner-mode-2" />
+              </el-radio-button>
+            </el-radio-group>
+          </el-form-item> -->
+          <el-form-item label="Dot样式">
+            <el-radio-group class="custom-radio-button" v-model="settingData.data.indicator">
+              <el-radio-button :label="1">
+                <sa-icon icon="sa-shop-decorate-imageBanner-indicator-1" />
+              </el-radio-button>
+              <el-radio-button :label="2">
+                <sa-icon icon="sa-shop-decorate-imageBanner-indicator-2" />
+              </el-radio-button>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item label="间距">
+            <dc-slider v-model="settingData.data.space"></dc-slider>
+          </el-form-item>
+          <el-form-item label="是否轮播">
+            <el-switch v-model="settingData.data.autoplay" />
+          </el-form-item>
+          <el-form-item v-if="settingData.data.autoplay" label="时间间隔">
+            <el-input v-model="settingData.data.interval">
+              <template #append>ms</template>
+            </el-input>
+          </el-form-item>
+        </div>
+      </div>
+      <dc-list
+        v-model="settingData.data.list"
+        :itemProp="{ title: '', type: 'image', src: '', poster: '', url: '' }"
+      >
+        <template #title>图片上传</template>
+        <template #listItem="{ element }">
+          <el-form-item label="标题">
+            <el-input v-model="element.title" placeholder="请输入标题"></el-input>
+          </el-form-item>
+          <el-form-item label="选择类型">
+            <el-radio-group v-model="element.type">
+              <el-radio label="image">图片</el-radio>
+              <el-radio label="video">视频</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item label="上传">
+            <sa-uploader v-model="element.src" :fileType="element.type"></sa-uploader>
+          </el-form-item>
+          <el-form-item v-if="element.type == 'video'" label="视频封面">
+            <sa-uploader v-model="element.poster" fileType="image"></sa-uploader>
+          </el-form-item>
+          <el-form-item label="链接">
+            <dc-url v-model="element.url"></dc-url>
+          </el-form-item>
+        </template>
+      </dc-list>
+    </template>
+  </div>
+</template>
+
+<script setup>
+  import dcList from '../../common/dc-list.vue';
+  import dcUrl from '../../common/dc-url.vue';
+  import dcSlider from '../../common/dc-slider.vue';
+
+  const props = defineProps(['settingData', 'tabType']);
+</script>

+ 27 - 0
src/app/shop/admin/decorate/page/component/center/comp/imageBlock/index.vue

@@ -0,0 +1,27 @@
+<template>
+  <div
+    class="image-block"
+    :style="{
+      height: compData.style.height + 'px',
+      'border-top-left-radius': compData.data.borderRadiusTop + 'px',
+      'border-top-right-radius': compData.data.borderRadiusTop + 'px',
+      'border-bottom-left-radius': compData.data.borderRadiusBottom + 'px',
+      'border-bottom-right-radius': compData.data.borderRadiusBottom + 'px',
+    }"
+  >
+    <sa-image :url="compData.data.src" fit="fill" radius="0" :suffix="null"></sa-image>
+  </div>
+</template>
+
+<script setup>
+  const props = defineProps(['compData']);
+</script>
+
+<style lang="scss" scoped>
+  .image-block {
+    overflow: hidden;
+    .sa-image {
+      height: 100%;
+    }
+  }
+</style>

+ 26 - 0
src/app/shop/admin/decorate/page/component/center/comp/imageBlock/setting.vue

@@ -0,0 +1,26 @@
+<template>
+  <div class="setting">
+    <template v-if="tabType == 'data'">
+      <div class="card">
+        <div class="title">添加图片</div>
+        <div class="content">
+          <el-form-item label="上传图片">
+            <div class="sa-flex">
+              <sa-uploader v-model="settingData.data.src" fileType="image"></sa-uploader>
+              <div class="tip">建议宽度:750</div>
+            </div>
+          </el-form-item>
+          <el-form-item label="链接">
+            <dc-url v-model="settingData.data.url"></dc-url>
+          </el-form-item>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup>
+  import dcUrl from '../../common/dc-url.vue';
+
+  const props = defineProps(['settingData', 'tabType']);
+</script>

+ 73 - 0
src/app/shop/admin/decorate/page/component/center/comp/imageCube/index.vue

@@ -0,0 +1,73 @@
+<template>
+  <div class="image-cube">
+    <div class="image-cube-wrap" :style="wrapStyle()">
+      <div class="image-cube-item" v-for="l in compData.data.list" :key="l" :style="itemStyle(l)">
+        <sa-image :url="l.src" radius="0" :style="imageStyle()" :suffix="null"></sa-image>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { computed } from 'vue';
+
+  const props = defineProps(['compData']);
+
+  const scale = computed(
+    () =>
+      (375 -
+        props.compData.style.marginLeft -
+        props.compData.style.marginRight -
+        props.compData.style.padding * 2 +
+        props.compData.data.space) /
+      4,
+  );
+
+  function wrapStyle() {
+    let height =
+      props.compData.data.list.length > 0
+        ? props.compData.data.list.reduce((prev, next) => {
+            return prev.top > next.top || (prev.top == next.top && prev.height > next.height)
+              ? prev
+              : next;
+          })
+        : 30;
+    return {
+      margin: `-${props.compData.data.space / 2}px`,
+      height: (height.top + height.height) * scale.value + 'px',
+    };
+  }
+
+  function itemStyle(l) {
+    return {
+      width: l.width * scale.value + 'px',
+      height: l.height * scale.value + 'px',
+      top: l.top * scale.value + 'px',
+      left: l.left * scale.value + 'px',
+      padding: props.compData.data.space / 2 + 'px',
+    };
+  }
+
+  function imageStyle() {
+    return {
+      width: '100%',
+      height: '100%',
+      'border-top-left-radius': props.compData.data.borderRadiusTop + 'px',
+      'border-top-right-radius': props.compData.data.borderRadiusTop + 'px',
+      'border-bottom-left-radius': props.compData.data.borderRadiusBottom + 'px',
+      'border-bottom-right-radius': props.compData.data.borderRadiusBottom + 'px',
+    };
+  }
+</script>
+
+<style lang="scss" scoped>
+  .image-cube {
+    .image-cube-wrap {
+      position: relative;
+      .image-cube-item {
+        position: absolute;
+        overflow: hidden;
+      }
+    }
+  }
+</style>

+ 260 - 0
src/app/shop/admin/decorate/page/component/center/comp/imageCube/setting.vue

@@ -0,0 +1,260 @@
+<template>
+  <div class="image-cube-setting setting">
+    <template v-if="tabType == 'data'">
+      <div class="card">
+        <div class="title">魔方样式 <div class="tip">每格尺寸:187*187</div></div>
+        <div class="content">
+          <table class="image-cube-map">
+            <tbody>
+              <tr v-for="trItem in mapList" :key="trItem">
+                <td
+                  class="map-item"
+                  :class="
+                    activeData.minRow <= trItem &&
+                    trItem <= activeData.maxRow &&
+                    activeData.minCol <= tdItem &&
+                    tdItem <= activeData.maxCol
+                      ? 'map-item--active'
+                      : ''
+                  "
+                  :style="{
+                    width: scale + 'px',
+                    height: scale + 'px',
+                  }"
+                  v-for="tdItem in mapList"
+                  :key="tdItem"
+                  @click.stop="selectItem(trItem, tdItem)"
+                  @mouseover="onMouseover(trItem, tdItem)"
+                >
+                  <el-icon>
+                    <Plus />
+                  </el-icon>
+                </td>
+              </tr>
+              <div
+                class="position-item sa-flex sa-row-center"
+                :class="selected.active == sindex ? 'position-item--active' : ''"
+                v-for="(style, sindex) in selected.data"
+                :style="{
+                  width: style.width * scale + 'px',
+                  height: style.height * scale + 'px',
+                  top: style.top * scale + 'px',
+                  left: style.left * scale + 'px',
+                }"
+                :key="style"
+                @mouseover="onCancelSelect"
+                @click.stop="onPositionSelect(sindex)"
+              >
+                {{ style.width }}*{{ style.height }}
+                <el-icon class="close" @click.stop="deleteItem(sindex)">
+                  <CircleCloseFilled />
+                </el-icon>
+              </div>
+            </tbody>
+          </table>
+          <template v-if="selected.data.length > 0">
+            <el-form-item label="上传图片">
+              <sa-uploader
+                v-model="selected.data[selected.active].src"
+                fileType="image"
+              ></sa-uploader>
+            </el-form-item>
+            <el-form-item label="链接">
+              <dc-url v-model="selected.data[selected.active].url"></dc-url>
+            </el-form-item>
+          </template>
+          <el-form-item label="上圆角">
+            <dc-slider v-model="settingData.data.borderRadiusTop"></dc-slider>
+          </el-form-item>
+          <el-form-item label="下圆角">
+            <dc-slider v-model="settingData.data.borderRadiusBottom"></dc-slider>
+          </el-form-item>
+          <el-form-item label="间距">
+            <dc-slider v-model="settingData.data.space"></dc-slider>
+          </el-form-item>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup>
+  import { reactive, ref, watch } from 'vue';
+  import { cloneDeep } from 'lodash';
+  import dcSlider from '../../common/dc-slider.vue';
+  import dcUrl from '../../common/dc-url.vue';
+
+  const props = defineProps(['settingData', 'tabType']);
+
+  watch(() => props.settingData, () => {
+    selected.data = props.settingData.data.list || [];
+    selected.active = props.settingData.data.list.length > 0 ? 0 : null;
+  }, {
+    deep: true
+  })
+
+  const scale = ref(66);
+  const mapList = [1, 2, 3, 4];
+  const start = reactive({ row: 0, col: 0 });
+  const selectFlag = ref(false);
+
+  const activeData = reactive({
+    width: 0,
+    height: 0,
+    top: 0,
+    left: 0,
+    minRow: 0,
+    maxRow: 0,
+    minCol: 0,
+    maxCol: 0,
+    src: '',
+    url: '',
+  });
+  let selected = reactive({
+    data: props.settingData.data.list || [],
+    active: props.settingData.data.list.length > 0 ? 0 : null,
+  });
+
+  function selectItem(row, col) {
+    // 开始的坐标
+    if (!selectFlag.value) {
+      start.row = row;
+      start.col = col;
+    }
+    // 结束存储数据
+    if (selectFlag.value) {
+      selected.data.push(cloneDeep(activeData));
+      selected.active = selected.data.length - 1;
+      clearActiveData();
+      updatelist();
+    }
+    selectFlag.value = !selectFlag.value;
+    onMouseover(row, col);
+  }
+  function onMouseover(row, col) {
+    if (selectFlag.value) {
+      let squaresArr = [
+        start.row + '*' + start.col,
+        row + '*' + col,
+        start.row + '*' + col,
+        row + '*' + start.col,
+      ];
+      let min = squaresArr.sort()[0].split('*');
+      let max = squaresArr.sort()[3].split('*');
+      // 面积不可重叠
+      const flag = selected.data.some((f) => {
+        return isOverlap(f, {
+          width: Math.abs(start.col - col) + 1,
+          height: Math.abs(start.row - row) + 1,
+          left: min[1] - 1,
+          top: min[0] - 1,
+        });
+      });
+      if (!flag) {
+        // 宽高
+        activeData.width = Math.abs(start.col - col) + 1;
+        activeData.height = Math.abs(start.row - row) + 1;
+        // 定位
+        activeData.left = min[1] - 1;
+        activeData.top = min[0] - 1;
+        // xy轴最大最小值
+        activeData.minRow = min[0];
+        activeData.minCol = min[1];
+        activeData.maxRow = max[0];
+        activeData.maxCol = max[1];
+      }
+    }
+  }
+  // 删除选中的区域
+  function deleteItem(index) {
+    selected.data.splice(index, 1);
+    selected.active = selected.data.length - 1;
+    updatelist();
+  }
+  // 取消选中区域
+  function onCancelSelect() {
+    selectFlag.value = false;
+    clearActiveData();
+  }
+  function onPositionSelect(index) {
+    selected.active = index;
+  }
+  // 提交
+  function updatelist() {
+    let deleteData = ['minRow', 'maxRow', 'minCol', 'maxCol'];
+    let tempData = [];
+    selected.data.forEach((s) => {
+      let obj = s;
+      deleteData.forEach((d) => {
+        delete obj[d];
+      });
+      tempData.push(obj);
+    });
+    props.settingData.data.list = tempData;
+  }
+  // 清除activeData数据
+  function clearActiveData() {
+    for (var a in activeData) {
+      activeData[a] = 0;
+      if (a == 'src' || a == 'url') {
+        activeData[a] = '';
+      }
+    }
+  }
+  // 选择面积不可重叠
+  function isOverlap(obj1, obj2) {
+    const l1 = { x: obj1.left, y: obj1.top };
+    const r1 = { x: obj1.left + obj1.width, y: obj1.top + obj1.height };
+    const l2 = { x: obj2.left, y: obj2.top };
+    const r2 = { x: obj2.left + obj2.width, y: obj2.top + obj2.height };
+    if (l1.x >= r2.x || l2.x >= r1.x || l1.y >= r2.y || l2.y >= r1.y) return false;
+    return true;
+  }
+</script>
+
+<style lang="scss" scoped>
+  .image-cube-setting {
+    .image-cube-map {
+      position: relative;
+      margin: 0 auto 16px;
+      border-spacing: 0;
+      border-collapse: collapse;
+      .map-item {
+        text-align: center;
+        border: 1px solid var(--sa-border);
+        box-sizing: border-box;
+        .el-icon {
+          font-size: 20px;
+          color: var(--sa-place);
+        }
+        &--active {
+          background-color: var(--sa-background-hex-hover);
+        }
+      }
+      .position-item {
+        position: absolute;
+        background: var(--sa-background-hex-active);
+        border: 1px solid var(--el-color-primary);
+        cursor: pointer;
+        .close {
+          display: none;
+          font-size: 12px;
+          color: var(--el-color-primary);
+          background: #fff;
+          border-radius: 6px;
+          position: absolute;
+          top: -6px;
+          right: -6px;
+          z-index: 10;
+        }
+        &--active {
+          background: var(--sa-background-hex-active);
+          border: 1px solid var(--el-color-primary);
+          .close {
+            display: block;
+          }
+        }
+      }
+    }
+  }
+</style>

+ 94 - 0
src/app/shop/admin/decorate/page/component/center/comp/index.vue

@@ -0,0 +1,94 @@
+<template>
+  <component :is="compData.type" class="comp-content" :style="compStyle(compData)" :compData="compData" />
+</template>
+<script>
+import searchBlock from './searchBlock/index.vue';
+import noticeBlock from './noticeBlock/index.vue';
+import menuButton from './menuButton/index.vue';
+import menuList from './menuList/index.vue';
+import menuGrid from './menuGrid/index.vue';
+import goodsCard from './goodsCard/index.vue';
+import goodsShelves from './goodsShelves/index.vue';
+import imageBlock from './imageBlock/index.vue';
+import imageBanner from './imageBanner/index.vue';
+import titleBlock from './titleBlock/index.vue';
+import imageCube from './imageCube/index.vue';
+import videoPlayer from './videoPlayer/index.vue';
+import lineBlock from './lineBlock/index.vue';
+import richtext from './richtext/index.vue';
+import hotzone from './hotzone/index.vue';
+// import subscribeWechatOfficialAccount from "./subscribeWechatOfficialAccount/index.vue";
+
+import userCard from './userCard/index.vue';
+import orderCard from './orderCard/index.vue';
+import walletCard from './walletCard/index.vue';
+import couponCard from './couponCard/index.vue';
+
+export default {
+  components: {
+    searchBlock,
+    noticeBlock,
+    menuButton,
+    menuList,
+    menuGrid,
+    goodsCard,
+    goodsShelves,
+    imageBlock,
+    imageBanner,
+    titleBlock,
+    imageCube,
+    videoPlayer,
+    lineBlock,
+    richtext,
+    hotzone,
+    // subscribeWechatOfficialAccount,
+
+    userCard,
+    orderCard,
+    walletCard,
+    couponCard,
+  },
+};
+</script>
+<script setup>
+import { checkUrl } from '@/sheep/utils/checkUrlSuffix';
+
+const props = defineProps(['compData']);
+
+// 组件样式
+function compStyle(element) {
+  let height = {};
+  let padding = {};
+  if (element.style) {
+    if (element.style.height || element.style.height == 0) {
+      height = {
+        height: `${element.style.height}px`,
+      };
+    }
+    if (element.style.padding || element.style.padding == 0) {
+      padding = {
+        padding: `${element.style.padding}px`,
+      };
+    }
+    return {
+      background:
+        element.style.background &&
+        (element.style.background.type == 'color'
+          ? element.style.background.bgColor
+          : element.style.background.bgImage
+            ? 'url(' + checkUrl(element.style.background.bgImage) + ')'
+            : ''),
+      'margin-top': element.style.marginTop + 'px',
+      'margin-right': element.style.marginRight + 'px',
+      'margin-bottom': element.style.marginBottom + 'px',
+      'margin-left': element.style.marginLeft + 'px',
+      'border-top-left-radius': element.style.borderRadiusTop + 'px',
+      'border-top-right-radius': element.style.borderRadiusTop + 'px',
+      'border-bottom-left-radius': element.style.borderRadiusBottom + 'px',
+      'border-bottom-right-radius': element.style.borderRadiusBottom + 'px',
+      ...height,
+      ...padding,
+    };
+  }
+}
+</script>

+ 24 - 0
src/app/shop/admin/decorate/page/component/center/comp/lineBlock/index.vue

@@ -0,0 +1,24 @@
+<template>
+  <div class="line-block sa-flex">
+    <div
+      class="line"
+      :style="{
+        'border-bottom-style': compData.data.mode,
+        'border-bottom-color': compData.data.lineColor,
+      }"
+    ></div>
+  </div>
+</template>
+
+<script setup>
+  const props = defineProps(['compData']);
+</script>
+
+<style lang="scss" scoped>
+  .line-block {
+    .line {
+      width: 100%;
+      border-bottom-width: 1px;
+    }
+  }
+</style>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません