NobleLeong 4 napja
commit
dc4d633534
100 módosított fájl, 16518 hozzáadás és 0 törlés
  1. 30 0
      .env
  2. 11 0
      .gitignore
  3. 6 0
      .prettierignore
  4. 10 0
      .prettierrc
  5. 38 0
      App.vue
  6. 21 0
      LICENSE
  7. 3 0
      androidPrivacy.json
  8. 11 0
      common/api/index.js
  9. 66 0
      common/api/infra/file.js
  10. 156 0
      common/api/member/auth.js
  11. 120 0
      common/api/member/task.js
  12. 85 0
      common/api/member/user.js
  13. 256 0
      common/components/s-activity-pop/s-activity-pop.vue
  14. 110 0
      common/components/s-address-item/s-address-item.vue
  15. 119 0
      common/components/s-auth-modal/components/account-login.vue
  16. 107 0
      common/components/s-auth-modal/components/account-reg.vue
  17. 127 0
      common/components/s-auth-modal/components/change-mobile.vue
  18. 106 0
      common/components/s-auth-modal/components/change-password.vue
  19. 152 0
      common/components/s-auth-modal/components/mp-authorization.vue
  20. 119 0
      common/components/s-auth-modal/components/reset-password.vue
  21. 138 0
      common/components/s-auth-modal/components/sms-login.vue
  22. 205 0
      common/components/s-auth-modal/index.scss
  23. 260 0
      common/components/s-auth-modal/s-auth-modal.vue
  24. 52 0
      common/components/s-block/s-block.vue
  25. 173 0
      common/components/s-count-down/s-count-down.vue
  26. 102 0
      common/components/s-coupon-card/s-coupon-card.vue
  27. 109 0
      common/components/s-coupon-get/s-coupon-get.vue
  28. 195 0
      common/components/s-coupon-list/s-coupon-list.vue
  29. 149 0
      common/components/s-coupon-select/s-coupon-select.vue
  30. 67 0
      common/components/s-custom-navbar/components/navbar-item.vue
  31. 314 0
      common/components/s-custom-navbar/components/navbar.vue
  32. 207 0
      common/components/s-custom-navbar/s-custom-navbar.vue
  33. 96 0
      common/components/s-discount-list/s-discount-list.vue
  34. 93 0
      common/components/s-empty/s-empty.vue
  35. 88 0
      common/components/s-float-menu/s-float-menu.vue
  36. 1026 0
      common/components/s-goods-column/s-goods-column.vue
  37. 189 0
      common/components/s-goods-item/s-goods-item.vue
  38. 33 0
      common/components/s-goods-scroll/s-goods-scroll.vue
  39. 46 0
      common/components/s-hotzone-block/s-hotzone-block.vue
  40. 51 0
      common/components/s-image-banner/s-image-banner.vue
  41. 27 0
      common/components/s-image-block/s-image-block.vue
  42. 110 0
      common/components/s-image-cube/s-image-cube.vue
  43. 279 0
      common/components/s-layout/s-layout.vue
  44. 15 0
      common/components/s-line-block/s-line-block.vue
  45. 144 0
      common/components/s-live-block/s-live-block.vue
  46. 234 0
      common/components/s-live-card/s-live-card.vue
  47. 343 0
      common/components/s-menu-button/s-menu-button.vue
  48. 104 0
      common/components/s-menu-grid/s-menu-grid.vue
  49. 66 0
      common/components/s-menu-list/s-menu-list.vue
  50. 118 0
      common/components/s-menu-tools/s-menu-tools.vue
  51. 38 0
      common/components/s-notice-block/s-notice-block.vue
  52. 132 0
      common/components/s-order-card/s-order-card.vue
  53. 383 0
      common/components/s-point-card/s-point-card.vue
  54. 85 0
      common/components/s-popup-image/s-popup-image.vue
  55. 164 0
      common/components/s-search-block/s-search-block.vue
  56. 508 0
      common/components/s-select-groupon-sku/s-select-groupon-sku.vue
  57. 453 0
      common/components/s-select-seckill-sku/s-select-seckill-sku.vue
  58. 464 0
      common/components/s-select-sku/s-select-sku.vue
  59. 168 0
      common/components/s-share-modal/canvas-poster/index.vue
  60. 125 0
      common/components/s-share-modal/canvas-poster/poster/goods.js
  61. 125 0
      common/components/s-share-modal/canvas-poster/poster/groupon.js
  62. 39 0
      common/components/s-share-modal/canvas-poster/poster/index.js
  63. 75 0
      common/components/s-share-modal/canvas-poster/poster/user.js
  64. 195 0
      common/components/s-share-modal/s-share-modal.vue
  65. 10 0
      common/components/s-statusbar/s-statusbar.vue
  66. 127 0
      common/components/s-tabbar/s-tabbar.vue
  67. 109 0
      common/components/s-title-block/s-title-block.vue
  68. 304 0
      common/components/s-uploader/choose-and-upload-file.js
  69. 677 0
      common/components/s-uploader/s-uploader.vue
  70. 335 0
      common/components/s-uploader/upload-file.vue
  71. 306 0
      common/components/s-uploader/upload-image.vue
  72. 109 0
      common/components/s-uploader/utils.js
  73. 184 0
      common/components/s-user-card/s-user-card.vue
  74. 32 0
      common/components/s-video-block/s-video-block.vue
  75. 119 0
      common/components/s-wallet-card/s-wallet-card.vue
  76. 25 0
      common/config/index.js
  77. 20 0
      common/config/zIndex.js
  78. 153 0
      common/helper/const.js
  79. 168 0
      common/helper/digit.js
  80. 700 0
      common/helper/index.js
  81. 285 0
      common/helper/test.js
  82. 31 0
      common/helper/throttle.js
  83. 67 0
      common/helper/tools.js
  84. 336 0
      common/helper/utils.js
  85. 499 0
      common/hooks/useGoods.js
  86. 132 0
      common/hooks/useModal.js
  87. 40 0
      common/index.js
  88. 32 0
      common/libs/mplive-manifest-plugin.js
  89. 244 0
      common/libs/permission.js
  90. 193 0
      common/libs/sdk-h5-weixin.js
  91. 121 0
      common/platform/index.js
  92. 302 0
      common/request/index.js
  93. 172 0
      common/router/index.js
  94. 79 0
      common/router/utils/strip-json-comments.js
  95. 103 0
      common/router/utils/uni-read-pages-v3.js
  96. 354 0
      common/scss/_main.scss
  97. 61 0
      common/scss/_mixins.scss
  98. 286 0
      common/scss/_tools.scss
  99. 163 0
      common/scss/_var.scss
  100. BIN
      common/scss/font/OPPOSANS-M-subfont.ttf

+ 30 - 0
.env

@@ -0,0 +1,30 @@
+# 版本号
+SOUND_CHAIN_VERSION=v2.4.1
+
+# 后端接口 - 正式环境(通过 process.env.NODE_ENV 非 development)
+SOUND_CHAIN_BASE_URL=https://test.wefanbot.com:28993
+SOUND_CHAIN_WEB_URL=https://callweb.wefanbot.com
+
+# 后端接口 - 测试环境(通过 process.env.NODE_ENV = development)
+SOUND_CHAIN_DEV_BASE_URL=/api
+
+# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务
+SOUND_CHAIN_UPLOAD_TYPE=server
+
+# 后端接口前缀(一般不建议调整)
+SOUND_CHAIN_API_PATH=
+
+
+# 开发环境运行端口
+SOUND_CHAIN_DEV_PORT=3000
+
+# 客户端静态资源地址 空=默认使用服务端指定的CDN资源地址前缀 | local=本地  |  http(s)://xxx.xxx=自定义静态资源地址前缀
+SOUND_CHAIN_STATIC_URL=https://test.wefanbot.com:28993
+### SOUND_CHAIN_STATIC_URL = https://test.wefanbot.com:28993
+
+
+# 是否开启直播  1 开启直播 | 0 关闭直播
+SOUND_CHAIN_MPLIVE_ON=0
+
+# 租户ID 默认 1
+SOUND_CHAIN_TENANT_ID=1

+ 11 - 0
.gitignore

@@ -0,0 +1,11 @@
+unpackage/*
+node_modules/*
+.idea/*
+deploy.sh
+.hbuilderx/
+.vscode/
+**/.DS_Store
+yarn.lock
+package-lock.json
+*.keystore
+pnpm-lock.yaml

+ 6 - 0
.prettierignore

@@ -0,0 +1,6 @@
+/unpackage/*
+/node_modules/**
+/uni_modules/**
+/public/*
+**/*.svg
+**/*.sh

+ 10 - 0
.prettierrc

@@ -0,0 +1,10 @@
+{
+  "printWidth": 100,
+  "semi": true,
+  "vueIndentScriptAndStyle": true,
+  "singleQuote": true,
+  "trailingComma": "all",
+  "proseWrap": "never",
+  "htmlWhitespaceSensitivity": "strict",
+  "endOfLine": "auto"
+}

+ 38 - 0
App.vue

@@ -0,0 +1,38 @@
+<script setup>
+  import { onLaunch, onShow, onError } from '@dcloudio/uni-app';
+  import sheep from '@/common';
+
+  onLaunch(() => {
+    // 隐藏原生导航栏 使用自定义底部导航
+    uni.hideTabBar({
+      fail: () => {},
+    });
+
+	//游客游客模式注释
+	if(!uni.getStorageSync("token")){
+		uni.reLaunch({
+			url: '/pages/index/index'
+		})
+	}else{
+		sheep.$store('user').getInfo()
+	}
+  })
+
+  onShow(() => {
+    // #ifdef APP-PLUS
+    // 获取urlSchemes参数
+    const args = plus.runtime.arguments;
+    if (args) {
+    }
+
+    // 获取剪贴板
+    uni.getClipboardData({
+      success: (res) => {},
+    });
+    // #endif
+  });
+</script>
+
+<style lang="scss">
+  @import '@/common/scss/index.scss';
+</style>

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 lidongtony
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 3 - 0
androidPrivacy.json

@@ -0,0 +1,3 @@
+{
+    "prompt" : "template"
+}

+ 11 - 0
common/api/index.js

@@ -0,0 +1,11 @@
+// 目的:解决微信小程序的「代码质量」在「JS 文件」提示:主包内,不应该存在主包未使用的 JS 文件
+const files = import.meta.glob('./*/*.js', { eager: true });
+let api = {};
+Object.keys(files).forEach((key) => {
+  api = {
+    ...api,
+    [key.replace(/(.*\/)*([^.]+).*/gi, '$2')]: files[key].default,
+  };
+});
+
+export default api;

+ 66 - 0
common/api/infra/file.js

@@ -0,0 +1,66 @@
+import { baseUrl, apiPath } from '@/common/config';
+import request, { getAccessToken } from '@/common/request';
+
+const FileApi = {
+  // 上传文件
+  uploadFile: (file, directory) => {
+    uni.showLoading({
+      title: '上传中',
+    });
+    return new Promise((resolve, reject) => {
+      uni.uploadFile({
+        url: baseUrl + apiPath + '/infra/file/upload',
+        filePath: file,
+        name: 'file',
+        header: {
+          Accept: '*/*',
+          Authorization: 'Bearer ' + getAccessToken(),
+        },
+        formData: {
+          directory,
+        },
+        success: (uploadFileRes) => {
+          let result = JSON.parse(uploadFileRes.data);
+          if (result.error === 1) {
+            uni.showToast({
+              icon: 'none',
+              title: result.msg,
+            });
+          } else {
+            return resolve(result);
+          }
+        },
+        fail: (error) => {
+          console.log('上传失败:', error);
+          return resolve(false);
+        },
+        complete: () => {
+          uni.hideLoading();
+        },
+      });
+    });
+  },
+
+  // 获取文件预签名地址
+  getFilePresignedUrl: (name, directory) => {
+    return request({
+      url: '/infra/file/presigned-url',
+      method: 'GET',
+      params: {
+        name,
+        directory,
+      },
+    });
+  },
+
+  // 创建文件
+  createFile: (data) => {
+    return request({
+      url: '/infra/file/create', // 请求的 URL
+      method: 'POST', // 请求方法
+      data: data, // 要发送的数据
+    });
+  },
+};
+
+export default FileApi;

+ 156 - 0
common/api/member/auth.js

@@ -0,0 +1,156 @@
+import request from '@/common/request';
+
+const AuthUtil = {
+  // 使用手机 + 密码登录
+  login: (data) => {
+    return request({
+      url: '/call/api/user/p/phonePswLogin',
+      method: 'POST',
+      data,
+      custom: {
+        showSuccess: true,
+        loadingMsg: '登录中',
+        successMsg: '登录成功',
+      },
+    });
+  },
+  // 手机密码注册
+  phonePswRegister: (data) => {
+    return request({
+      url: '/call/api/user/p/phonePswRegister',
+      method: 'POST',
+      data,
+      custom: {
+        showSuccess: true,
+        loadingMsg: '注册中',
+        successMsg: '注册成功',
+      },
+    });
+  },
+  // 图形验证码
+  getCaptcha: () => {
+    return request({
+      url: '/captcha',
+      method: 'GET',
+      custom: {
+        showLoading: false, // 不用加载中
+        showError: false, // 不展示错误提示
+      },
+    });
+  },
+  // 登出系统
+  logout: () => {
+    return request({
+      url: '/call/api/user/logout',
+      method: 'post',
+    });
+  },
+  // 刷新令牌
+  refreshToken: (token) => {
+    return request({
+      url: '/token/update',
+      method: 'POST',
+      params: {
+        token,
+      },
+      custom: {
+        showLoading: false, // 不用加载中
+        showError: false, // 不展示错误提示
+      },
+    });
+  },
+  // 使用手机 + 验证码登录
+  smsLogin: (data) => {
+    return request({
+      url: '/member/auth/sms-login',
+      method: 'POST',
+      data,
+      custom: {
+        showSuccess: true,
+        loadingMsg: '登录中',
+        successMsg: '登录成功',
+      },
+    });
+  },
+  // 发送手机验证码
+  sendSmsCode: (mobile, scene) => {
+    return request({
+      url: '/member/auth/send-sms-code',
+      method: 'POST',
+      data: {
+        mobile,
+        scene,
+      },
+      custom: {
+        loadingMsg: '发送中',
+        showSuccess: true,
+        successMsg: '发送成功',
+      },
+    });
+  },
+  // 社交授权的跳转
+  socialAuthRedirect: (type, redirectUri) => {
+    return request({
+      url: '/member/auth/social-auth-redirect',
+      method: 'GET',
+      params: {
+        type,
+        redirectUri,
+      },
+      custom: {
+        showSuccess: true,
+        loadingMsg: '登陆中',
+      },
+    });
+  },
+  // 社交快捷登录
+  socialLogin: (type, code, state) => {
+    return request({
+      url: '/member/auth/social-login',
+      method: 'POST',
+      data: {
+        type,
+        code,
+        state,
+      },
+      custom: {
+        showSuccess: true,
+        loadingMsg: '登陆中',
+      },
+    });
+  },
+  // 微信小程序的一键登录
+  weixinMiniAppLogin: (phoneCode, loginCode, state) => {
+    return request({
+      url: '/member/auth/weixin-mini-app-login',
+      method: 'POST',
+      data: {
+        phoneCode,
+        loginCode,
+        state,
+      },
+      custom: {
+        showSuccess: true,
+        loadingMsg: '登陆中',
+        successMsg: '登录成功',
+      },
+    });
+  },
+  // 创建微信 JS SDK 初始化所需的签名
+  createWeixinMpJsapiSignature: (url) => {
+    return request({
+      url: '/member/auth/create-weixin-jsapi-signature',
+      method: 'POST',
+      params: {
+        url,
+      },
+      custom: {
+        showError: false,
+        showLoading: false,
+      },
+    });
+  },
+  //
+};
+
+export default AuthUtil;

+ 120 - 0
common/api/member/task.js

@@ -0,0 +1,120 @@
+import request from '@/common/request'
+const TaskApi = {
+// 刷新令牌
+  taskType: () => {
+    return request({
+      url: `/call/api/call-task-type/findListByPage?page=1&pageCount=1000`,
+      method: 'POST',
+	  data:{},
+      custom: {
+        showLoading: false, // 不用加载中
+        showError: false, // 不展示错误提示
+      },
+    })
+  },
+  
+  //创建任务
+  addTask: (data) => {
+    return request({
+      url: `/call/api/task/add`,
+      method: 'POST',
+      data,
+      custom: {
+        showSuccess: true,
+        loadingMsg: '新建中',
+        successMsg: '新建成功',
+      },
+    })
+  },
+  
+  //测试话术
+  addTest: (data) => {
+    return request({
+      url: `/call/api/call-user-call-test-record/add`,
+      method: 'POST',
+      data,
+      custom: {
+        showSuccess: false,
+        showLoading: false,
+      },
+    })
+  },
+  taskList: (data) => {
+    return request({
+      url: `/call/api/task/findListByPage?page=${data.page}&pageCount=${data.pageCount}`,
+      method: 'POST',
+  	  data,
+      custom: {
+        showLoading: false, // 加载中
+        showError: true, // 展示错误提示
+      },
+    })
+  },
+  taskDetail: (id) => {
+    return request({
+      url: `/call/api/task/findById`,
+      method: 'get',
+  	  params: {
+  	    id
+  	  },
+      custom: {
+        showLoading: true, // 加载中
+        showError: true, // 展示错误提示
+      },
+    })
+  },
+  intentionList: (id) => {
+    return request({
+      url: `/call/api/task/intention-options?taskId=${id}`,
+      method: 'post',
+      custom: {
+        showLoading: false, // 加载中
+        showError: true, // 展示错误提示
+      },
+    })
+  },
+  phoneList: (data) => {
+    return request({
+      url: `/call/api/task/phone/findListByPage`,
+      method: 'post',
+  	  data,
+      custom: {
+        showLoading: false, // 加载中
+        showError: true, // 展示错误提示
+      },
+    })
+  },
+  lastTaskDetail: () => {
+    return request({
+      url: `/call/api/task/lastTask`,
+      method: 'get',
+      custom: {
+        showLoading: false, // 加载中
+        showError: false, // 展示错误提示
+      },
+    })
+  },
+  taskDel: (data) => {
+    return request({
+      url: `/call/api/task/del?id=${data.id}`,
+      method: 'POST',
+  	  data,
+      custom: {
+        showLoading: false, // 加载中
+        showError: true, // 展示错误提示
+      },
+    })
+  },
+  phoneExport: (data) => {
+    return request({
+      url: `/call/api/task/phone/export`,
+      method: 'POST',
+	  data,
+      custom: {
+        showLoading: false, // 加载中
+        showError: true, // 展示错误提示
+      },
+    })
+  },
+}
+export default TaskApi;

+ 85 - 0
common/api/member/user.js

@@ -0,0 +1,85 @@
+import request from '@/common/request';
+
+const UserApi = {
+  // 获得基本信息
+  getUserInfo: () => {
+    return request({
+      url: '/call/api/user/info',
+      method: 'post',
+      custom: {
+        showLoading: false,
+        auth: true,
+      },
+    });
+  },
+  // 修改基本信息
+  updateUser: (data) => {
+    return request({
+      url: '/member/user/update',
+      method: 'PUT',
+      data,
+      custom: {
+        auth: true,
+        showSuccess: true,
+        successMsg: '更新成功'
+      },
+    });
+  },
+  // 修改用户手机
+  updateUserMobile: (data) => {
+    return request({
+      url: '/member/user/update-mobile',
+      method: 'PUT',
+      data,
+      custom: {
+        loadingMsg: '验证中',
+        showSuccess: true,
+        successMsg: '修改成功'
+      },
+    });
+  },
+  // 基于微信小程序的授权码,修改用户手机
+  updateUserMobileByWeixin: (code) => {
+    return request({
+      url: '/member/user/update-mobile-by-weixin',
+      method: 'PUT',
+      data: {
+        code
+      },
+      custom: {
+        showSuccess: true,
+        loadingMsg: '获取中',
+        successMsg: '修改成功'
+      },
+    });
+  },
+  // 修改密码
+  updateUserPassword: (data) => {
+    return request({
+      url: '/member/user/update-password',
+      method: 'PUT',
+      data,
+      custom: {
+        loadingMsg: '验证中',
+        showSuccess: true,
+        successMsg: '修改成功'
+      },
+    });
+  },
+  // 重置密码
+  resetUserPassword: (data) => {
+    return request({
+      url: '/member/user/reset-password',
+      method: 'PUT',
+      data,
+      custom: {
+        loadingMsg: '验证中',
+        showSuccess: true,
+        successMsg: '修改成功'
+      }
+    });
+  },
+
+};
+
+export default UserApi;

+ 256 - 0
common/components/s-activity-pop/s-activity-pop.vue

@@ -0,0 +1,256 @@
+<!-- 商品信息:满减送等营销活动的弹窗 -->
+<template>
+  <su-popup :show="show" type="bottom" round="20" @close="emits('close')" showClose>
+    <view class="model-box">
+      <view class="title ss-m-t-16 ss-m-l-20 ss-flex">优惠</view>
+      <view v-if="state.rewardActivity && state.rewardActivity.id > 0">
+        <view class="titleLi">促销</view>
+        <scroll-view
+          class="model-content"
+          scroll-y
+          :scroll-with-animation="false"
+          :enable-back-to-top="true"
+        >
+          <view
+            class="actBox"
+            v-for="(item, index) in getRewardActivityRuleGroupDescriptions(state.rewardActivity)"
+            :key="index"
+          >
+            <view
+              class="boxCont ss-flex ss-col-top ss-m-b-40"
+              @tap="onGoodsList(state.rewardActivity)"
+            >
+              <view class="model-content-tag ss-flex ss-row-center">{{ item.name }}</view>
+              <view class="model-content-title">
+                <view class="contBu">
+                  {{ item.values.join(';') }}
+                </view>
+                <view class="ss-m-b-24 cotBu-txt">
+                  {{ sheep.$helper.timeFormat(state.rewardActivity.startTime, 'yyyy.mm.dd') }}
+                  -
+                  {{ sheep.$helper.timeFormat(state.rewardActivity.endTime, 'yyyy.mm.dd') }}
+                </view>
+              </view>
+              <text class="cicon-forward" />
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+      <view class="titleLi">可领优惠券</view>
+      <scroll-view
+        class="model-content"
+        scroll-y
+        :scroll-with-animation="false"
+        :enable-back-to-top="true"
+        v-if="state.couponInfo.length"
+      >
+        <view class="actBox" v-for="item in state.couponInfo" :key="item.id">
+          <view class="boxCont ss-flex ss-col-top ss-m-b-40">
+            <view class="model-content-tag2">
+              <view class="usePrice"> ¥{{ fen2yuan(item.discountPrice) }} </view>
+              <view class="impose"> 满¥{{ fen2yuan(item.usePrice) }}可用 </view>
+            </view>
+            <view class="model-content-title2">
+              <view class="contBu">
+                {{ item.name }}
+              </view>
+              <view class="ss-m-b-24 cotBu-txt">
+                {{
+                  item.validityType == 1
+                    ? sheep.$helper.timeFormat(item.validStartTime, 'yyyy-mm-dd') +
+                      '-' +
+                      sheep.$helper.timeFormat(item.validEndTime, 'yyyy-mm-dd')
+                    : '领取后' + item.fixedStartTerm + '-' + item.fixedEndTerm + '天可用'
+                }}
+              </view>
+            </view>
+            <view class="coupon" @click.stop="getBuy(item.id)" v-if="item.canTake"> 立即领取 </view>
+            <view class="coupon2" v-else> 已领取 </view>
+          </view>
+        </view>
+      </scroll-view>
+      <view class="nullBox" v-else> 暂无可领优惠券 </view>
+    </view>
+  </su-popup>
+</template>
+<script setup>
+  import sheep from '@/common';
+  import { getRewardActivityRuleGroupDescriptions } from '@/common/hooks/useGoods';
+  import { computed, reactive, watch, ref } from 'vue';
+  import { fen2yuan } from '@/common/hooks/useGoods';
+  const props = defineProps({
+    modelValue: {
+      type: Object,
+      default() {},
+    },
+    show: {
+      type: Boolean,
+      default: false,
+    },
+  });
+  const emits = defineEmits(['close']);
+  const state = reactive({
+    rewardActivity: computed(() => props.modelValue.rewardActivity),
+    couponInfo: computed(() => props.modelValue.couponInfo),
+  });
+
+  // 领取优惠劵
+  const getBuy = (id) => {
+    emits('get', id);
+  };
+
+  function onGoodsList(e) {
+    sheep.$router.go('/pages/activity/index', {
+      activityId: e.id,
+    });
+  }
+</script>
+<style lang="scss" scoped>
+  .model-box {
+    height: 60vh;
+
+    .title {
+      justify-content: center;
+      font-size: 36rpx;
+      height: 80rpx;
+      font-weight: bold;
+      color: #333333;
+    }
+  }
+
+  .model-content {
+    height: fit-content;
+    max-height: 380rpx;
+    padding: 0 20rpx;
+    box-sizing: border-box;
+    margin-top: 20rpx;
+
+    .model-content-tag {
+      // background: rgba(#ff6911, 0.1);
+      font-size: 35rpx;
+      font-weight: 500;
+      color: #ff6911;
+      line-height: 150rpx;
+      width: 200rpx;
+      height: 150rpx;
+      text-align: center;
+
+      // border-radius: 5rpx;
+    }
+
+    .model-content-title {
+      width: 450rpx;
+      height: 150rpx;
+      font-size: 26rpx;
+      font-weight: 500;
+      color: #333333;
+      overflow: hidden;
+    }
+
+    .cicon-forward {
+      font-size: 28rpx;
+      color: #999999;
+      margin: 0 auto;
+    }
+  }
+
+  // 新增的
+  .titleLi {
+    margin: 10rpx 0 10rpx 20rpx;
+    font-size: 26rpx;
+  }
+
+  .actBox {
+    width: 700rpx;
+    height: 150rpx;
+    background-color: #fff2f2;
+    margin: 10rpx auto;
+    border-radius: 10rpx;
+  }
+
+  .boxCont {
+    width: 700rpx;
+    height: 150rpx;
+    align-items: center;
+  }
+
+  .contBu {
+    height: 80rpx;
+    line-height: 80rpx;
+    overflow: hidden;
+    font-size: 30rpx;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    -o-text-overflow: ellipsis;
+  }
+
+  .cotBu-txt {
+    height: 70rpx;
+    line-height: 70rpx;
+    font-size: 25rpx;
+    color: #999999;
+  }
+
+  .model-content-tag2 {
+    font-size: 35rpx;
+    font-weight: 500;
+    color: #ff6911;
+    width: 200rpx;
+    height: 150rpx;
+    text-align: center;
+  }
+
+  .usePrice {
+    width: 200rpx;
+    height: 90rpx;
+    line-height: 100rpx;
+    // background-color: red;
+  }
+
+  .impose {
+    width: 200rpx;
+    height: 50rpx;
+    // line-height: 75rpx;
+    font-size: 23rpx;
+    // background-color: gold;
+  }
+
+  .model-content-title2 {
+    width: 330rpx;
+    height: 150rpx;
+    font-size: 26rpx;
+    font-weight: 500;
+    color: #333333;
+    overflow: hidden;
+  }
+
+  .coupon {
+    width: 150rpx;
+    height: 50rpx;
+    line-height: 50rpx;
+    background-color: rgb(255, 68, 68);
+    color: white;
+    border-radius: 30rpx;
+    text-align: center;
+    font-size: 25rpx;
+  }
+
+  .coupon2 {
+    width: 150rpx;
+    height: 50rpx;
+    line-height: 50rpx;
+    background-color: rgb(203, 192, 191);
+    color: white;
+    border-radius: 30rpx;
+    text-align: center;
+    font-size: 25rpx;
+  }
+  .nullBox {
+    width: 100%;
+    height: 300rpx;
+    font-size: 25rpx;
+    line-height: 300rpx;
+    text-align: center;
+    color: #999999;
+  }
+</style>

+ 110 - 0
common/components/s-address-item/s-address-item.vue

@@ -0,0 +1,110 @@
+<!-- 地址卡片 -->
+<template>
+  <view
+    class="address-item ss-flex ss-row-between ss-col-center"
+    :class="[{ 'border-bottom': props.hasBorderBottom }]"
+  >
+    <view class="item-left" v-if="!isEmpty(props.item)">
+      <view class="area-text ss-flex ss-col-center">
+        <uni-tag
+          class="ss-m-r-10"
+          size="small"
+          custom-style="background-color: var(--ui-BG-Main); border-color: var(--ui-BG-Main); color: #fff;"
+          v-if="props.item.defaultStatus"
+          text="默认"
+        />
+        {{ props.item.areaName }}
+      </view>
+      <view class="address-text">
+        {{ props.item.detailAddress }}
+      </view>
+      <view class="person-text"> {{ props.item.name }} {{ props.item.mobile }} </view>
+    </view>
+    <view v-else>
+      <view class="address-text ss-m-b-10">请选择收货地址</view>
+    </view>
+    <slot>
+      <button class="ss-reset-button edit-btn" @tap.stop="onEdit">
+        <view class="edit-icon ss-flex ss-row-center ss-col-center">
+          <image :src="sheep.$url.static('/static/img/shop/user/address/edit.png')" />
+        </view>
+      </button>
+    </slot>
+  </view>
+</template>
+
+<script setup>
+  /**
+   * 基础组件 - 地址卡片
+   *
+   * @param {String}  icon = _icon-edit    - icon
+   *
+   * @event {Function()} click			 - 点击
+   * @event {Function()} actionClick		 - 点击工具栏
+   *
+   * @slot 								 - 默认插槽
+   */
+  import sheep from '@/common';
+  import { isEmpty } from 'lodash-es';
+  const props = defineProps({
+    item: {
+      type: Object,
+      default() {},
+    },
+    hasBorderBottom: {
+      type: Boolean,
+      defult: true,
+    },
+  });
+
+  const onEdit = () => {
+    sheep.$router.go('/pages/user/address/edit', {
+      id: props.item.id,
+    });
+  };
+</script>
+
+<style lang="scss" scoped>
+  .address-item {
+    padding: 24rpx 30rpx;
+
+    .item-left {
+      width: 600rpx;
+    }
+
+    .area-text {
+      font-size: 26rpx;
+      font-weight: 400;
+      color: $dark-9;
+    }
+
+    .address-text {
+      font-size: 32rpx;
+      font-weight: 500;
+      color: #333333;
+      line-height: 48rpx;
+    }
+
+    .person-text {
+      font-size: 28rpx;
+      font-weight: 400;
+      color: $dark-9;
+    }
+  }
+
+  .edit-btn {
+    width: 44rpx;
+    height: 44rpx;
+    background: $gray-f;
+    border-radius: 50%;
+
+    .edit-icon {
+      width: 24rpx;
+      height: 24rpx;
+    }
+  }
+  image {
+    width: 100%;
+    height: 100%;
+  }
+</style>

+ 119 - 0
common/components/s-auth-modal/components/account-login.vue

@@ -0,0 +1,119 @@
+<!-- 账号密码登录 accountLogin  -->
+<template>
+  <view>
+    <!-- 标题栏 -->
+    <view class="head-box ss-m-b-60 ss-flex-col">
+      <view class="head-title ss-m-r-40">登录</view>
+      <view class="head-subtitle">登录后即可使用更多功能</view>
+    </view>
+	
+    <!-- 表单项 -->
+    <uni-forms
+      ref="accountLoginRef"
+      v-model="state.model"
+      :rules="state.rules"
+      validateTrigger="bind"
+    >
+      <uni-forms-item name="phone">
+        <uni-easyinput placeholder="请输入手机号" v-model="state.model.phone" :inputBorder="false" :maxlength="11"></uni-easyinput>
+      </uni-forms-item>
+
+      <uni-forms-item name="password">
+        <uni-easyinput
+          type="password"
+          placeholder="请输入密码"
+          v-model="state.model.password"
+          :inputBorder="false"
+        >
+        </uni-easyinput>
+      </uni-forms-item>
+	  <!-- <uni-forms-item name="captcha">
+	    <uni-easyinput placeholder="请输入验证码" v-model="state.model.captcha" :inputBorder="false">
+			<template v-slot:right>
+			  <image class="captcha-btn" :src="state.captchaImg" mode="heightFix"></image>
+			</template>
+		</uni-easyinput>
+	  </uni-forms-item> -->
+    </uni-forms>
+	<view class="forgot_box">
+		<view class="forgot_btn">忘记密码</view>
+	</view>
+	<view class="submit_btn" @tap="accountLoginSubmit">登录</view>
+  </view>
+</template>
+
+<script setup>
+  import { ref, reactive, unref } from 'vue';
+  import { onLoad } from '@dcloudio/uni-app'
+  import sheep from '@/common';
+  import { phone, username, password, captcha } from '@/common/validate/form';
+  import { closeAuthModal, closeLoginPage } from '@/common/hooks/useModal';
+  import AuthUtil from '@/common/api/member/auth';
+  import { conformsTo } from 'lodash';
+
+  const accountLoginRef = ref(null);
+
+  const emits = defineEmits(['onConfirm']);
+
+  const props = defineProps({
+    agreeStatus: {
+      type: [Boolean, null],
+      default: null,
+    },
+  })
+
+  // 数据
+  const state = reactive({
+    model: {
+		clientType: 0,
+		// username: '', // 账号
+		phone: '', 
+		password: '', // 密码
+		// captcha: '', //验证码
+		// captchaId: '',
+    },
+    rules: {
+		// username,
+		phone,
+		password,
+		// captcha,
+    },
+	//captchaImg:''
+  });
+  onLoad(async (options) => {
+    //getCaptcha()
+  })
+  //获取图形验证码
+  async function getCaptcha(){
+	  const { code, data } = await AuthUtil.getCaptcha()
+	  if (code === 0) {
+		  state.captchaImg = data.imgPath
+		  state.model.captchaId = data.captchaId
+	  }
+  }
+
+  // 账号登录
+  async function accountLoginSubmit() {
+    // 表单验证
+    const validate = await unref(accountLoginRef).validate().catch((error) => {
+        console.log('error: ', error)
+    })
+    if (!validate) return
+	if(!props.agreeStatus){
+		sheep.$helper.toast('请选择同意协议')
+	}
+    // 提交数据
+    const { code, data } = await AuthUtil.login(state.model);
+    if (code === 1) {
+		sheep.$store('task').resetTaskData()
+		//closeAuthModal()
+		closeLoginPage()
+    }else{
+		//getCaptcha()
+	}
+  }
+</script>
+
+<style lang="scss" scoped>
+  @import '../index.scss';
+</style>

+ 107 - 0
common/components/s-auth-modal/components/account-reg.vue

@@ -0,0 +1,107 @@
+<!-- 账号注册 accountReg  -->
+<template>
+  <view>
+    <!-- 标题栏 -->
+    <view class="head-box ss-m-b-60 ss-flex-col">
+      <view class="head-title ss-m-r-40">注册</view>
+      <view class="head-subtitle">注册后即可登录使用更多功能</view>
+    </view>
+	
+    <!-- 表单项 -->
+    <uni-forms
+      ref="accountRegRef"
+      v-model="state.model"
+      :rules="state.rules"
+      validateTrigger="bind"
+    >
+      <uni-forms-item name="phone">
+        <uni-easyinput placeholder="请输入手机号" v-model="state.model.phone" :inputBorder="false" :maxlength="11"></uni-easyinput>
+      </uni-forms-item>
+
+      <uni-forms-item name="password">
+        <uni-easyinput
+          type="password"
+          placeholder="请输入密码"
+          v-model="state.model.password"
+          :inputBorder="false"
+        >
+        </uni-easyinput>
+      </uni-forms-item>
+	  <uni-forms-item name="password1">
+	    <uni-easyinput
+	      type="password"
+	      placeholder="请再次输入密码"
+	      v-model="state.model.password1"
+	      :inputBorder="false"
+	    >
+	    </uni-easyinput>
+	  </uni-forms-item>
+    </uni-forms>
+	<view class="submit_btn reg_btn" @tap="accountRegSubmit">注册</view>
+  </view>
+</template>
+
+<script setup>
+  import { ref, reactive, unref } from 'vue';
+  import sheep from '@/common';
+  import { phone, mobile, password } from '@/common/validate/form';
+  import { closeAuthModal } from '@/common/hooks/useModal';
+import AuthUtil from '@/common/api/member/auth';
+import $store from '@/common/store';
+  const accountRegRef = ref(null);
+
+  const emits = defineEmits(['onConfirm']);
+
+  const props = defineProps({
+    agreeStatus: {
+      type: [Boolean, null],
+      default: null,
+    },
+  });
+
+  // 数据
+  const state = reactive({
+    model: {
+	  clientType: 0,
+      //mobile: '', // 账号
+	  phone: '',
+      password: '', // 密码
+	  password1: ''
+    },
+    rules: {
+      //mobile,
+	  phone,
+      password,
+    },
+  });
+
+  // 账号注册
+  async function accountRegSubmit() {
+    // 表单验证
+    const validate = await unref(accountRegRef).validate().catch((error) => {
+        console.log('error: ', error);
+    })
+    if (!validate) return;
+	if(state.model.password1!==state.model.password){
+		sheep.$helper.toast('两次密码输入不一致')
+		return
+	}
+	if(!props.agreeStatus){
+		sheep.$helper.toast('请选择同意协议')
+		return
+	}
+	// 提交数据
+	const { code, data } = await AuthUtil.phonePswRegister(state.model)
+	if (code === 1) {
+		//closeAuthModal()
+		const modal = $store('modal')
+		modal.$patch((state) => {
+		  state.loginType = 'accountLogin'
+		})
+	}
+  }
+</script>
+
+<style lang="scss" scoped>
+  @import '../index.scss';
+</style>

+ 127 - 0
common/components/s-auth-modal/components/change-mobile.vue

@@ -0,0 +1,127 @@
+<!-- 绑定/更换手机号 changeMobile  -->
+<template>
+  <view>
+    <!-- 标题栏 -->
+    <view class="head-box ss-m-b-60">
+      <view class="head-title ss-m-b-20">
+        {{ userInfo.mobile ? '更换手机号' : '绑定手机号' }}
+      </view>
+      <view class="head-subtitle">为了您的账号安全,请使用本人手机号码</view>
+    </view>
+
+    <!-- 表单项 -->
+    <uni-forms
+      ref="changeMobileRef"
+      v-model="state.model"
+      :rules="state.rules"
+      validateTrigger="bind"
+      labelWidth="140"
+      labelAlign="center"
+    >
+      <uni-forms-item name="mobile" label="手机号">
+        <uni-easyinput
+          placeholder="请输入手机号"
+          v-model="state.model.mobile"
+          :inputBorder="false"
+          type="number"
+        >
+          <template v-slot:right>
+            <button
+              class="ss-reset-button code-btn-start"
+              :disabled="state.isMobileEnd"
+              :class="{ 'code-btn-end': state.isMobileEnd }"
+              @tap="getSmsCode('changeMobile', state.model.mobile)"
+            >
+              {{ getSmsTimer('changeMobile') }}
+            </button>
+          </template>
+        </uni-easyinput>
+      </uni-forms-item>
+
+      <uni-forms-item name="code" label="验证码">
+        <uni-easyinput
+          placeholder="请输入验证码"
+          v-model="state.model.code"
+          :inputBorder="false"
+          type="number"
+          maxlength="4"
+        >
+          <template v-slot:right>
+            <button class="ss-reset-button login-btn-start" @tap="changeMobileSubmit">
+              确认
+            </button>
+          </template>
+        </uni-easyinput>
+      </uni-forms-item>
+    </uni-forms>
+
+    <!-- 微信独有:读取手机号 -->
+    <button
+      v-if="'WechatMiniProgram' === sheep.$platform.name"
+      class="ss-reset-button type-btn"
+      open-type="getPhoneNumber"
+      @getphonenumber="getPhoneNumber"
+    >
+      使用微信手机号
+    </button>
+  </view>
+</template>
+
+<script setup>
+  import { computed, ref, reactive, unref } from 'vue';
+  import sheep from '@/common';
+  import { code, mobile } from '@/common/validate/form';
+  import { closeAuthModal, getSmsCode, getSmsTimer } from '@/common/hooks/useModal';
+  import UserApi from '@/common/api/member/user';
+
+  const changeMobileRef = ref(null);
+  const userInfo = computed(() => sheep.$store('user').userInfo);
+
+  // 数据
+  const state = reactive({
+    isMobileEnd: false, // 手机号输入完毕
+    model: {
+      mobile: '', // 手机号
+      code: '', // 验证码
+    },
+    rules: {
+      code,
+      mobile,
+    },
+  });
+
+  // 绑定手机号
+  async function changeMobileSubmit() {
+    const validate = await unref(changeMobileRef)
+      .validate()
+      .catch((error) => {
+        console.log('error: ', error);
+      });
+    if (!validate) {
+      return;
+    }
+    // 提交更新请求
+    const { code } = await UserApi.updateUserMobile(state.model);
+    if (code !== 0) {
+      return;
+    }
+    sheep.$store('user').getInfo();
+    closeAuthModal();
+  }
+
+  // 使用微信手机号
+  async function getPhoneNumber(e) {
+    if (e.detail.errMsg !== 'getPhoneNumber:ok') {
+      return;
+    }
+    const result = await sheep.$platform.useProvider().bindUserPhoneNumber(e.detail);
+    if (result) {
+      sheep.$store('user').getInfo();
+      closeAuthModal();
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  @import '../index.scss';
+</style>

+ 106 - 0
common/components/s-auth-modal/components/change-password.vue

@@ -0,0 +1,106 @@
+<!-- 修改密码(登录时)  -->
+<template>
+  <view>
+    <!-- 标题栏 -->
+    <view class="head-box ss-m-b-60">
+      <view class="head-title ss-m-b-20">修改密码</view>
+      <view class="head-subtitle">如密码丢失或未设置,请点击忘记密码重新设置</view>
+    </view>
+
+    <!-- 表单项 -->
+    <uni-forms
+      ref="changePasswordRef"
+      v-model="state.model"
+      :rules="state.rules"
+      validateTrigger="bind"
+      labelWidth="140"
+      labelAlign="center"
+    >
+      <uni-forms-item name="code" label="验证码">
+        <uni-easyinput
+          placeholder="请输入验证码"
+          v-model="state.model.code"
+          type="number"
+          maxlength="4"
+          :inputBorder="false"
+        >
+          <template v-slot:right>
+            <button
+              class="ss-reset-button code-btn code-btn-start"
+              :disabled="state.isMobileEnd"
+              :class="{ 'code-btn-end': state.isMobileEnd }"
+              @tap="getSmsCode('changePassword')"
+            >
+              {{ getSmsTimer('resetPassword') }}
+            </button>
+          </template>
+        </uni-easyinput>
+      </uni-forms-item>
+
+      <uni-forms-item name="reNewPassword" label="密码">
+        <uni-easyinput
+          type="password"
+          placeholder="请输入密码"
+          v-model="state.model.password"
+          :inputBorder="false"
+        >
+          <template v-slot:right>
+            <button class="ss-reset-button login-btn-start" @tap="changePasswordSubmit">
+              确认
+            </button>
+          </template>
+        </uni-easyinput>
+      </uni-forms-item>
+    </uni-forms>
+
+    <button class="ss-reset-button type-btn" @tap="closeAuthModal">
+      取消修改
+    </button>
+  </view>
+</template>
+
+<script setup>
+  import { ref, reactive, unref } from 'vue';
+  import { code, password } from '@/common/validate/form';
+  import { closeAuthModal, getSmsCode, getSmsTimer } from '@/common/hooks/useModal';
+  import UserApi from '@/common/api/member/user';
+
+  const changePasswordRef = ref(null);
+
+  // 数据
+  const state = reactive({
+    model: {
+      mobile: '', // 手机号
+      code: '', // 验证码
+      password: '', // 密码
+    },
+    rules: {
+      code,
+      password,
+    },
+  });
+
+  // 更改密码
+  async function changePasswordSubmit() {
+    // 参数校验
+    const validate = await unref(changePasswordRef)
+      .validate()
+      .catch((error) => {
+        console.log('error: ', error);
+      });
+    if (!validate) {
+      return;
+    }
+    // 发起请求
+    const { code } = await UserApi.updateUserPassword(state.model);
+    if (code !== 0) {
+      return;
+    }
+    // 成功后,只需要关闭弹窗
+    closeAuthModal();
+  }
+</script>
+
+<style lang="scss" scoped>
+  @import '../index.scss';
+</style>

+ 152 - 0
common/components/s-auth-modal/components/mp-authorization.vue

@@ -0,0 +1,152 @@
+<!-- 微信授权信息 mpAuthorization  -->
+<template>
+  <view>
+    <!-- 标题栏 -->
+    <view class="head-box ss-m-b-60 ss-flex-col">
+      <view class="ss-flex ss-m-b-20">
+        <view class="head-title ss-m-r-40 head-title-animation">授权信息</view>
+      </view>
+      <view class="head-subtitle">完善您的头像、昵称、手机号</view>
+    </view>
+
+    <!-- 表单项 -->
+    <uni-forms
+      ref="accountLoginRef"
+      v-model="state.model"
+      :rules="state.rules"
+      validateTrigger="bind"
+      labelWidth="140"
+      labelAlign="center"
+    >
+      <!-- 获取头像昵称:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/userProfile.html -->
+      <uni-forms-item name="avatar" label="头像">
+        <button
+          class="ss-reset-button avatar-btn"
+          open-type="chooseAvatar"
+          @chooseavatar="onChooseAvatar"
+        >
+          <image
+            class="avatar-img"
+            :src="sheep.$url.cdn(state.model.avatar)"
+            mode="aspectFill"
+            @tap="sheep.$router.go('/pages/user/info')"
+          />
+          <text class="cicon-forward" />
+        </button>
+      </uni-forms-item>
+      <uni-forms-item name="nickname" label="昵称">
+        <uni-easyinput
+          type="nickname"
+          placeholder="请输入昵称"
+          v-model="state.model.nickname"
+          :inputBorder="false"
+        />
+      </uni-forms-item>
+      <view class="foot-box">
+        <button class="ss-reset-button authorization-btn" @tap="onConfirm"> 确认授权 </button>
+      </view>
+    </uni-forms>
+  </view>
+</template>
+
+<script setup>
+  import { computed, ref, reactive } from 'vue';
+  import sheep from '@/common';
+  import { closeAuthModal } from '@/common/hooks/useModal';
+  import FileApi from '@/common/api/infra/file';
+  import UserApi from '@/common/api/member/user';
+
+  const props = defineProps({
+    agreeStatus: {
+      type: Boolean,
+      default: false,
+    },
+  });
+
+  const userInfo = computed(() => sheep.$store('user').userInfo);
+
+  const accountLoginRef = ref(null);
+
+  // 数据
+  const state = reactive({
+    model: {
+      nickname: userInfo.value.nickname,
+      avatar: userInfo.value.avatar,
+    },
+    rules: {},
+    disabledStyle: {
+      color: '#999',
+      disableColor: '#fff',
+    },
+  });
+
+  // 选择头像(来自微信)
+  function onChooseAvatar(e) {
+    const tempUrl = e.detail.avatarUrl || '';
+    uploadAvatar(tempUrl);
+  }
+
+  // 选择头像(来自文件系统)
+  async function uploadAvatar(tempUrl) {
+    if (!tempUrl) {
+      return;
+    }
+    let { data } = await FileApi.uploadFile(tempUrl);
+    state.model.avatar = data;
+  }
+
+  // 确认授权
+  async function onConfirm() {
+    const { model } = state;
+    const { nickname, avatar } = model;
+    if (!nickname) {
+      sheep.$helper.toast('请输入昵称');
+      return;
+    }
+    if (!avatar) {
+      sheep.$helper.toast('请选择头像');
+      return;
+    }
+    // 发起更新
+    const { code } = await UserApi.updateUser({
+      avatar: state.model.avatar,
+      nickname: state.model.nickname,
+    });
+    // 更新成功
+    if (code === 0) {
+      sheep.$helper.toast('授权成功');
+      await sheep.$store('user').getInfo();
+      closeAuthModal();
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  @import '../index.scss';
+
+  .foot-box {
+    width: 100%;
+    display: flex;
+    justify-content: center;
+  }
+  .authorization-btn {
+    width: 686rpx;
+    height: 80rpx;
+    background-color: var(--ui-BG-Main);
+    border-radius: 40rpx;
+    color: #fff;
+  }
+  .avatar-img {
+    width: 72rpx;
+    height: 72rpx;
+    border-radius: 36rpx;
+  }
+  .cicon-forward {
+    font-size: 30rpx;
+    color: #595959;
+  }
+  .avatar-btn {
+    width: 100%;
+    justify-content: space-between;
+  }
+</style>

+ 119 - 0
common/components/s-auth-modal/components/reset-password.vue

@@ -0,0 +1,119 @@
+<!-- 重置密码(未登录时)  -->
+<template>
+  <view>
+    <!-- 标题栏 -->
+    <view class="head-box ss-m-b-60">
+      <view class="head-title ss-m-b-20">重置密码</view>
+      <view class="head-subtitle">为了您的账号安全,设置密码前请先进行安全验证</view>
+    </view>
+
+    <!-- 表单项 -->
+    <uni-forms
+      ref="resetPasswordRef"
+      v-model="state.model"
+      :rules="state.rules"
+      validateTrigger="bind"
+      labelWidth="140"
+      labelAlign="center"
+    >
+      <uni-forms-item name="mobile" label="手机号">
+        <uni-easyinput
+          placeholder="请输入手机号"
+          v-model="state.model.mobile"
+          type="number"
+          :inputBorder="false"
+        >
+          <template v-slot:right>
+            <button
+              class="ss-reset-button code-btn code-btn-start"
+              :disabled="state.isMobileEnd"
+              :class="{ 'code-btn-end': state.isMobileEnd }"
+              @tap="getSmsCode('resetPassword', state.model.mobile)"
+            >
+              {{ getSmsTimer('resetPassword') }}
+            </button>
+          </template>
+        </uni-easyinput>
+      </uni-forms-item>
+
+      <uni-forms-item name="code" label="验证码">
+        <uni-easyinput
+          placeholder="请输入验证码"
+          v-model="state.model.code"
+          type="number"
+          maxlength="4"
+          :inputBorder="false"
+        />
+      </uni-forms-item>
+
+      <uni-forms-item name="password" label="密码">
+        <uni-easyinput
+          type="password"
+          placeholder="请输入密码"
+          v-model="state.model.password"
+          :inputBorder="false"
+        >
+          <template v-slot:right>
+            <button class="ss-reset-button login-btn-start" @tap="resetPasswordSubmit">
+              确认
+            </button>
+          </template>
+        </uni-easyinput>
+      </uni-forms-item>
+    </uni-forms>
+
+    <button v-if="!isLogin" class="ss-reset-button type-btn" @tap="showAuthModal('accountLogin')">
+      返回登录
+    </button>
+  </view>
+</template>
+
+<script setup>
+  import { computed, ref, reactive, unref } from 'vue';
+  import sheep from '@/common';
+  import { code, mobile, password } from '@/common/validate/form';
+  import { showAuthModal, closeAuthModal, getSmsCode, getSmsTimer } from '@/common/hooks/useModal';
+  import UserApi from '@/common/api/member/user';
+
+  const resetPasswordRef = ref(null);
+  const isLogin = computed(() => sheep.$store('user').isLogin);
+
+  // 数据
+  const state = reactive({
+    isMobileEnd: false, // 手机号输入完毕
+    model: {
+      mobile: '', // 手机号
+      code: '', // 验证码
+      password: '', // 密码
+    },
+    rules: {
+      code,
+      mobile,
+      password,
+    },
+  });
+
+  // 重置密码
+  const resetPasswordSubmit = async () => {
+    // 参数校验
+    const validate = await unref(resetPasswordRef)
+      .validate()
+      .catch((error) => {
+        console.log('error: ', error);
+      });
+    if (!validate) {
+      return;
+    }
+    // 发起请求
+    const { code } = await UserApi.resetUserPassword(state.model);
+    if (code !== 0) {
+      return;
+    }
+    // 成功后,用户重新登录
+    showAuthModal('accountLogin')
+  };
+</script>
+
+<style lang="scss" scoped>
+  @import '../index.scss';
+</style>

+ 138 - 0
common/components/s-auth-modal/components/sms-login.vue

@@ -0,0 +1,138 @@
+<!-- 短信登录 - smsLogin  -->
+<template>
+  <view>
+    <!-- 标题栏 -->
+    <view class="head-box ss-m-b-60">
+      <view class="ss-flex ss-m-b-20">
+        <view class="head-title head-title-line head-title-animation">短信登录</view>
+        <view class="head-title-active ss-m-r-40" @tap="showAuthModal('accountLogin')">
+          账号登录
+        </view>
+      </view>
+      <view class="head-subtitle">未注册的手机号,验证后自动注册账号</view>
+    </view>
+
+    <!-- 表单项 -->
+    <uni-forms
+      ref="smsLoginRef"
+      v-model="state.model"
+      :rules="state.rules"
+      validateTrigger="bind"
+      labelWidth="140"
+      labelAlign="center"
+    >
+      <uni-forms-item name="mobile" label="手机号">
+        <uni-easyinput
+          placeholder="请输入手机号"
+          v-model="state.model.mobile"
+          :inputBorder="false"
+          type="number"
+        >
+          <template v-slot:right>
+            <button
+              class="ss-reset-button code-btn code-btn-start"
+              :disabled="state.isMobileEnd || props.agreeStatus === false"
+              :class="{ 'code-btn-end': state.isMobileEnd || props.agreeStatus === false }"
+              @tap="checkAgreementAndGetSmsCode"
+            >
+              {{ getSmsTimer('smsLogin') }}
+            </button>
+          </template>
+        </uni-easyinput>
+      </uni-forms-item>
+
+      <uni-forms-item name="code" label="验证码">
+        <uni-easyinput
+          placeholder="请输入验证码"
+          v-model="state.model.code"
+          :inputBorder="false"
+          type="number"
+          maxlength="4"
+        >
+          <template v-slot:right>
+            <button class="ss-reset-button login-btn-start" @tap="smsLoginSubmit"> 登录 </button>
+          </template>
+        </uni-easyinput>
+      </uni-forms-item>
+    </uni-forms>
+  </view>
+</template>
+
+<script setup>
+  import { ref, reactive, unref } from 'vue';
+  import sheep from '@/common';
+  import { code, mobile } from '@/common/validate/form';
+  import { showAuthModal, closeAuthModal, getSmsCode, getSmsTimer } from '@/common/hooks/useModal';
+  import AuthUtil from '@/common/api/member/auth';
+
+  const smsLoginRef = ref(null);
+
+  const emits = defineEmits(['onConfirm']);
+
+  const props = defineProps({
+    agreeStatus: {
+      type: [Boolean, null],
+      default: null,
+    },
+  });
+
+  // 数据
+  const state = reactive({
+    isMobileEnd: false, // 手机号输入完毕
+    codeText: '获取验证码',
+    model: {
+      mobile: '', // 手机号
+      code: '', // 验证码
+    },
+    rules: {
+      code,
+      mobile,
+    },
+  });
+
+  // 检查协议并获取验证码
+  function checkAgreementAndGetSmsCode() {
+    if (props.agreeStatus !== true) {
+      emits('onConfirm', true);
+      if (props.agreeStatus === false) {
+        sheep.$helper.toast('您已拒绝协议,无法发送验证码');
+      } else {
+        sheep.$helper.toast('请选择是否同意协议');
+      }
+      return;
+    }
+    getSmsCode('smsLogin', state.model.mobile);
+  }
+
+  // 短信登录
+  async function smsLoginSubmit() {
+    // 参数校验
+    const validate = await unref(smsLoginRef)
+      .validate()
+      .catch((error) => {
+        console.log('error: ', error);
+      });
+    if (!validate) {
+      return;
+    }
+    // 检查协议状态
+    if (props.agreeStatus !== true) {
+      emits('onConfirm', true);
+      if (props.agreeStatus === false) {
+        sheep.$helper.toast('您已拒绝协议,无法继续登录');
+      } else {
+        sheep.$helper.toast('请选择是否同意协议');
+      }
+      return;
+    }
+    // 提交数据
+    const { code } = await AuthUtil.smsLogin(state.model);
+    if (code === 0) {
+      closeAuthModal();
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  @import '../index.scss';
+</style>

+ 205 - 0
common/components/s-auth-modal/index.scss

@@ -0,0 +1,205 @@
+@keyframes title-animation {
+  0% {
+    font-size: 32rpx;
+  }
+  100% {
+    font-size: 36rpx;
+  }
+}
+
+.login-wrap {
+  padding: 50rpx 34rpx;
+  min-height: 500rpx;
+  background-color: #fff;
+  border-radius: 20rpx 20rpx 0 0;
+}
+
+.head-box {
+  padding: 0 10rpx;
+  .head-title {
+    min-width: 160rpx;
+    font-weight: bold;
+    font-size: 48rpx;
+    color: #222222;
+    line-height: 80rpx;
+	margin-bottom: 20rpx;
+  }
+  .head-title-active {
+    width: 160rpx;
+    font-size: 32rpx;
+    font-weight: 600;
+    color: #999;
+    line-height: 36rpx;
+  }
+  .head-title-animation {
+    animation-name: title-animation;
+    animation-duration: 0.1s;
+    animation-timing-function: ease-out;
+    animation-fill-mode: forwards;
+  }
+  .head-title-line {
+    position: relative;
+    &::before {
+      content: '';
+      width: 1rpx;
+      height: 34rpx;
+      background-color: #e4e7ed;
+      position: absolute;
+      left: -30rpx;
+      top: 50%;
+      transform: translateY(-50%);
+    }
+  }
+  .head-subtitle {
+    font-weight: 400;
+    font-size: 28rpx;
+    color: #999999;
+	line-height: 40rpx;
+  }
+}
+
+// .code-btn[disabled] {
+// 	background-color: #fff;
+// }
+.code-btn-start {
+  width: 160rpx;
+  height: 56rpx;
+  line-height: normal;
+  border: 2rpx solid var(--ui-BG-Main);
+  border-radius: 28rpx;
+  font-size: 26rpx;
+  font-weight: 400;
+  color: var(--ui-BG-Main);
+  opacity: 1;
+}
+.captcha-btn{
+	height: 68rpx;
+	margin-right: 16rpx;
+}
+.forgot-btn {
+  width: 160rpx;
+  line-height: 56rpx;
+  font-size: 30rpx;
+  font-weight: 500;
+  color: #999;
+}
+
+.login-btn-start {
+  width: 158rpx;
+  height: 56rpx;
+  line-height: normal;
+  background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
+  border-radius: 28rpx;
+  font-size: 26rpx;
+  font-weight: 500;
+  color: #fff;
+}
+
+.type-btn {
+  padding: 20rpx;
+  margin: 40rpx auto;
+  width: 200rpx;
+  font-size: 30rpx;
+  font-weight: 500;
+  color: #999999;
+}
+
+.auto-login-box {
+  width: 100%;
+  .auto-login-btn {
+    width: 68rpx;
+    height: 68rpx;
+    border-radius: 50%;
+    margin: 0 30rpx;
+  }
+  .auto-login-img {
+    width: 68rpx;
+    height: 68rpx;
+    border-radius: 50%;
+  }
+}
+
+.agreement-box {
+  margin: 80rpx auto 0;
+  .protocol-check {
+    transform: scale(0.7);
+  }
+  .agreement-text {
+    font-size: 26rpx;
+    font-weight: 500;
+    color: #999999;
+    .tcp-text {
+      color: var(--ui-BG-Main);
+    }
+  }
+}
+
+// 修改密码
+.editPwd-btn-box {
+  .save-btn {
+    width: 690rpx;
+    line-height: 70rpx;
+    background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
+    border-radius: 35rpx;
+    font-size: 28rpx;
+    font-weight: 500;
+    color: #ffffff;
+  }
+  .forgot-btn {
+    width: 690rpx;
+    line-height: 70rpx;
+    font-size: 28rpx;
+    font-weight: 500;
+    color: #999999;
+  }
+}
+:deep(.uni-forms){
+	.uni-forms-item__inner{
+		padding-bottom: 40rpx;
+	}
+	.uni-easyinput__placeholder-class{
+		font-weight: 400;
+		font-size: 28rpx;
+		color: #CCCCCC;
+	}
+	.uni-easyinput{
+		background: #F7F7F7;
+		border-radius: 20rpx;
+	}
+	.uni-easyinput__content-input{
+		height: 100rpx;
+		line-height: 100rpx;
+		padding: 0 30rpx !important;
+		box-sizing: border-box;
+		font-weight: 500;
+		font-size: 28rpx;
+		color: #222222;
+	}
+}
+.forgot_box{
+	display: flex;
+	justify-content: flex-end;
+	padding-bottom: 40rpx;
+	opacity: 0;
+	.forgot_btn{
+		font-weight: 400;
+		font-size: 28rpx;
+		color: #24D688;
+		line-height: 40rpx;
+	}
+}
+.submit_btn{
+	width: 630rpx;
+	height: 104rpx;
+	background: #35E89A;
+	border-radius: 40rpx;
+	margin: 0 auto;
+	font-weight: bold;
+	font-size: 32rpx;
+	color: #222222;
+	line-height: 104rpx;
+	text-align: center;
+}
+.reg_btn{
+	margin-top: 212rpx;
+}

+ 260 - 0
common/components/s-auth-modal/s-auth-modal.vue

@@ -0,0 +1,260 @@
+<template>
+  <!-- 规格弹窗 -->
+  <su-popup :show="authType !== ''" round="20" :showClose="true" @close="closeAuthModal">
+    <view class="login-wrap">
+      <!-- 1. 账号密码登录 accountLogin -->
+      <account-login
+        v-if="authType === 'accountLogin'"
+        :agreeStatus="state.protocol"
+        @onConfirm="onConfirm"
+      />
+
+      <!-- 2. 短信登录  smsLogin -->
+      <sms-login
+        v-if="authType === 'smsLogin'"
+        :agreeStatus="state.protocol"
+        @onConfirm="onConfirm"
+      />
+
+      <!-- 3. 忘记密码 resetPassword-->
+      <reset-password v-if="authType === 'resetPassword'" />
+
+      <!-- 4. 绑定手机号 changeMobile -->
+      <change-mobile v-if="authType === 'changeMobile'" />
+
+      <!-- 5. 修改密码 changePassword-->
+      <changePassword v-if="authType === 'changePassword'" />
+
+      <!-- 6. 微信小程序授权 -->
+      <mp-authorization v-if="authType === 'mpAuthorization'" />
+
+      <!-- 7. 第三方登录 -->
+      <view
+        v-if="['accountLogin', 'smsLogin'].includes(authType)"
+        class="auto-login-box ss-flex ss-flex-col ss-row-center ss-col-center"
+      >
+        <!-- 7.1 微信小程序的快捷登录 -->
+        <view v-if="sheep.$platform.name === 'WechatMiniProgram'" class="ss-flex register-box">
+          <view class="register-title">还没有账号?</view>
+          <button
+            class="ss-reset-button login-btn"
+            open-type="getPhoneNumber"
+            @getphonenumber="getPhoneNumber"
+          >
+            快捷登录
+          </button>
+          <view class="circle" />
+        </view>
+
+        <!-- 7.2 微信的公众号、App、小程序的登录,基于 openid + code -->
+        <button
+          v-if="
+            ['WechatOfficialAccount', 'WechatMiniProgram', 'App'].includes(sheep.$platform.name) &&
+            sheep.$platform.isWechatInstalled
+          "
+          @tap="thirdLogin('wechat')"
+          class="ss-reset-button auto-login-btn"
+        >
+          <image
+            class="auto-login-img"
+            :src="sheep.$url.static('/static/img/shop/platform/wechat.png')"
+          />
+        </button>
+
+        <!-- 7.3 iOS 登录 TODO 芋艿:等后面搞 App 再弄 -->
+        <button
+          v-if="sheep.$platform.os === 'ios' && sheep.$platform.name === 'App'"
+          @tap="thirdLogin('apple')"
+          class="ss-reset-button auto-login-btn"
+        >
+          <image
+            class="auto-login-img"
+            :src="sheep.$url.static('/static/img/shop/platform/apple.png')"
+          />
+        </button>
+      </view>
+
+      <!-- 同意选项 -->
+      <view class="agreement-option ss-m-b-20">
+        <label class="radio ss-flex ss-col-center" @tap="onAgree">
+      	<view class="check_box" @tap.stop="onAgree">
+      		<image v-if="state.protocol" class="check_icon" src="/static/check_icon.png"></image>
+      		<image v-else class="check_icon" src="/static/check_icon1.png"></image>
+      	</view>
+          <view class="agreement-text ss-flex ss-col-center ss-m-l-8">
+            我已阅读并同意遵守
+            <view class="tcp-text" @tap.stop="onProtocol('用户协议')"> 《用户协议》 </view>
+            <view class="agreement-text">与</view>
+            <view class="tcp-text" @tap.stop="onProtocol('隐私协议')"> 《隐私协议》 </view>
+          </view>
+        </label>
+      </view>
+      <view class="safe-box" />
+    </view>
+  </su-popup>
+</template>
+
+<script setup>
+  import { computed, reactive, ref } from 'vue';
+  import sheep from '@/common';
+  import accountLogin from './components/account-login.vue';
+  import smsLogin from './components/sms-login.vue';
+  import resetPassword from './components/reset-password.vue';
+  import changeMobile from './components/change-mobile.vue';
+  import changePassword from './components/change-password.vue';
+  import mpAuthorization from './components/mp-authorization.vue';
+  import { closeAuthModal, showAuthModal } from '@/common/hooks/useModal';
+
+  const modalStore = sheep.$store('modal');
+  // 授权弹窗类型
+  const authType = computed(() => modalStore.auth);
+
+  const state = reactive({
+    protocol: null, // null表示未选择,true表示同意,false表示拒绝
+  });
+
+  const currentProtocol = ref(false);
+
+  // 同意协议
+  function onAgree() {
+    state.protocol = !state.protocol;
+  }
+
+  // 查看协议
+  function onProtocol(title) {
+    closeAuthModal();
+    sheep.$router.go('/pages/public/richtext', {
+      title,
+    });
+  }
+
+  // 点击登录 / 注册事件
+  function onConfirm(e) {
+    currentProtocol.value = e;
+    setTimeout(() => {
+      currentProtocol.value = false;
+    }, 1000);
+  }
+
+  // 第三方授权登陆(微信小程序、Apple)
+  const thirdLogin = async (provider) => {
+    if (state.protocol !== true) {
+      currentProtocol.value = true;
+      setTimeout(() => {
+        currentProtocol.value = false;
+      }, 1000);
+      
+      if (state.protocol === false) {
+        sheep.$helper.toast('您已拒绝协议,无法继续登录');
+      } else {
+        sheep.$helper.toast('请选择是否同意协议');
+      }
+      return;
+    }
+    const loginRes = await sheep.$platform.useProvider(provider).login();
+    if (loginRes) {
+      const userInfo = await sheep.$store('user').getInfo();
+      closeAuthModal();
+      // 如果用户已经有头像和昵称,不需要再次授权
+      if (userInfo.avatar && userInfo.nickname) {
+        return;
+      }
+
+      // 触发小程序授权信息弹框
+      // #ifdef MP-WEIXIN
+      showAuthModal('mpAuthorization');
+      // #endif
+    }
+  };
+
+  // 微信小程序的“手机号快速验证”:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html
+  const getPhoneNumber = async (e) => {
+    if (e.detail.errMsg !== 'getPhoneNumber:ok') {
+      sheep.$helper.toast('快捷登录失败');
+      return;
+    }
+    let result = await sheep.$platform.useProvider().mobileLogin(e.detail);
+    if (result) {
+      closeAuthModal();
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  @import './index.scss';
+
+  .shake {
+    animation: shake 0.05s linear 4 alternate;
+  }
+
+  @keyframes shake {
+    from {
+      transform: translateX(-10rpx);
+    }
+    to {
+      transform: translateX(10rpx);
+    }
+  }
+
+  .register-box {
+    position: relative;
+    justify-content: center;
+    .register-btn {
+      color: #999999;
+      font-size: 30rpx;
+      font-weight: 500;
+    }
+    .register-title {
+      color: #999999;
+      font-size: 30rpx;
+      font-weight: 400;
+      margin-right: 24rpx;
+    }
+    .or-title {
+      margin: 0 16rpx;
+      color: #999999;
+      font-size: 30rpx;
+      font-weight: 400;
+    }
+    .login-btn {
+      color: var(--ui-BG-Main);
+      font-size: 30rpx;
+      font-weight: 500;
+    }
+    .circle {
+      position: absolute;
+      right: 0rpx;
+      top: 18rpx;
+      width: 8rpx;
+      height: 8rpx;
+      border-radius: 8rpx;
+      background: var(--ui-BG-Main);
+    }
+  }
+  .safe-box {
+    height: calc(constant(safe-area-inset-bottom) / 5 * 3);
+    height: calc(env(safe-area-inset-bottom) / 5 * 3);
+  }
+
+  .agreement-option {
+    width: 100%;
+    display: flex;
+    justify-content: center;
+	padding-top: 40rpx;
+    .check_box{
+  	  padding-right: 10rpx;
+  	  .check_icon{
+  		  width: 24rpx;
+  		  height: 24rpx;
+  	  }
+    }
+    .agreement-text {
+		font-weight: 400;
+		font-size: 24rpx;
+		color: #222222;
+		.tcp-text {
+		  color: #24D688;
+		}
+    }
+  }
+</style>

+ 52 - 0
common/components/s-block/s-block.vue

@@ -0,0 +1,52 @@
+<!-- 装修组件容器 -->
+<template>
+  <view :style="[elStyles, elBackground]"><slot /></view>
+</template>
+
+<script setup>
+  /**
+   * 容器组件 - 装修组件的样式容器
+   */
+  import { computed, provide, unref } from 'vue';
+  import sheep from '@/common';
+
+  const props = defineProps({
+    styles: {
+      type: Object,
+      default() {},
+    },
+  });
+
+  // 组件样式
+
+  const elBackground = computed(() => {
+    if (props.styles) {
+      if (props.styles.bgType === 'color')
+        return { background: props.styles.bgColor };
+      if (props.styles.bgType === 'img')
+        return {
+          background: `url(${props.styles.bgImage}) no-repeat top center / 100% auto`,
+        };
+    }
+  });
+
+  const elStyles = computed(() => {
+    if (props.styles) {
+      return {
+        marginTop: `${props.styles.marginTop || 0}rpx`,
+        marginBottom: `${props.styles.marginBottom || 0}rpx`,
+        marginLeft: `${props.styles.marginLeft || 0}rpx`,
+        marginRight: `${props.styles.marginRight || 0}rpx`,
+        paddingTop: `${props.styles.paddingTop || 0}rpx`,
+        paddingRight: `${props.styles.paddingRight || 0}rpx`,
+        paddingBottom: `${props.styles.paddingBottom || 0}rpx`,
+        paddingLeft: `${props.styles.paddingLeft || 0}rpx`,
+        borderTopLeftRadius: `${props.styles.borderTopLeftRadius || 0}rpx`,
+        borderTopRightRadius: `${props.styles.borderTopRightRadius || 0}rpx`,
+        borderBottomRightRadius: `${props.styles.borderBottomRightRadius || 0}rpx`,
+        borderBottomLeftRadius: `${props.styles.borderBottomLeftRadius || 0}rpx`,
+        overflow: 'hidden',
+      };
+    }
+  });
+</script>

+ 173 - 0
common/components/s-count-down/s-count-down.vue

@@ -0,0 +1,173 @@
+<template>
+	<view class="time" :style="justifyLeft">
+		<text class="" v-if="tipText">{{ tipText }}</text>
+		<text class="styleAll p6" v-if="isDay === true"
+			:style="{background:bgColor.bgColor,color:bgColor.Color}">{{ day }}{{bgColor.isDay?'天':''}}</text>
+		<text class="timeTxt" v-if="dayText"
+			:style="{width:bgColor.timeTxtwidth,color:bgColor.bgColor}">{{ dayText }}</text>
+		<text class="styleAll" :class='isCol?"timeCol":""'
+			:style="{background:bgColor.bgColor,color:bgColor.Color,width:bgColor.width}">{{ hour }}</text>
+		<text class="timeTxt" v-if="hourText" :class='isCol?"whit":""'
+			:style="{width:bgColor.timeTxtwidth,color:bgColor.bgColor}">{{ hourText }}</text>
+		<text class="styleAll" :class='isCol?"timeCol":""'
+			:style="{background:bgColor.bgColor,color:bgColor.Color,width:bgColor.width}">{{ minute }}</text>
+		<text class="timeTxt" v-if="minuteText" :class='isCol?"whit":""'
+			:style="{width:bgColor.timeTxtwidth,color:bgColor.bgColor}">{{ minuteText }}</text>
+		<text class="styleAll" :class='isCol?"timeCol":""'
+			:style="{background:bgColor.bgColor,color:bgColor.Color,width:bgColor.width}">{{ second }}</text>
+		<text class="timeTxt" v-if="secondText">{{ secondText }}</text>
+	</view>
+</template>
+
+<script>
+	export default {
+		name: "countDown",
+		props: {
+			justifyLeft: {
+				type: String,
+				default: ""
+			},
+			//距离开始提示文字
+			tipText: {
+				type: String,
+				default: "倒计时"
+			},
+			dayText: {
+				type: String,
+				default: "天"
+			},
+			hourText: {
+				type: String,
+				default: "时"
+			},
+			minuteText: {
+				type: String,
+				default: "分"
+			},
+			secondText: {
+				type: String,
+				default: "秒"
+			},
+			datatime: {
+				type: Number,
+				default: 0
+			},
+			isDay: {
+				type: Boolean,
+				default: true
+			},
+			isCol: {
+				type: Boolean,
+				default: false
+			},
+			bgColor: {
+				type: Object,
+				default: null
+			}
+		},
+		data: function() {
+			return {
+				day: "00",
+				hour: "00",
+				minute: "00",
+				second: "00"
+			};
+		},
+		created: function() {
+			this.show_time();
+		},
+		mounted: function() {},
+		methods: {
+			show_time: function() {
+				let that = this;
+
+				function runTime() {
+					//时间函数
+					let intDiff = that.datatime - Date.parse(new Date()) / 1000; //获取数据中的时间戳的时间差;
+					let day = 0,
+						hour = 0,
+						minute = 0,
+						second = 0;
+					if (intDiff > 0) {
+						//转换时间
+						if (that.isDay === true) {
+							day = Math.floor(intDiff / (60 * 60 * 24));
+						} else {
+							day = 0;
+						}
+						hour = Math.floor(intDiff / (60 * 60)) - day * 24;
+						minute = Math.floor(intDiff / 60) - day * 24 * 60 - hour * 60;
+						second =
+							Math.floor(intDiff) -
+							day * 24 * 60 * 60 -
+							hour * 60 * 60 -
+							minute * 60;
+						if (hour <= 9) hour = "0" + hour;
+						if (minute <= 9) minute = "0" + minute;
+						if (second <= 9) second = "0" + second;
+						that.day = day;
+						that.hour = hour;
+						that.minute = minute;
+						that.second = second;
+					} else {
+						that.day = "00";
+						that.hour = "00";
+						that.minute = "00";
+						that.second = "00";
+					}
+				}
+				runTime();
+				setInterval(runTime, 1000);
+			}
+		}
+	};
+</script>
+
+<style scoped>
+	.p6 {
+		padding: 0 8rpx;
+	}
+
+	.styleAll {
+		/* color: #fff; */
+		font-size: 24rpx;
+		height: 36rpx;
+		line-height: 36rpx;
+		border-radius: 6rpx;
+		text-align: center;
+		/* padding: 0 6rpx; */
+	}
+
+	.timeTxt {
+		text-align: center;
+		/* width: 16rpx; */
+		height: 36rpx;
+		line-height: 36rpx;
+		display: inline-block;
+	}
+
+	.whit {
+		color: #fff !important;
+	}
+
+	.time {
+		display: flex;
+		justify-content: center;
+	}
+
+	.red {
+		color: #fc4141;
+		margin: 0 4rpx;
+	}
+
+	.timeCol {
+		/* width: 40rpx;
+		height: 40rpx;
+		line-height: 40rpx;
+		text-align:center;
+		border-radius: 6px;
+		background: #fff;
+		font-size: 24rpx; */
+		color: #E93323;
+	}
+</style>

+ 102 - 0
common/components/s-coupon-card/s-coupon-card.vue

@@ -0,0 +1,102 @@
+<!-- 装修用户组件:用户卡券 -->
+<template>
+	<view class="ss-coupon-menu-wrap ss-flex ss-col-center" :style="[bgStyle, { marginLeft: `${data.space}px` }]">
+		<view class="menu-item ss-flex-col ss-row-center ss-col-center" v-for="item in props.list" :key="item.title"
+			@tap="sheep.$router.go(item.path, { type: item.type })"
+			:class="item.type === 'all' ? 'menu-wallet' : 'ss-flex-1'">
+			<image class="item-icon" :src="sheep.$url.static(item.icon)" mode="aspectFit"></image>
+			<view class="menu-title ss-m-t-28">{{ item.title }}</view>
+		</view>
+	</view>
+</template>
+
+<script setup>
+	/**
+	 * 装修组件 - 优惠券菜单
+	 */
+	import sheep from '@/common';
+	import { computed } from 'vue';
+
+	// 接收参数
+	const props = defineProps({
+		list: {
+			type: Array,
+			default () {
+				return [{
+						title: '已领取',
+						value: '0',
+						icon: '/static/img/shop/order/nouse_coupon.png',
+						path: '/pages/coupon/list',
+						type: 'geted',
+					},
+					{
+						title: '已使用',
+						value: '0',
+						icon: '/static/img/shop/order/useend_coupon.png',
+						path: '/pages/coupon/list',
+						type: 'used',
+					},
+					{
+						title: '已失效',
+						value: '0',
+						icon: '/static/img/shop/order/out_coupon.png',
+						path: '/pages/coupon/list',
+						type: 'expired',
+					},
+					{
+					  title: '领券中心',
+					  value: '0',
+					  icon: '/static/img/shop/order/all_coupon.png',
+					  path: '/pages/coupon/list',
+					  type: 'all',
+					},
+				];
+			},
+		},
+		// 装修数据
+		data: {
+		  type: Object,
+		  default: () => ({}),
+		},
+		// 装修样式
+		styles: {
+		  type: Object,
+		  default: () => ({}),
+		},
+	});
+	// 设置背景样式
+	const bgStyle = computed(() => {
+	  // 直接从 props.styles 解构
+	  const { bgType, bgImg, bgColor } = props.styles; 
+	
+	  // 根据 bgType 返回相应的样式
+	  return {
+		background: bgType === 'img'
+			? `url(${bgImg}) no-repeat top center / 100% 100%`
+			: bgColor
+		};
+	});
+</script>
+
+<style lang="scss" scoped>
+	.ss-coupon-menu-wrap {
+		.menu-item {
+			height: 160rpx;
+
+			.menu-title {
+				font-size: 24rpx;
+				line-height: 24rpx;
+				color: #333333;
+			}
+
+			.item-icon {
+				width: 44rpx;
+				height: 44rpx;
+			}
+		}
+
+		.menu-wallet {
+			width: 144rpx;
+		}
+	}
+</style>

+ 109 - 0
common/components/s-coupon-get/s-coupon-get.vue

@@ -0,0 +1,109 @@
+<!-- 商品详情 - 优惠劵领取 -->
+<template>
+  <su-popup
+    :show="show"
+    type="bottom"
+    round="20"
+    @close="emits('close')"
+    showClose
+    backgroundColor="#f2f2f2"
+  >
+    <view class="model-box">
+      <view class="title ss-m-t-16 ss-m-l-20 ss-flex">优惠券</view>
+      <scroll-view
+        class="model-content"
+        scroll-y
+        :scroll-with-animation="false"
+        :enable-back-to-top="true"
+      >
+        <view class="subtitle ss-m-l-20">可使用优惠券</view>
+        <view v-for="item in state.couponInfo" :key="item.id">
+          <s-coupon-list :data="item">
+            <template #default>
+              <button
+                class="ss-reset-button card-btn ss-flex ss-row-center ss-col-center"
+                :class="!item.canTake ? 'boder-btn' : ''"
+                @click.stop="getBuy(item.id)"
+                :disabled="!item.canTake"
+              >
+                {{ item.canTake ? '立即领取' : '已领取' }}
+              </button>
+            </template>
+          </s-coupon-list>
+        </view>
+      </scroll-view>
+    </view>
+  </su-popup>
+</template>
+<script setup>
+  import { computed, reactive } from 'vue';
+
+  const props = defineProps({
+    modelValue: {
+      type: Object,
+      default() {},
+    },
+    show: {
+      type: Boolean,
+      default: false,
+    },
+  });
+
+  const emits = defineEmits(['get', 'close']);
+
+  const state = reactive({
+    couponInfo: computed(() => props.modelValue)
+  });
+
+  // 领取优惠劵
+  const getBuy = (id) => {
+    emits('get', id);
+  };
+</script>
+<style lang="scss" scoped>
+  .model-box {
+    height: 60vh;
+    .title {
+      font-size: 36rpx;
+      height: 80rpx;
+      font-weight: bold;
+      color: #333333;
+    }
+    .subtitle {
+      font-size: 26rpx;
+      font-weight: 500;
+      color: #333333;
+    }
+  }
+  .model-content {
+    height: 54vh;
+  }
+  .modal-footer {
+    width: 100%;
+    height: 120rpx;
+    background: #fff;
+  }
+  .confirm-btn {
+    width: 710rpx;
+    margin-left: 20rpx;
+    height: 80rpx;
+    background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
+    border-radius: 40rpx;
+    color: #fff;
+  }
+  // 优惠券按钮
+  .card-btn {
+    // width: 144rpx;
+    padding: 0 16rpx;
+    height: 50rpx;
+    border-radius: 40rpx;
+    background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
+    color: #ffffff;
+    font-size: 24rpx;
+    font-weight: 400;
+  }
+  .boder-btn {
+    background: linear-gradient(90deg, var(--ui-BG-Main-opacity-4), var(--ui-BG-Main-light));
+    color: #fff !important;
+  }
+</style>

+ 195 - 0
common/components/s-coupon-list/s-coupon-list.vue

@@ -0,0 +1,195 @@
+<template>
+  <view class="ss-m-20" :style="{ opacity: disabled ? '0.5' : '1' }">
+    <view class="content">
+      <view
+        class="tag ss-flex ss-row-center"
+        :class="isDisable ? 'disabled-bg-color' : 'info-bg-color'"
+      >
+        {{ data.discountType === 1 ? '满减券' : '折扣券' }}
+      </view>
+      <view class="title ss-m-x-30 ss-p-t-18">
+        <view class="ss-flex ss-row-between">
+          <view
+            class="value-text ss-flex-1 ss-m-r-10"
+            :class="isDisable ? 'disabled-color' : 'info-color'"
+          >
+            {{ data.name }}
+          </view>
+          <view>
+            <view
+              class="ss-flex ss-col-bottom"
+              :class="isDisable ? 'disabled-color' : 'price-text'"
+            >
+              <view class="value-reduce ss-m-b-10" v-if="data.discountType === 1">¥</view>
+              <view class="value-price">
+                {{
+                  data.discountType === 1
+                    ? fen2yuan(data.discountPrice)
+                    : data.discountPercent / 10.0
+                }}
+              </view>
+              <view class="value-discount ss-m-b-10 ss-m-l-4" v-if="data.discountType === 2"
+                >折</view
+              >
+            </view>
+          </view>
+        </view>
+        <view class="ss-flex ss-row-between ss-m-t-16">
+          <view
+            class="sellby-text"
+            :class="isDisable ? 'disabled-color' : 'subtitle-color'"
+            v-if="data.validityType === 2"
+          >
+            有效期:领取后 {{ data.fixedEndTerm }} 天内可用
+          </view>
+          <view class="sellby-text" :class="isDisable ? 'disabled-color' : 'subtitle-color'" v-else>
+            有效期: {{ sheep.$helper.timeFormat(data.validStartTime, 'yyyy-mm-dd') }} 至
+            {{ sheep.$helper.timeFormat(data.validEndTime, 'yyyy-mm-dd') }}
+          </view>
+          <view class="value-enough" :class="isDisable ? 'disabled-color' : 'subtitle-color'">
+            满 {{ fen2yuan(data.usePrice) }} 可用
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="desc ss-flex ss-row-between">
+      <view>
+        <view class="desc-title">{{ data.description }}</view>
+        <view>
+          <slot name="reason" />
+        </view>
+      </view>
+      <view>
+        <slot />
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  import { computed } from 'vue';
+  import { fen2yuan } from '../../hooks/useGoods';
+  import sheep from '../../index';
+
+  const isDisable = computed(() => {
+    if (props.type === 'coupon') {
+      return false;
+    }
+    return props.disabled;
+  });
+
+  // 接受参数
+  const props = defineProps({
+    data: {
+      type: Object,
+      default: {},
+    },
+    disabled: {
+      type: Boolean,
+      default: false,
+    },
+    type: {
+      type: String,
+      default: 'coupon', // coupon 优惠劵模版;user 用户优惠劵
+    },
+  });
+</script>
+
+<style lang="scss" scoped>
+  .info-bg-color {
+    background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
+  }
+
+  .disabled-bg-color {
+    background: #999;
+  }
+
+  .info-color {
+    color: #333;
+  }
+
+  .subtitle-color {
+    color: #666;
+  }
+
+  .disabled-color {
+    color: #999;
+  }
+
+  .content {
+    width: 100%;
+    background: #fff;
+    border-radius: 20rpx 20rpx 0 0;
+    -webkit-mask: radial-gradient(circle at 12rpx 100%, #0000 12rpx, red 0) -12rpx;
+    box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.04);
+
+    .tag {
+      width: 100rpx;
+
+      color: #fff;
+      height: 40rpx;
+      font-size: 24rpx;
+      border-radius: 20rpx 0 20rpx 0;
+    }
+
+    .title {
+      padding-bottom: 22rpx;
+      border-bottom: 2rpx dashed #d3d3d3;
+
+      .value-text {
+        font-size: 32rpx;
+        font-weight: 600;
+      }
+
+      .sellby-text {
+        font-size: 24rpx;
+        font-weight: 400;
+      }
+
+      .value-price {
+        font-size: 64rpx;
+        font-weight: 500;
+        line-height: normal;
+        font-family: OPPOSANS;
+      }
+
+      .value-reduce {
+        line-height: normal;
+        font-size: 32rpx;
+      }
+
+      .value-discount {
+        line-height: normal;
+        font-size: 28rpx;
+      }
+
+      .value-enough {
+        font-size: 24rpx;
+        font-weight: 400;
+        font-family: OPPOSANS;
+      }
+    }
+  }
+
+  .desc {
+    width: 100%;
+    background: #fff;
+    -webkit-mask: radial-gradient(circle at 12rpx 0%, #0000 12rpx, red 0) -12rpx;
+    box-shadow: rgba(#000, 0.1);
+    box-sizing: border-box;
+    padding: 24rpx 30rpx;
+    box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.04);
+    border-radius: 0 0 20rpx 20rpx;
+
+    .desc-title {
+      font-size: 24rpx;
+      color: #999;
+      font-weight: 400;
+    }
+  }
+
+  .price-text {
+    color: #ff0000;
+  }
+</style>

+ 149 - 0
common/components/s-coupon-select/s-coupon-select.vue

@@ -0,0 +1,149 @@
+<!-- 订单确认的优惠劵选择弹窗 -->
+<template>
+  <su-popup
+    :show="show"
+    type="bottom"
+    round="20"
+    @close="emits('close')"
+    showClose
+    backgroundColor="#f2f2f2"
+  >
+    <view class="model-box">
+      <view class="title ss-m-t-16 ss-m-l-20 ss-flex">优惠券</view>
+      <scroll-view
+        class="model-content"
+        scroll-y
+        :scroll-with-animation="false"
+        :enable-back-to-top="true"
+      >
+        <!--可使用的优惠券区域-->
+        <view class="subtitle ss-m-l-20">可使用优惠券</view>
+        <view
+          v-for="(item, index) in state.couponInfo.filter((coupon) => coupon.match)"
+          :key="index"
+        >
+          <s-coupon-list :data="item" type="user" :disabled="false">
+            <template #default>
+              <label class="ss-flex ss-col-center" @tap="radioChange(item.id)">
+                <radio
+                  color="var(--ui-BG-Main)"
+                  style="transform: scale(0.8)"
+                  :checked="state.couponId === item.id"
+                  @tap.stop="radioChange(item.id)"
+                />
+              </label>
+            </template>
+          </s-coupon-list>
+        </view>
+        <!--不可使用的优惠券区域-->
+        <view class="subtitle ss-m-t-40 ss-m-l-20">不可使用优惠券</view>
+        <view v-for="item in state.couponInfo.filter((coupon) => !coupon.match)" :key="item.id">
+          <s-coupon-list :data="item" type="user" :disabled="true">
+            <template v-slot:reason>
+              <view class="ss-flex ss-m-t-24">
+                <view class="reason-title"> 不可用原因:</view>
+                <view class="reason-desc">{{ item.mismatchReason || '未达到使用门槛' }}</view>
+              </view>
+            </template>
+          </s-coupon-list>
+        </view>
+      </scroll-view>
+    </view>
+    <view class="modal-footer ss-flex">
+      <button class="confirm-btn ss-reset-button" @tap="onConfirm">确认</button>
+    </view>
+  </su-popup>
+</template>
+<script setup>
+  import { computed, reactive } from 'vue';
+
+  const props = defineProps({
+    modelValue: {
+      // 优惠劵列表
+      type: Object,
+      default() {},
+    },
+    show: {
+      type: Boolean,
+      default: false,
+    },
+  });
+
+  const emits = defineEmits(['confirm', 'close']);
+
+  const state = reactive({
+    couponInfo: computed(() => props.modelValue), // 优惠劵列表
+    couponId: undefined, // 选中的优惠劵编号
+  });
+
+  // 选中优惠劵
+  function radioChange(couponId) {
+    if (state.couponId === couponId) {
+      state.couponId = undefined;
+    } else {
+      state.couponId = couponId;
+    }
+  }
+
+  // 确认优惠劵
+  const onConfirm = () => {
+    emits('confirm', state.couponId);
+  };
+</script>
+<style lang="scss" scoped>
+  :deep() {
+    .uni-checkbox-input {
+      background-color: var(--ui-BG-Main);
+    }
+  }
+
+  .model-box {
+    height: 60vh;
+  }
+
+  .title {
+    font-size: 36rpx;
+    height: 80rpx;
+    font-weight: bold;
+    color: #333333;
+  }
+
+  .subtitle {
+    font-size: 26rpx;
+    font-weight: 500;
+    color: #333333;
+  }
+
+  .model-content {
+    height: 54vh;
+  }
+
+  .modal-footer {
+    width: 100%;
+    height: 120rpx;
+    background: #fff;
+  }
+
+  .confirm-btn {
+    width: 710rpx;
+    margin-left: 20rpx;
+    height: 80rpx;
+    background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
+    border-radius: 40rpx;
+    color: #fff;
+  }
+
+  .reason-title {
+    font-weight: 600;
+    font-size: 20rpx;
+    line-height: 26rpx;
+    color: #ff0003;
+  }
+
+  .reason-desc {
+    font-weight: 600;
+    font-size: 20rpx;
+    line-height: 26rpx;
+    color: #434343;
+  }
+</style>

+ 67 - 0
common/components/s-custom-navbar/components/navbar-item.vue

@@ -0,0 +1,67 @@
+<!-- 顶部导航栏 - 单元格 -->
+<template>
+  <view class="ss-flex ss-col-center">
+    <!-- 类型一: 文字 -->
+    <view
+      v-if="data.type === 'text'"
+      class="nav-title inline"
+      :style="[{ color: data.textColor, width: width }]"
+      @tap="sheep.$router.go(data.url)"
+    >
+      {{ data.text }}
+    </view>
+    <!-- 类型二: 图片 -->
+    <view
+      v-if="data.type === 'image'"
+      :style="[{ width: width }]"
+      class="menu-icon-wrap ss-flex ss-row-center ss-col-center"
+      @tap="sheep.$router.go(data.url)"
+    >
+      <image class="nav-image" :src="sheep.$url.cdn(data.imgUrl)" mode="aspectFit"></image>
+    </view>
+    <!-- 类型三: 搜索框 -->
+    <view class="ss-flex-1" v-if="data.type === 'search'" :style="[{ width: width }]">
+      <s-search-block
+        :placeholder="data.placeholder || '搜索关键字'"
+        :radius="data.borderRadius"
+        elBackground="#fff"
+        :height="height"
+        :width="width"
+        @click="sheep.$router.go('/pages/index/search')"
+      ></s-search-block>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  import sheep from '@/common';
+  import { computed } from 'vue';
+
+  // 接收参数
+  const props = defineProps({
+    data: {
+      type: Object,
+      default: () => ({}),
+    },
+    width: {
+      type: String,
+      default: '1px',
+    },
+  });
+
+  const height = computed(() => sheep.$platform.capsule.height);
+</script>
+
+<style lang="scss" scoped>
+  .nav-title {
+    font-size: 36rpx;
+    color: #333;
+    text-align: center;
+  }
+
+  .menu-icon-wrap {
+    .nav-image {
+      height: 24px;
+    }
+  }
+</style>

+ 314 - 0
common/components/s-custom-navbar/components/navbar.vue

@@ -0,0 +1,314 @@
+<template>
+  <su-fixed
+    :noFixed="props.noFixed"
+    :alway="props.alway"
+    :bgStyles="props.bgStyles"
+    :val="0"
+    :index="props.zIndex"
+    noNav
+    :bg="props.bg"
+    :ui="props.ui"
+    :opacity="props.opacity"
+    :placeholder="props.placeholder"
+    :sticky="props.sticky"
+  >
+    <su-status-bar />
+    <!-- 
+      :class="[{ 'border-bottom': !props.opacity && props.bg != 'bg-none' }]"
+     -->
+    <view class="ui-navbar-box">
+      <view
+        class="ui-bar"
+        :class="
+          props.status == '' ? `text-a` : props.status == 'light' ? 'text-white' : 'text-black'
+        "
+        :style="[{ height: (sys_navBar - sys_statusBar)*2 + 'rpx' }]"
+      >
+        <slot name="item"></slot>
+        <view class="right">
+          <!-- #ifdef MP -->
+          <view :style="[state.capsuleStyle]"></view>
+          <!-- #endif -->
+        </view>
+      </view>
+    </view>
+  </su-fixed>
+</template>
+
+<script setup>
+  /**
+   * 标题栏 - 基础组件navbar
+   *
+   * @param {Number}  zIndex = 100  							- 层级
+   * @param {Boolean}  back = true 							- 是否返回上一页
+   * @param {String}  backtext = ''  							- 返回文本
+   * @param {String}  bg = 'bg-white'  						- 公共Class
+   * @param {String}  status = ''  							- 状态栏颜色
+   * @param {Boolean}  alway = true							- 是否常驻
+   * @param {Boolean}  opacity = false  						- 是否开启透明渐变
+   * @param {Boolean}  opacityBg = false  					- 开启滑动渐变后,返回按钮是否添加背景
+   * @param {Boolean}  noFixed = false  						- 是否浮动
+   * @param {String}  ui = ''									- 公共Class
+   * @param {Boolean}  capsule = false  						- 是否开启胶囊返回
+   * @param {Boolean}  stopBack = false 					    - 是否禁用返回
+   * @param {Boolean}  placeholder = true 					- 是否开启占位
+   * @param {Object}   bgStyles = {} 					    	- 背景样式
+   *
+   */
+
+  import { computed, reactive, onBeforeMount } from 'vue';
+  import sheep from '@/common';
+
+  // 本地数据
+  const state = reactive({
+    statusCur: '',
+    capsuleStyle: {},
+    capsuleBack: {},
+  });
+
+  const sys_statusBar = sheep.$platform.device.statusBarHeight;
+  const sys_navBar = sheep.$platform.navbar;
+
+  const props = defineProps({
+    sticky: Boolean,
+    zIndex: {
+      type: Number,
+      default: 100,
+    },
+    back: {
+      //是否返回上一页
+      type: Boolean,
+      default: true,
+    },
+    backtext: {
+      //返回文本
+      type: String,
+      default: '',
+    },
+    bg: {
+      type: String,
+      default: 'bg-white',
+    },
+    status: {
+      //状态栏颜色 可以选择light dark/其他字符串视为黑色
+      type: String,
+      default: '',
+    },
+    // 常驻
+    alway: {
+      type: Boolean,
+      default: true,
+    },
+    opacity: {
+      //是否开启滑动渐变
+      type: Boolean,
+      default: false,
+    },
+    opacityBg: {
+      //开启滑动渐变后 返回按钮是否添加背景
+      type: Boolean,
+      default: false,
+    },
+    noFixed: {
+      //是否浮动
+      type: Boolean,
+      default: false,
+    },
+    ui: {
+      type: String,
+      default: '',
+    },
+    capsule: {
+      //是否开启胶囊返回
+      type: Boolean,
+      default: false,
+    },
+    stopBack: {
+      type: Boolean,
+      default: false,
+    },
+    placeholder: {
+      type: [Boolean],
+      default: true,
+    },
+    bgStyles: {
+      type: Object,
+      default() {},
+    },
+  });
+
+  const emits = defineEmits(['navback']);
+
+  onBeforeMount(() => {
+    init();
+  });
+
+  // 返回
+  const onNavback = () => {
+    sheep.$router.back();
+  };
+
+  // 初始化
+  const init = () => {
+    state.capsuleStyle = {
+      width: sheep.$platform.capsule.width + 'px',
+      height: sheep.$platform.capsule.height + 'px',
+      margin: '0 ' + (sheep.$platform.device.windowWidth - sheep.$platform.capsule.right) + 'px',
+    };
+
+    state.capsuleBack = state.capsuleStyle;
+  };
+</script>
+
+<style lang="scss" scoped>
+  .ui-navbar-box {
+    background-color: transparent;
+    width: 750rpx;
+    margin: 0 auto;
+    .ui-bar {
+      position: relative;
+      z-index: 2;
+      white-space: nowrap;
+      display: flex;
+      position: relative;
+      align-items: center;
+      justify-content: space-between;
+
+      .left {
+        @include flex-bar;
+
+        .back {
+          @include flex-bar;
+
+          .back-icon {
+            @include flex-center;
+            width: 56rpx;
+            height: 56rpx;
+            margin: 0 10rpx;
+            font-size: 46rpx !important;
+
+            &.opacityIcon {
+              position: relative;
+              border-radius: 50%;
+              background-color: rgba(127, 127, 127, 0.5);
+
+              &::after {
+                content: '';
+                display: block;
+                position: absolute;
+                height: 200%;
+                width: 200%;
+                left: 0;
+                top: 0;
+                border-radius: inherit;
+                transform: scale(0.5);
+                transform-origin: 0 0;
+                opacity: 0.1;
+                border: 1px solid currentColor;
+                pointer-events: none;
+              }
+
+              &::before {
+                transform: scale(0.9);
+              }
+            }
+          }
+
+          /* #ifdef  MP-ALIPAY */
+          ._icon-back {
+            opacity: 0;
+          }
+
+          /* #endif */
+        }
+
+        .capsule {
+          @include flex-bar;
+          border-radius: 100px;
+          position: relative;
+
+          &.dark {
+            background-color: rgba(255, 255, 255, 0.5);
+          }
+
+          &.light {
+            background-color: rgba(0, 0, 0, 0.15);
+          }
+
+          &::after {
+            content: '';
+            display: block;
+            position: absolute;
+            height: 60%;
+            width: 1px;
+            left: 50%;
+            top: 20%;
+            background-color: currentColor;
+            opacity: 0.1;
+            pointer-events: none;
+          }
+
+          &::before {
+            content: '';
+            display: block;
+            position: absolute;
+            height: 200%;
+            width: 200%;
+            left: 0;
+            top: 0;
+            border-radius: inherit;
+            transform: scale(0.5);
+            transform-origin: 0 0;
+            opacity: 0.1;
+            border: 1px solid currentColor;
+            pointer-events: none;
+          }
+
+          .capsule-back,
+          .capsule-home {
+            @include flex-center;
+            flex: 1;
+          }
+
+          &.isFristPage {
+            .capsule-back,
+            &::after {
+              display: none;
+            }
+          }
+        }
+      }
+
+      .right {
+        @include flex-bar;
+
+        .right-content {
+          @include flex;
+          flex-direction: row-reverse;
+        }
+      }
+
+      .center {
+        @include flex-center;
+        text-overflow: ellipsis;
+        text-align: center;
+        flex: 1;
+
+        .image {
+          display: block;
+          height: 36px;
+          max-width: calc(100vw - 200px);
+        }
+      }
+    }
+
+    .ui-bar-bg {
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      top: 0;
+      z-index: 1;
+      pointer-events: none;
+    }
+  }
+</style>

+ 207 - 0
common/components/s-custom-navbar/s-custom-navbar.vue

@@ -0,0 +1,207 @@
+<!-- 顶部导航栏 -->
+<template>
+  <navbar
+    :alway="isAlways"
+    :back="false"
+    bg=""
+    :placeholder="isPlaceholder"
+    :bgStyles="bgStyles"
+    :opacity="isOpacity"
+    :sticky="sticky"
+  >
+    <template #item>
+      <view class="nav-box">
+        <view class="nav-icon" v-if="showLeftButton">
+          <view class="icon-box ss-flex" :class="{ 'inner-icon-box': data.styleType === 'inner' }">
+            <view class="icon-button icon-button-left ss-flex ss-row-center" @tap="onClickLeft">
+              <text class="sicon-back" v-if="hasHistory" />
+              <text class="sicon-home" v-else />
+            </view>
+            <view class="line"></view>
+            <view class="icon-button icon-button-right ss-flex ss-row-center" @tap="onClickRight">
+              <text class="sicon-more" />
+            </view>
+          </view>
+        </view>
+        <view
+          class="nav-item"
+          v-for="(item, index) in navList"
+          :key="index"
+          :style="[parseImgStyle(item)]"
+          :class="[{ 'ss-flex ss-col-center ss-row-center': item.type !== 'search' }]"
+        >
+          <navbar-item :data="item" :width="parseImgStyle(item).width" />
+        </view>
+      </view>
+    </template>
+  </navbar>
+</template>
+
+<script setup>
+  /**
+   *  装修组件 - 自定义标题栏
+   *
+   *
+   * @property {Number | String}  alwaysShow = [0,1]			    - 是否常驻
+   * @property {Number | String}  styleType = [inner]			   	- 是否沉浸式
+   * @property {String | Number} type              - 标题背景模式
+   * @property {String} color                  - 页面背景色
+   * @property {String} src                    - 页面背景图片
+   */
+  import { computed, unref } from 'vue';
+  import sheep from '@/common';
+  import Navbar from './components/navbar.vue';
+  import NavbarItem from './components/navbar-item.vue';
+  import { showMenuTools } from '@/common/hooks/useModal';
+
+  const props = defineProps({
+    data: {
+      type: Object,
+      default: () => ({}),
+    },
+    showLeftButton: {
+      type: Boolean,
+      default: false,
+    },
+  });
+  const hasHistory = sheep.$router.hasHistory();
+  const sticky = computed(() => {
+    if (props.data.styleType === 'inner') {
+      if (props.data.alwaysShow) {
+        return false;
+      }
+    }
+    if (props.data.styleType === 'normal') {
+      return false;
+    }
+  });
+  const navList = computed(() => {
+    // #ifdef MP
+    return props.data.mpCells || [];
+    // #endif
+    return props.data.otherCells || [];
+  });
+  // 页面宽度
+  const windowWidth = sheep.$platform.device.windowWidth;
+  // 单元格宽度
+  const cell = computed(() => {
+    if (unref(navList).length) {
+      // 默认宽度为8个格子,微信公众号右上角有胶囊按钮所以是6个格子
+      let cell = (windowWidth - 90) / 8;
+      // #ifdef MP
+      cell = (windowWidth - 80 - unref(sheep.$platform.capsule).width) / 6;
+      // #endif
+      return cell;
+    }
+  });
+  // 解析位置
+  const parseImgStyle = (item) => {
+    let obj = {
+      width: item.width * cell.value + (item.width - 1) * 10 + 'px',
+      left: item.left * cell.value + (item.left + 1) * 10 + 'px',
+      'border-radius': item.borderRadius + 'px',
+    };
+    return obj;
+  };
+  const isAlways = computed(() =>
+    props.data.styleType === 'inner' ? Boolean(props.data.alwaysShow) : true,
+  );
+  const isOpacity = computed(() =>
+    props.data.styleType === 'normal'
+      ? false
+      : props.showLeftButton
+      ? false
+      : props.data.styleType === 'inner',
+  );
+  const isPlaceholder = computed(() => props.data.styleType === 'normal');
+  const bgStyles = computed(() => {
+    return {
+      background:
+        props.data.bgType === 'img' && props.data.bgImg
+          ? `url(${sheep.$url.cdn(props.data.bgImg)}) no-repeat top center / 100% 100%`
+          : props.data.bgColor,
+    };
+  });
+
+  // 左侧按钮:返回上一页或首页
+  function onClickLeft() {
+    if (hasHistory) {
+      sheep.$router.back();
+    } else {
+      sheep.$router.go('/pages/index/index');
+    }
+  }
+
+  // 右侧按钮:打开快捷菜单
+  function onClickRight() {
+    showMenuTools();
+  }
+</script>
+
+<style lang="scss" scoped>
+  .nav-box {
+    width: 750rpx;
+    position: relative;
+    height: 100%;
+
+    .nav-item {
+      position: absolute;
+      top: 50%;
+      transform: translateY(-50%);
+    }
+
+    .nav-icon {
+      position: absolute;
+      top: 50%;
+      transform: translateY(-50%);
+      left: 20rpx;
+
+      .inner-icon-box {
+        border: 1px solid rgba(#fff, 0.4);
+        background: none !important;
+      }
+
+      .icon-box {
+        background: #ffffff;
+        box-shadow: 0px 0px 4rpx rgba(51, 51, 51, 0.08), 0px 4rpx 6rpx 2rpx rgba(102, 102, 102, 0.12);
+        border-radius: 30rpx;
+        width: 134rpx;
+        height: 56rpx;
+        margin-left: 8rpx;
+
+        .line {
+          width: 2rpx;
+          height: 24rpx;
+          background: #e5e5e7;
+        }
+
+        .sicon-back {
+          font-size: 32rpx;
+        }
+
+        .sicon-home {
+          font-size: 32rpx;
+        }
+
+        .sicon-more {
+          font-size: 32rpx;
+        }
+
+        .icon-button {
+          width: 67rpx;
+          height: 56rpx;
+
+          &-left:hover {
+            background: rgba(0, 0, 0, 0.16);
+            border-radius: 30rpx 0px 0px 30rpx;
+          }
+
+          &-right:hover {
+            background: rgba(0, 0, 0, 0.16);
+            border-radius: 0px 30rpx 30rpx 0px;
+          }
+        }
+      }
+    }
+  }
+</style>

+ 96 - 0
common/components/s-discount-list/s-discount-list.vue

@@ -0,0 +1,96 @@
+<template>
+  <su-popup
+    :show="show"
+    type="bottom"
+    round="20"
+    @close="emits('close')"
+    showClose
+    backgroundColor="#f2f2f2"
+  >
+    <view class="model-box">
+      <view class="title ss-m-t-38 ss-m-l-20 ss-m-b-40">活动优惠</view>
+      <scroll-view
+        class="model-content ss-m-l-20"
+        scroll-y
+        :scroll-with-animation="false"
+        :enable-back-to-top="true"
+      >
+        <view v-for="(item, index) in state.orderInfo.promotions" :key="index">
+          <!-- 不展示积分、优惠劵、会员折扣,因为它们已经单独展示了 -->
+          <view class="ss-flex ss-m-b-40 subtitle" v-if="[1, 2, 3, 4, 5].includes(item.type)">
+            <view> {{ item.description }} </view>
+          </view>
+        </view>
+      </scroll-view>
+    </view>
+    <view class="modal-footer ss-flex">
+      <button class="confirm-btn ss-reset-button" @tap="emits('close')">确认</button>
+    </view>
+  </su-popup>
+</template>
+<script setup>
+  import { computed, reactive } from 'vue';
+  const props = defineProps({
+    promoInfo: {
+      type: Array,
+      default: () => [],
+    },
+    goodsList: {
+      type: Array,
+      default: () => [],
+    },
+    modelValue: {
+      type: Object,
+      default() {},
+    },
+    show: {
+      type: Boolean,
+      default: false,
+    },
+  });
+  const emits = defineEmits(['close']);
+  const state = reactive({
+    orderInfo: computed(() => props.modelValue),
+  });
+</script>
+<style lang="scss" scoped>
+  .model-box {
+    height: 60vh;
+  }
+
+  .model-content {
+    height: 54vh;
+  }
+
+  .modal-footer {
+    width: 100%;
+    height: 120rpx;
+    background: #fff;
+  }
+
+  .confirm-btn {
+    width: 710rpx;
+    margin-left: 20rpx;
+    height: 80rpx;
+    background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
+    border-radius: 40rpx;
+    color: #fff;
+  }
+
+  .content-img {
+    width: 140rpx;
+    height: 140rpx;
+    margin-right: 20rpx;
+    margin-bottom: 20rpx;
+  }
+
+  .subtitle {
+    font-size: 28rpx;
+    font-weight: 500;
+    color: #333333;
+  }
+
+  .price-text {
+    color: #ff3000;
+  }
+</style>

+ 93 - 0
common/components/s-empty/s-empty.vue

@@ -0,0 +1,93 @@
+<template>
+  <view
+    class="ss-flex-col ss-col-center ss-row-center empty-box"
+    :style="[{ paddingTop: paddingTop + 'rpx' }]"
+  >
+    <view class=""><image class="empty-icon" :src="icon" mode="widthFix"></image></view>
+    <view class="empty-text ss-m-t-28 ss-m-b-40">
+      <text v-if="text !== ''">{{ text }}</text>
+    </view>
+    <button class="ss-reset-button empty-btn" v-if="showAction" @tap="clickAction">
+      {{ actionText }}
+    </button>
+  </view>
+</template>
+
+<script setup>
+  import sheep from '@/common';
+  /**
+   * 容器组件 - 装修组件的样式容器
+   */
+
+  const props = defineProps({
+    // 图标
+    icon: {
+      type: String,
+      default: '',
+    },
+    // 描述
+    text: {
+      type: String,
+      default: '',
+    },
+    // 是否显示button
+    showAction: {
+      type: Boolean,
+      default: false,
+    },
+    // button 文字
+    actionText: {
+      type: String,
+      default: '',
+    },
+    // 链接
+    actionUrl: {
+      type: String,
+      default: '',
+    },
+    // 间距
+    paddingTop: {
+      type: String,
+      default: '260',
+    },
+    //主题色
+    buttonColor: {
+      type: String,
+      default: 'var(--ui-BG-Main)',
+    },
+  });
+
+  const emits = defineEmits(['clickAction']);
+
+  function clickAction() {
+    if (props.actionUrl !== '') {
+      sheep.$router.go(props.actionUrl);
+    }
+    emits('clickAction');
+  }
+</script>
+
+<style lang="scss" scoped>
+  .empty-box {
+    width: 100%;
+  }
+  .empty-icon {
+    width: 240rpx;
+  }
+
+  .empty-text {
+    font-size: 26rpx;
+    font-weight: 500;
+    color: #999999;
+  }
+
+  .empty-btn {
+    width: 320rpx;
+    height: 70rpx;
+    border: 2rpx solid v-bind('buttonColor');
+    border-radius: 35rpx;
+    font-weight: 500;
+    color: v-bind('buttonColor');
+    font-size: 28rpx;
+  }
+</style>

+ 88 - 0
common/components/s-float-menu/s-float-menu.vue

@@ -0,0 +1,88 @@
+<!-- 装修基础组件:悬浮按钮 -->
+<template>
+  <!-- 模态背景:展开时显示,点击后折叠 -->
+  <view class="modal-bg" v-if="fabRef?.isShow" @click="handleCollapseFab"></view>
+  <!-- 悬浮按钮 -->
+  <uni-fab
+    ref="fabRef"
+    horizontal="right"
+    vertical="bottom"
+    :direction="state.direction"
+    :pattern="state.pattern"
+    :content="state.content"
+    @trigger="handleOpenLink"
+  />
+</template>
+<script setup>
+  /**
+   * 悬浮按钮
+   */
+
+  import sheep from '@/common';
+  import { reactive, ref, unref } from 'vue';
+  import { onBackPress } from '@dcloudio/uni-app';
+
+  // 定义属性
+  const props = defineProps({
+    data: {
+      type: Object,
+      default() {},
+    }
+  })
+
+  // 悬浮按钮配置: https://uniapp.dcloud.net.cn/component/uniui/uni-fab.html#fab-props
+  const state = reactive({
+    // 可选样式配置项
+    pattern: [],
+    // 展开菜单内容配置项
+    content: [],
+    // 展开菜单显示方式:horizontal-水平显示,vertical-垂直显示
+    direction: '',
+  });
+
+  // 悬浮按钮引用
+  const fabRef = ref(null);
+  // 按钮方向
+  state.direction = props.data.direction;
+  props.data?.list.forEach((item) => {
+    // 按钮文字
+    const text = props.data?.showText ? item.text : ''
+    // 生成内容配置项
+    state.content.push({ iconPath: sheep.$url.cdn(item.imgUrl), url: item.url, text });
+    // 生成样式配置项
+    state.pattern.push({ color: item.textColor });
+  });
+
+  // 处理链接跳转
+  function handleOpenLink(e) {
+    sheep.$router.go(e.item.url);
+  }
+
+  // 折叠
+  function handleCollapseFab() {
+    if (unref(fabRef)?.isShow) {
+      unref(fabRef)?.close();
+    }
+  }
+
+  // 按返回值后,折叠悬浮按钮
+  onBackPress(() => {
+    if (unref(fabRef)?.isShow) {
+      unref(fabRef)?.close();
+      return true;
+    }
+    return false;
+  });
+</script>
+<style lang="scss" scoped>
+  /* 模态背景 */
+  .modal-bg {
+    position: fixed;
+    left: 0;
+    top: 0;
+    z-index: 11;
+    width: 100%;
+    height: 100%;
+    background-color: rgba(#000000, 0.4);
+  }
+</style>

+ 1026 - 0
common/components/s-goods-column/s-goods-column.vue

@@ -0,0 +1,1026 @@
+<!-- 页面 -->
+<template>
+  <view class="ss-goods-wrap">
+    <!-- xs卡片:横向紧凑型,一行放两个,图片左内容右边  -->
+    <view
+      v-if="size === 'xs'"
+      class="xs-goods-card ss-flex ss-col-stretch"
+      :style="[elStyles]"
+      @tap="onClick"
+    >
+      <view v-if="tagStyle.show" class="tag-icon-box">
+        <image class="tag-icon" :src="sheep.$url.cdn(tagStyle.src || tagStyle.imgUrl)"></image>
+      </view>
+      <image class="xs-img-box" :src="sheep.$url.cdn(data.image || data.picUrl)" mode="aspectFit" />
+      <view
+        v-if="goodsFields.title?.show || goodsFields.name?.show || goodsFields.price?.show"
+        class="xs-goods-content ss-flex-col ss-row-around"
+      >
+        <view
+          v-if="goodsFields.title?.show || goodsFields.name?.show"
+          class="xs-goods-title ss-line-1"
+          :style="[{ color: titleColor, width: titleWidth ? titleWidth + 'rpx' : '' }]"
+        >
+          {{ data.title || data.name }}
+        </view>
+        <!-- 活动信息 -->
+        <view class="iconBox" v-if="data.promotionType > 0 || data.rewardActivity">
+          <view class="card" v-if="discountText">{{ discountText }}</view>
+          <view
+            class="card2"
+            v-for="item in getRewardActivityRuleItemDescriptions(data.rewardActivity).slice(0, 1)"
+            :key="item"
+          >
+            {{ item }}
+          </view>
+        </view>
+        <view
+          v-if="goodsFields.price?.show"
+          class="xs-goods-price font-OPPOSANS"
+          :style="[{ color: goodsFields.price.color }]"
+        >
+          <!-- 活动价格 -->
+          <view
+            class="ss-flex"
+            v-if="data.activityType && data.activityType === PromotionActivityTypeEnum.POINT.type"
+          >
+            <image
+              :src="sheep.$url.static('/static/img/shop/goods/score1.svg')"
+              class="point-img"
+            ></image>
+            <text class="point-text ss-m-r-16">
+              {{ data.point }}
+              {{
+                !data.pointPrice || data.pointPrice === 0
+                  ? ''
+                  : `+${priceUnit}${fen2yuan(data.pointPrice)}`
+              }}
+            </text>
+          </view>
+          <template v-else>
+            <text class="price-unit ss-font-24">{{ priceUnit }}</text>
+            <text v-if="data.promotionPrice > 0">{{ fen2yuan(data.promotionPrice) }}</text>
+            <text v-else>
+              {{ isArray(data.price) ? fen2yuan(data.price[0]) : fen2yuan(data.price) }}
+            </text>
+          </template>
+        </view>
+      </view>
+    </view>
+
+    <!-- sm卡片:竖向紧凑,一行放三个,图上内容下 -->
+    <view v-if="size === 'sm'" class="sm-goods-card ss-flex-col" :style="[elStyles]" @tap="onClick">
+      <view v-if="tagStyle.show" class="tag-icon-box">
+        <image class="tag-icon" :src="sheep.$url.cdn(tagStyle.src || tagStyle.imgUrl)"></image>
+      </view>
+      <image
+        class="sm-img-box"
+        :src="sheep.$url.cdn(data.image || data.picUrl)"
+        mode="aspectFill"
+      ></image>
+
+      <view
+        v-if="goodsFields.title?.show || goodsFields.name?.show || goodsFields.price?.show"
+        class="sm-goods-content"
+        :style="[{ color: titleColor, width: titleWidth ? titleWidth + 'rpx' : '' }]"
+      >
+        <view
+          v-if="goodsFields.title?.show || goodsFields.name?.show"
+          class="sm-goods-title ss-line-1 ss-m-b-16"
+        >
+          {{ data.title || data.name }}
+        </view>
+        <!-- 活动信息 -->
+        <view class="iconBox" v-if="data.promotionType > 0 || data.rewardActivity">
+          <view class="card" v-if="discountText">{{ discountText }}</view>
+          <view
+            class="card2"
+            v-for="item in getRewardActivityRuleItemDescriptions(data.rewardActivity).slice(0, 1)"
+            :key="item"
+          >
+            {{ item }}
+          </view>
+        </view>
+        <view
+          v-if="goodsFields.price?.show"
+          class="sm-goods-price font-OPPOSANS"
+          :style="[{ color: goodsFields.price.color }]"
+        >
+          <!-- 活动价格 -->
+          <view
+            class="ss-flex"
+            v-if="data.activityType && data.activityType === PromotionActivityTypeEnum.POINT.type"
+          >
+            <image
+              :src="sheep.$url.static('/static/img/shop/goods/score1.svg')"
+              class="point-img"
+            ></image>
+            <text class="point-text ss-m-r-16">
+              {{ data.point }}
+              {{
+                !data.pointPrice || data.pointPrice === 0
+                  ? ''
+                  : `+${priceUnit}${fen2yuan(data.pointPrice)}`
+              }}
+            </text>
+          </view>
+          <template v-else>
+            <text class="price-unit ss-font-24">{{ priceUnit }}</text>
+            <text v-if="data.promotionPrice > 0">{{ fen2yuan(data.promotionPrice) }}</text>
+            <text v-else>
+              {{ isArray(data.price) ? fen2yuan(data.price[0]) : fen2yuan(data.price) }}
+            </text>
+          </template>
+        </view>
+      </view>
+    </view>
+
+    <!-- md卡片:竖向,一行放两个,图上内容下 -->
+    <view v-if="size === 'md'" class="md-goods-card ss-flex-col" :style="[elStyles]" @tap="onClick">
+      <view v-if="tagStyle.show" class="tag-icon-box">
+        <image class="tag-icon" :src="sheep.$url.cdn(tagStyle.src || tagStyle.imgUrl)" />
+      </view>
+      <image
+        v-if="data.image_wh"
+        class="md-img-box"
+        :src="sheep.$url.cdn(data.image || data.picUrl)"
+        mode="widthFix"
+      />
+      <image
+        v-else
+        class="md-img-box"
+        :src="sheep.$url.cdn(data.image || data.picUrl)"
+        :style="[{ height: defaultImgWidth * 2 + 'rpx' }]"
+        mode="aspectFill"
+      />
+      <view
+        class="md-goods-content ss-flex-col ss-row-around ss-p-b-20 ss-p-t-20 ss-p-x-16"
+        :id="elId"
+      >
+        <view
+          v-if="goodsFields.title?.show || goodsFields.name?.show"
+          class="md-goods-title ss-line-1"
+          :style="[{ color: titleColor, width: titleWidth ? titleWidth + 'rpx' : '' }]"
+        >
+          {{ data.title || data.name }}
+        </view>
+        <view
+          v-if="goodsFields.subtitle?.show || goodsFields.introduction?.show"
+          class="md-goods-subtitle ss-m-t-16 ss-line-1"
+          :style="[{ color: subTitleColor, background: subTitleBackground }]"
+        >
+          {{ data.subtitle || data.introduction }}
+        </view>
+        <slot name="activity">
+          <view v-if="data.promos?.length" class="tag-box ss-flex-wrap ss-flex ss-col-center">
+            <view
+              class="activity-tag ss-m-r-10 ss-m-t-16"
+              v-for="item in data.promos"
+              :key="item.id"
+            >
+              {{ item.title }}
+            </view>
+          </view>
+        </slot>
+        <!-- 活动信息 -->
+        <view class="iconBox" v-if="data.promotionType > 0 || data.rewardActivity">
+          <view class="card" v-if="discountText">{{ discountText }}</view>
+          <view
+            class="card2"
+            v-for="item in getRewardActivityRuleItemDescriptions(data.rewardActivity).slice(0, 1)"
+            :key="item"
+          >
+            {{ item }}
+          </view>
+        </view>
+        <view class="ss-flex ss-col-bottom">
+          <view
+            v-if="goodsFields.price?.show"
+            class="md-goods-price ss-m-t-16 font-OPPOSANS ss-m-r-10"
+            :style="[{ color: goodsFields.price.color }]"
+          >
+            <!-- 活动价格 -->
+            <view
+              class="ss-flex"
+              v-if="data.activityType && data.activityType === PromotionActivityTypeEnum.POINT.type"
+            >
+              <image
+                :src="sheep.$url.static('/static/img/shop/goods/score1.svg')"
+                class="point-img"
+              ></image>
+              <text class="point-text ss-m-r-16">
+                {{ data.point }}
+                {{
+                  !data.pointPrice || data.pointPrice === 0
+                    ? ''
+                    : `+${priceUnit}${fen2yuan(data.pointPrice)}`
+                }}
+              </text>
+            </view>
+            <template v-else>
+              <text class="price-unit ss-font-24">{{ priceUnit }}</text>
+              <text v-if="data.promotionPrice > 0">{{ fen2yuan(data.promotionPrice) }}</text>
+              <text v-else>
+                {{ isArray(data.price) ? fen2yuan(data.price[0]) : fen2yuan(data.price) }}
+              </text>
+            </template>
+          </view>
+          <view
+            v-if="
+              (goodsFields.original_price?.show || goodsFields.marketPrice?.show) &&
+              (data.original_price > 0 || data.marketPrice > 0)
+            "
+            class="goods-origin-price ss-m-t-16 font-OPPOSANS ss-flex"
+            :style="[{ color: originPriceColor }]"
+          >
+            <text class="price-unit ss-font-20">{{ priceUnit }}</text>
+            <view class="ss-m-l-8">{{ fen2yuan(data.marketPrice) }}</view>
+          </view>
+        </view>
+
+        <view class="ss-m-t-16 ss-flex ss-col-center ss-flex-wrap">
+          <view class="sales-text">{{ salesAndStock }}</view>
+        </view>
+      </view>
+
+      <slot name="cart">
+        <view class="cart-box ss-flex ss-col-center ss-row-center">
+          <image class="cart-icon" src="/static/img/shop/tabbar/category2.png" mode="" />
+        </view>
+      </slot>
+    </view>
+
+    <!-- lg卡片:横向型,一行放一个,图片左内容右边  -->
+    <view
+      v-if="size === 'lg'"
+      class="lg-goods-card ss-flex ss-col-stretch"
+      :style="[elStyles]"
+      @tap="onClick"
+    >
+      <view v-if="tagStyle.show" class="tag-icon-box">
+        <image class="tag-icon" :src="sheep.$url.cdn(tagStyle.src || tagStyle.imgUrl)"></image>
+      </view>
+      <view v-if="seckillTag" class="seckill-tag ss-flex ss-row-center">秒杀</view>
+      <view v-if="grouponTag" class="groupon-tag ss-flex ss-row-center">
+        <view class="tag-icon">拼团</view>
+      </view>
+      <image
+        class="lg-img-box"
+        :src="sheep.$url.cdn(data.image || data.picUrl)"
+        mode="aspectFill"
+      />
+      <view class="lg-goods-content ss-flex-1 ss-flex-col ss-row-between ss-p-b-10 ss-p-t-20">
+        <view>
+          <view
+            v-if="goodsFields.title?.show || goodsFields.name?.show"
+            class="lg-goods-title ss-line-2"
+            :style="[{ color: titleColor }]"
+          >
+            {{ data.title || data.name }}
+          </view>
+          <view
+            v-if="goodsFields.subtitle?.show || goodsFields.introduction?.show"
+            class="lg-goods-subtitle ss-m-t-10 ss-line-1"
+            :style="[{ color: subTitleColor, background: subTitleBackground }]"
+          >
+            {{ data.subtitle || data.introduction }}
+          </view>
+        </view>
+        <view>
+          <slot name="activity">
+            <view v-if="data.promos?.length" class="tag-box ss-flex ss-col-center">
+              <view class="activity-tag ss-m-r-10" v-for="item in data.promos" :key="item.id">
+                {{ item.title }}
+              </view>
+            </view>
+          </slot>
+          <!-- 活动信息 -->
+          <view class="iconBox" v-if="data.promotionType > 0 || data.rewardActivity">
+            <view class="card" v-if="discountText">{{ discountText }}</view>
+            <view
+              class="card2"
+              v-for="item in getRewardActivityRuleItemDescriptions(data.rewardActivity).slice(0, 1)"
+              :key="item"
+            >
+              {{ item }}
+            </view>
+          </view>
+          <view v-if="goodsFields.price?.show" class="ss-flex ss-col-bottom font-OPPOSANS">
+            <view class="sl-goods-price ss-m-r-12" :style="[{ color: goodsFields.price.color }]">
+              <!-- 活动价格 -->
+              <view
+                class="ss-flex"
+                v-if="
+                  data.activityType && data.activityType === PromotionActivityTypeEnum.POINT.type
+                "
+              >
+                <image
+                  :src="sheep.$url.static('/static/img/shop/goods/score1.svg')"
+                  class="point-img"
+                ></image>
+                <text class="point-text ss-m-r-16">
+                  {{ data.point }}
+                  {{
+                    !data.pointPrice || data.pointPrice === 0
+                      ? ''
+                      : `+${priceUnit}${fen2yuan(data.pointPrice)}`
+                  }}
+                </text>
+              </view>
+              <template v-else>
+                <text class="price-unit ss-font-24">{{ priceUnit }}</text>
+                <text v-if="data.promotionPrice > 0">{{ fen2yuan(data.promotionPrice) }}</text>
+                <text v-else>
+                  {{ isArray(data.price) ? fen2yuan(data.price[0]) : fen2yuan(data.price) }}
+                </text>
+              </template>
+            </view>
+            <view
+              v-if="
+                (goodsFields.original_price?.show || goodsFields.marketPrice?.show) &&
+                (data.original_price > 0 || data.marketPrice > 0)
+              "
+              class="goods-origin-price ss-m-t-16 font-OPPOSANS ss-flex"
+              :style="[{ color: originPriceColor }]"
+            >
+              <text class="price-unit ss-font-20">{{ priceUnit }}</text>
+              <view class="ss-m-l-8">{{ fen2yuan(data.marketPrice) }}</view>
+            </view>
+          </view>
+          <view class="ss-m-t-8 ss-flex ss-col-center ss-flex-wrap">
+            <view class="sales-text">{{ salesAndStock }}</view>
+          </view>
+        </view>
+      </view>
+
+      <slot name="cart">
+        <view class="buy-box ss-flex ss-col-center ss-row-center" v-if="buttonShow"> 去购买</view>
+      </slot>
+    </view>
+
+    <!-- sl卡片:竖向型,一行放一个,图片上内容下边 -->
+    <view v-if="size === 'sl'" class="sl-goods-card ss-flex-col" :style="[elStyles]" @tap="onClick">
+      <view v-if="tagStyle.show" class="tag-icon-box">
+        <image class="tag-icon" :src="sheep.$url.cdn(tagStyle.src || tagStyle.imgUrl)" />
+      </view>
+      <image
+        class="sl-img-box"
+        :src="sheep.$url.cdn(data.image || data.picUrl)"
+        mode="aspectFill"
+      />
+      <view class="sl-goods-content">
+        <view>
+          <view
+            v-if="goodsFields.title?.show || goodsFields.name?.show"
+            class="sl-goods-title ss-line-1"
+            :style="[{ color: titleColor }]"
+          >
+            {{ data.title || data.name }}
+          </view>
+          <view
+            v-if="goodsFields.subtitle?.show || goodsFields.introduction?.show"
+            class="sl-goods-subtitle ss-m-t-16"
+            :style="[{ color: subTitleColor, background: subTitleBackground }]"
+          >
+            {{ data.subtitle || data.introduction }}
+          </view>
+        </view>
+        <view>
+          <slot name="activity">
+            <view v-if="data.promos?.length" class="tag-box ss-flex ss-col-center ss-flex-wrap">
+              <view
+                class="activity-tag ss-m-r-10 ss-m-t-16"
+                v-for="item in data.promos"
+                :key="item.id"
+              >
+                {{ item.title }}
+              </view>
+            </view>
+          </slot>
+          <!-- 活动信息 -->
+          <view class="iconBox" v-if="data.promotionType > 0 || data.rewardActivity">
+            <view class="card" v-if="discountText">{{ discountText }}</view>
+            <view
+              class="card2"
+              v-for="item in getRewardActivityRuleItemDescriptions(data.rewardActivity).slice(0, 1)"
+              :key="item"
+            >
+              {{ item }}
+            </view>
+          </view>
+          <view v-if="goodsFields.price?.show" class="ss-flex ss-col-bottom font-OPPOSANS">
+            <view class="sl-goods-price ss-m-r-12" :style="[{ color: goodsFields.price.color }]">
+              <!-- 活动价格 -->
+              <view
+                class="ss-flex"
+                v-if="
+                  data.activityType && data.activityType === PromotionActivityTypeEnum.POINT.type
+                "
+              >
+                <image
+                  :src="sheep.$url.static('/static/img/shop/goods/score1.svg')"
+                  class="point-img"
+                ></image>
+                <text class="ss-m-r-16">
+                  {{ data.point }}
+                  {{
+                    !data.pointPrice || data.pointPrice === 0
+                      ? ''
+                      : `+${priceUnit}${fen2yuan(data.pointPrice)}`
+                  }}
+                </text>
+              </view>
+              <template v-else>
+                <text class="price-unit ss-font-24">{{ priceUnit }}</text>
+                <text v-if="data.promotionPrice > 0">{{ fen2yuan(data.promotionPrice) }}</text>
+                <text v-else>
+                  {{ isArray(data.price) ? fen2yuan(data.price[0]) : fen2yuan(data.price) }}
+                </text>
+              </template>
+            </view>
+            <view
+              v-if="
+                (goodsFields.original_price?.show || goodsFields.marketPrice?.show) &&
+                (data.original_price > 0 || data.marketPrice > 0)
+              "
+              class="goods-origin-price ss-m-t-16 font-OPPOSANS ss-flex"
+              :style="[{ color: originPriceColor }]"
+            >
+              <text class="price-unit ss-font-20">{{ priceUnit }}</text>
+              <view class="ss-m-l-8">{{ fen2yuan(data.marketPrice) }}</view>
+            </view>
+          </view>
+          <view class="ss-m-t-16 ss-flex ss-flex-wrap">
+            <view class="sales-text">{{ salesAndStock }}</view>
+          </view>
+        </view>
+      </view>
+
+      <slot name="cart">
+        <view class="buy-box ss-flex ss-col-center ss-row-center">去购买</view>
+      </slot>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  /**
+   * 商品卡片
+   *
+   * @property {Array} size = [xs | sm | md | lg | sl ] 			 	- 列表数据
+   * @property {String} tag                      - md及以上才有
+   * @property {String} img                      - 图片
+   * @property {String} background                  - 背景色
+   * @property {String} topRadius                  - 上圆角
+   * @property {String} bottomRadius                  - 下圆角
+   * @property {String} title                    - 标题
+   * @property {String} titleColor                  - 标题颜色
+   * @property {Number} titleWidth = 0								- 标题宽度,默认0,单位rpx
+   * @property {String} subTitle                    - 副标题
+   * @property {String} subTitleColor                  - 副标题颜色
+   * @property {String} subTitleBackground              - 副标题背景
+   * @property {String | Number} price                - 价格
+   * @property {String} priceColor                  - 价格颜色
+   * @property {String | Number} originPrice              - 原价/划线价
+   * @property {String} originPriceColor                - 原价颜色
+   * @property {String | Number} sales                - 销售数量
+   * @property {String} salesColor                  - 销售数量颜色
+   *
+   * @slots activity												 	- 活动插槽
+   * @slots cart														- 购物车插槽,默认包含文字,背景色,文字颜色 || 图片 || 行为
+   *
+   * @event {Function()} click                    - 点击卡片
+   *
+   */
+  import { computed, ref, reactive, getCurrentInstance, nextTick, onMounted } from 'vue';
+  import sheep from '@/common';
+  import {
+    fen2yuan,
+    formatExchange,
+    formatSales,
+    formatStock,
+    getRewardActivityRuleItemDescriptions,
+  } from '@/common/hooks/useGoods';
+  import { isArray } from 'lodash-es';
+  import { PromotionActivityTypeEnum } from '@/common/helper/const';
+
+  // 数据
+  let defaultImgWidth = ref(0);
+
+  // 接收参数
+  const props = defineProps({
+    goodsFields: {
+      type: [Array, Object],
+      default() {
+        return {
+          // 商品价格
+          price: {
+            show: true,
+          },
+          // 库存
+          stock: {
+            show: true,
+          },
+          // 商品名称
+          name: {
+            show: true,
+          },
+          // 商品介绍
+          introduction: {
+            show: true,
+          },
+          // 市场价
+          marketPrice: {
+            show: true,
+          },
+          // 销量
+          salesCount: {
+            show: true,
+          },
+        };
+      },
+    },
+    tagStyle: {
+      type: Object,
+      default: () => ({}),
+    },
+    data: {
+      type: Object,
+      default: () => ({}),
+    },
+    size: {
+      type: String,
+      default: 'sl',
+    },
+    background: {
+      type: String,
+      default: '',
+    },
+    topRadius: {
+      type: Number,
+      default: 0,
+    },
+    bottomRadius: {
+      type: Number,
+      default: 0,
+    },
+    titleWidth: {
+      type: Number,
+      default: 0,
+    },
+    titleColor: {
+      type: String,
+      default: '#333',
+    },
+    priceColor: {
+      type: String,
+      default: '',
+    },
+    originPriceColor: {
+      type: String,
+      default: '#C4C4C4',
+    },
+    priceUnit: {
+      type: String,
+      default: '¥',
+    },
+    subTitleColor: {
+      type: String,
+      default: '#999999',
+    },
+    subTitleBackground: {
+      type: String,
+      default: '',
+    },
+    buttonShow: {
+      type: Boolean,
+      default: true,
+    },
+    seckillTag: {
+      type: Boolean,
+      default: false,
+    },
+    grouponTag: {
+      type: Boolean,
+      default: false,
+    },
+  });
+
+  // 优惠文案
+  const discountText = computed(() => {
+    const promotionType = props.data.promotionType;
+    if (promotionType === 4) {
+      return '限时优惠';
+    } else if (promotionType === 6) {
+      return '会员价';
+    }
+    return undefined;
+  });
+
+  // 组件样式
+  const elStyles = computed(() => {
+    return {
+      background: props.background,
+      'border-top-left-radius': props.topRadius + 'px',
+      'border-top-right-radius': props.topRadius + 'px',
+      'border-bottom-left-radius': props.bottomRadius + 'px',
+      'border-bottom-right-radius': props.bottomRadius + 'px',
+    };
+  });
+
+  // 格式化销量、库存信息
+  const salesAndStock = computed(() => {
+    let text = [];
+    if (props.goodsFields.salesCount?.show) {
+      if (
+        props.data.activityType &&
+        props.data.activityType === PromotionActivityTypeEnum.POINT.type
+      ) {
+        text.push(
+          formatExchange(
+            props.data.sales_show_type,
+            (props.data.pointTotalStock || 0) - (props.data.pointStock || 0),
+          ),
+        );
+      } else {
+        text.push(formatSales(props.data.sales_show_type, props.data.salesCount));
+      }
+    }
+    if (props.goodsFields.stock?.show) {
+      if (
+        props.data.activityType &&
+        props.data.activityType === PromotionActivityTypeEnum.POINT.type
+      ) {
+        text.push(formatStock(props.data.stock_show_type, props.data.pointTotalStock));
+      } else {
+        text.push(formatStock(props.data.stock_show_type, props.data.stock));
+      }
+    }
+    return text.join(' | ');
+  });
+
+  // 返回事件
+  const emits = defineEmits(['click', 'getHeight']);
+
+  const onClick = () => {
+    emits('click');
+  };
+
+  // 获取卡片实时高度
+  const { proxy } = getCurrentInstance();
+  const elId = `sheep_${Math.ceil(Math.random() * 10e5).toString(36)}`;
+
+  function getGoodsPriceCardWH() {
+    if (props.size === 'md') {
+      const view = uni.createSelectorQuery().in(proxy);
+      view.select(`#${elId}`).fields({
+        size: true,
+        scrollOffset: true,
+      });
+      view.exec((data) => {
+        console.log(data, 'data');
+        let totalHeight = 0;
+        const goodsPriceCard = data[0];
+        defaultImgWidth.value = data[0].width;
+
+        if (props.data.image_wh && Number(props.data.image_wh.w)) {
+          totalHeight =
+            (goodsPriceCard.width / props.data.image_wh.w) * props.data.image_wh.h +
+            goodsPriceCard.height;
+        } else {
+          totalHeight = goodsPriceCard.width + goodsPriceCard.height;
+        }
+        emits('getHeight', totalHeight);
+      });
+    }
+  }
+
+  onMounted(() => {
+    nextTick(() => {
+      getGoodsPriceCardWH();
+    });
+  });
+</script>
+
+<style lang="scss" scoped>
+  .tag-icon-box {
+    position: absolute;
+    left: 0;
+    top: 0;
+    z-index: 2;
+
+    .tag-icon {
+      width: 72rpx;
+      height: 44rpx;
+    }
+  }
+
+  .seckill-tag {
+    position: absolute;
+    left: 0;
+    top: 0;
+    z-index: 2;
+    width: 68rpx;
+    height: 38rpx;
+    background: linear-gradient(90deg, #ff5854 0%, #ff2621 100%);
+    border-radius: 10rpx 0px 10rpx 0px;
+    font-size: 24rpx;
+    font-weight: 500;
+    color: #ffffff;
+    line-height: 32rpx;
+  }
+
+  .point-img {
+    width: 30rpx;
+    height: 30rpx;
+    margin: 0 4rpx;
+  }
+
+  .groupon-tag {
+    position: absolute;
+    left: 0;
+    top: 0;
+    z-index: 2;
+    width: 68rpx;
+    height: 38rpx;
+    background: linear-gradient(90deg, #fe832a 0%, #ff6600 100%);
+    border-radius: 10rpx 0px 10rpx 0px;
+    font-size: 24rpx;
+    font-weight: 500;
+    color: #ffffff;
+    line-height: 32rpx;
+  }
+
+  .goods-img {
+    width: 100%;
+    height: 100%;
+    background-color: #f5f5f5;
+  }
+
+  .price-unit {
+    margin-right: -4px;
+  }
+
+  .sales-text {
+    display: table;
+    font-size: 24rpx;
+    transform: scale(0.8);
+    margin-left: 0rpx;
+    color: #c4c4c4;
+  }
+
+  .activity-tag {
+    font-size: 20rpx;
+    color: #ff0000;
+    line-height: 30rpx;
+    padding: 0 10rpx;
+    border: 1px solid rgba(#ff0000, 0.25);
+    border-radius: 4px;
+    flex-shrink: 0;
+  }
+
+  .goods-origin-price {
+    font-size: 20rpx;
+    color: #c4c4c4;
+    line-height: 36rpx;
+    text-decoration: line-through;
+  }
+
+  // xs
+  .xs-goods-card {
+    overflow: hidden;
+    // max-width: 375rpx;
+    background-color: $white;
+    position: relative;
+
+    .xs-img-box {
+      width: 128rpx;
+      height: 128rpx;
+      margin-right: 20rpx;
+    }
+
+    .xs-goods-title {
+      font-size: 26rpx;
+      color: #333;
+      font-weight: 500;
+    }
+
+    .xs-goods-price {
+      font-size: 30rpx;
+      color: $red;
+    }
+  }
+
+  // sm
+  .sm-goods-card {
+    overflow: hidden;
+    // width: 223rpx;
+    // width: 100%;
+    background-color: $white;
+    position: relative;
+
+    .sm-img-box {
+      // width: 228rpx;
+      width: 100%;
+      height: 208rpx;
+    }
+
+    .sm-goods-content {
+      padding: 20rpx 16rpx;
+      box-sizing: border-box;
+    }
+
+    .sm-goods-title {
+      font-size: 26rpx;
+      color: #333;
+    }
+
+    .sm-goods-price {
+      font-size: 30rpx;
+      color: $red;
+    }
+  }
+
+  // md
+  .md-goods-card {
+    overflow: hidden;
+    width: 100%;
+    position: relative;
+    z-index: 1;
+    background-color: $white;
+    position: relative;
+
+    .md-img-box {
+      width: 100%;
+    }
+
+    .md-goods-title {
+      font-size: 26rpx;
+      color: #333;
+      width: 100%;
+    }
+
+    .md-goods-subtitle {
+      font-size: 24rpx;
+      font-weight: 400;
+      color: #999999;
+    }
+
+    .md-goods-price {
+      font-size: 30rpx;
+      color: $red;
+      line-height: 36rpx;
+    }
+
+    .cart-box {
+      width: 54rpx;
+      height: 54rpx;
+      background: linear-gradient(90deg, #fe8900, #ff5e00);
+      border-radius: 50%;
+      position: absolute;
+      bottom: 50rpx;
+      right: 20rpx;
+      z-index: 2;
+
+      .cart-icon {
+        width: 30rpx;
+        height: 30rpx;
+      }
+    }
+  }
+
+  // lg
+  .lg-goods-card {
+    overflow: hidden;
+    position: relative;
+    z-index: 1;
+    background-color: $white;
+    height: 280rpx;
+
+    .lg-img-box {
+      width: 280rpx;
+      height: 280rpx;
+      margin-right: 20rpx;
+    }
+
+    .lg-goods-title {
+      font-size: 28rpx;
+      font-weight: 500;
+      color: #333333;
+      // line-height: 36rpx;
+      // width: 410rpx;
+    }
+
+    .lg-goods-subtitle {
+      font-size: 24rpx;
+      font-weight: 400;
+      color: #999999;
+      // line-height: 30rpx;
+      // width: 410rpx;
+    }
+
+    .lg-goods-price {
+      font-size: 30rpx;
+      color: $red;
+      line-height: 36rpx;
+    }
+
+    .buy-box {
+      position: absolute;
+      bottom: 20rpx;
+      right: 20rpx;
+      z-index: 2;
+      width: 120rpx;
+      height: 50rpx;
+      background: linear-gradient(90deg, #fe8900, #ff5e00);
+      border-radius: 25rpx;
+      font-size: 24rpx;
+      color: #ffffff;
+    }
+
+    .tag-box {
+      width: 100%;
+    }
+  }
+
+  // sl
+
+  .sl-goods-card {
+    overflow: hidden;
+    position: relative;
+    z-index: 1;
+    width: 100%;
+    background-color: $white;
+
+    .sl-goods-content {
+      padding: 20rpx 20rpx;
+      box-sizing: border-box;
+    }
+
+    .sl-img-box {
+      width: 100%;
+      height: 360rpx;
+    }
+
+    .sl-goods-title {
+      font-size: 26rpx;
+      color: #333;
+      font-weight: 500;
+    }
+
+    .sl-goods-subtitle {
+      font-size: 24rpx;
+      font-weight: 400;
+      color: #999999;
+      line-height: 30rpx;
+    }
+
+    .sl-goods-price {
+      font-size: 30rpx;
+      color: $red;
+      line-height: 36rpx;
+    }
+
+    .buy-box {
+      position: absolute;
+      bottom: 20rpx;
+      right: 20rpx;
+      z-index: 2;
+      width: 148rpx;
+      height: 50rpx;
+      background: linear-gradient(90deg, #fe8900, #ff5e00);
+      border-radius: 25rpx;
+      font-size: 24rpx;
+      color: #ffffff;
+    }
+  }
+
+  .card {
+    width: fit-content;
+    height: fit-content;
+    padding: 2rpx 10rpx;
+    background-color: red;
+    color: #ffffff;
+    font-size: 24rpx;
+    margin-top: 5rpx;
+  }
+
+  .card2 {
+    width: fit-content;
+    height: fit-content;
+    padding: 2rpx 10rpx;
+    background-color: rgb(255, 242, 241);
+    color: #ff2621;
+    font-size: 24rpx;
+    margin: 5rpx 0 5rpx 5rpx;
+  }
+
+  .iconBox {
+    width: 100%;
+    height: fit-content;
+    margin-top: 10rpx;
+    display: flex;
+    justify-content: flex-start;
+    flex-wrap: wrap;
+  }
+</style>

+ 189 - 0
common/components/s-goods-item/s-goods-item.vue

@@ -0,0 +1,189 @@
+<template>
+  <view>
+    <view>
+      <slot name="top"></slot>
+    </view>
+    <view
+      class="ss-order-card-warp ss-flex ss-col-stretch ss-row-between bg-white"
+      :style="[{ borderRadius: radius + 'rpx', marginBottom: marginBottom + 'rpx' }]"
+    >
+      <view class="img-box ss-m-r-24">
+        <image class="order-img" :src="sheep.$url.cdn(img)" mode="aspectFill"></image>
+      </view>
+      <view
+        class="box-right ss-flex-col ss-row-between"
+        :style="[{ width: titleWidth ? titleWidth + 'rpx' : '' }]"
+      >
+        <view class="title-text ss-line-2" v-if="title">{{ title }}</view>
+        <view v-if="skuString" class="spec-text ss-m-t-8 ss-m-b-12">{{ skuString }}</view>
+        <view class="groupon-box">
+          <slot name="groupon"></slot>
+        </view>
+        <view class="ss-flex">
+          <view class="ss-flex ss-col-center">
+            <view
+              class="price-text ss-flex ss-col-center"
+              :style="[{ color: priceColor }]"
+              v-if="price && Number(price) > 0"
+            >
+              ¥{{ fen2yuan(price) }}
+            </view>
+            <view v-if="point && Number(price) > 0">+</view>
+            <view class="price-text ss-flex ss-col-center" v-if="point">
+              <image
+                :src="sheep.$url.static('/static/img/shop/goods/score1.svg')"
+                class="point-img"
+              ></image>
+              <view>{{ point }}</view>
+            </view>
+            <view v-if="num" class="total-text ss-flex ss-col-center">x {{ num }}</view>
+            <slot name="priceSuffix"></slot>
+          </view>
+        </view>
+        <view class="tool-box">
+          <slot name="tool"></slot>
+        </view>
+        <view>
+          <slot name="rightBottom"></slot>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  import sheep from '@/common';
+  import { computed } from 'vue';
+  import { fen2yuan } from '@/common/hooks/useGoods';
+  /**
+   * 订单卡片
+   *
+   * @property {String} img 											- 图片
+   * @property {String} title 										- 标题
+   * @property {Number} titleWidth = 0								- 标题宽度,默认0,单位rpx
+   * @property {String} skuText 										- 规格
+   * @property {String | Number} price 								- 价格
+   * @property {String} priceColor 									- 价格颜色
+   * @property {Number | String} num									- 数量
+   *
+   */
+  const props = defineProps({
+    img: {
+      type: String,
+      default: 'https://img1.baidu.com/it/u=1601695551,235775011&fm=26&fmt=auto',
+    },
+    title: {
+      type: String,
+      default: '',
+    },
+    titleWidth: {
+      type: Number,
+      default: 0,
+    },
+    skuText: {
+      type: [String, Array],
+      default: '',
+    },
+    price: {
+      type: [String, Number],
+      default: '',
+    },
+    priceColor: {
+      type: [String],
+      default: '',
+    },
+    num: {
+      type: [String, Number],
+      default: 0,
+    },
+    point: {
+      type: [String, Number],
+      default: '',
+    },
+    radius: {
+      type: [String],
+      default: '',
+    },
+    marginBottom: {
+      type: [String],
+      default: '',
+    },
+  });
+  const skuString = computed(() => {
+    if (!props.skuText) {
+      return '';
+    }
+    if (typeof props.skuText === 'object') {
+      return props.skuText.join(',');
+    }
+    return props.skuText;
+  });
+</script>
+
+<style lang="scss" scoped>
+  .point-img {
+    width: 36rpx;
+    height: 36rpx;
+    margin: 0 4rpx;
+  }
+  .ss-order-card-warp {
+    padding: 20rpx;
+
+    .img-box {
+      width: 164rpx;
+      height: 164rpx;
+      border-radius: 10rpx;
+      overflow: hidden;
+
+      .order-img {
+        width: 164rpx;
+        height: 164rpx;
+      }
+    }
+
+    .box-right {
+      flex: 1;
+      // width: 500rpx;
+      // height: 164rpx;
+      position: relative;
+
+      .tool-box {
+        position: absolute;
+        right: 0rpx;
+        bottom: -10rpx;
+      }
+    }
+
+    .title-text {
+      font-size: 28rpx;
+      font-weight: 500;
+      line-height: 40rpx;
+    }
+
+    .spec-text {
+      font-size: 24rpx;
+      font-weight: 400;
+      color: $dark-9;
+      min-width: 0;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      display: -webkit-box;
+      -webkit-line-clamp: 1;
+      -webkit-box-orient: vertical;
+    }
+
+    .price-text {
+      font-size: 24rpx;
+      font-weight: 500;
+      font-family: OPPOSANS;
+    }
+
+    .total-text {
+      font-size: 24rpx;
+      font-weight: 400;
+      line-height: 24rpx;
+      color: $dark-9;
+      margin-left: 8rpx;
+    }
+  }
+</style>

+ 33 - 0
common/components/s-goods-scroll/s-goods-scroll.vue

@@ -0,0 +1,33 @@
+<!-- 商品组 - 横向滚动商品(目前暂时没用到) -->
+<template>
+  <view class="goods-scroll-box">
+    <scroll-view class="scroll-box" scroll-x scroll-anchoring>
+      <view class="goods-box ss-flex">
+        <view v-for="(item, index) in list" :key="index">
+          <s-goods-column
+            class="goods-card ss-m-l-20"
+            size="sm"
+            :data="item"
+            :titleWidth="200 - marginLeft - marginRight"
+          />
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup>
+  /**
+   * 商品组 - 横向滚动商品
+   */
+  const props = defineProps({
+    list: {
+      type: Array,
+      default() {
+        return [];
+      },
+    },
+  });
+</script>
+
+<style lang="scss" scoped></style>

+ 46 - 0
common/components/s-hotzone-block/s-hotzone-block.vue

@@ -0,0 +1,46 @@
+<!-- 装修图文组件:热区 -->
+<template>
+  <view class="hotzone-wrap">
+    <image :src="sheep.$url.cdn(data.imgUrl)" style="width: 100%" mode="widthFix"></image>
+    <view
+      class="hotzone-box"
+      v-for="(item, index) in data.list"
+      :key="index"
+      :style="[
+        {
+          top: `${item.top}px`,
+          left: `${item.left}px`,
+          width: `${item.width}px`,
+          height: `${item.height}px`,
+        },
+      ]"
+      @tap.stop="sheep.$router.go(item.url)"
+    >
+    </view>
+  </view>
+</template>
+
+<script setup>
+  import sheep from '@/common';
+
+  // 接收参数
+  const props = defineProps({
+    data: {
+      type: Object,
+      default: () => ({}),
+    },
+    styles: {
+      type: Object,
+      default: () => ({}),
+    },
+  });
+</script>
+
+<style lang="scss" scoped>
+  .hotzone-wrap {
+    position: relative;
+  }
+  .hotzone-box {
+    position: absolute;
+  }
+</style>

+ 51 - 0
common/components/s-image-banner/s-image-banner.vue

@@ -0,0 +1,51 @@
+<!-- 装修图文组件:图片轮播 -->
+<template>
+  <su-swiper
+    :list="imgList"
+    :dotStyle="data.indicator === 'dot' ? 'long' : 'tag'"
+    imageMode="scaleToFill"
+    dotCur="bg-mask-40"
+    :seizeHeight="300"
+    :autoplay="data.autoplay"
+    :interval="data.interval * 1000"
+    :mode="data.type"
+    :height="px2rpx(data.height)"
+  />
+</template>
+
+<script setup>
+  import { computed } from 'vue';
+  import sheep from '@/common';
+
+  // 轮播图
+  const props = defineProps({
+    data: {
+      type: Object,
+      default: () => ({}),
+    },
+    styles: {
+      type: Object,
+      default: () => ({}),
+    },
+  });
+
+  function px2rpx(px) {
+    //计算比例
+    let scale = uni.upx2px(100)/100;
+    return px/scale
+  }
+
+  const imgList = computed(() =>
+      props.data.items.map((item) => {
+        const src = item.type === 'img' ? item.imgUrl : item.videoUrl;
+        return {
+          ...item,
+          type: item.type === 'img' ? 'image' : 'video',
+          src: sheep.$url.cdn(src),
+          poster: sheep.$url.cdn(item.imgUrl),
+        };
+      }),
+  );
+</script>
+
+<style></style>

+ 27 - 0
common/components/s-image-block/s-image-block.vue

@@ -0,0 +1,27 @@
+<!-- 装修图文组件:图片展示 -->
+<template>
+  <view @tap="sheep.$router.go(data?.url)">
+    <su-image :src="sheep.$url.cdn(data.imgUrl)" mode="widthFix" />
+  </view>
+</template>
+
+<script setup>
+  /**
+   * 图片组件
+   */
+  import sheep from '@/common';
+
+  // 接收参数
+  const props = defineProps({
+    data: {
+      type: Object,
+      default: () => ({}),
+    },
+    styles: {
+      type: Object,
+      default: () => ({}),
+    },
+  });
+</script>
+
+<style lang="scss" scoped></style>

+ 110 - 0
common/components/s-image-cube/s-image-cube.vue

@@ -0,0 +1,110 @@
+<!-- 装修图文组件:广告魔方 -->
+<template>
+  <view class="ss-cube-wrap" :style="[parseAdWrap]">
+    <view v-for="(item, index) in data.list" :key="index">
+      <view
+        class="cube-img-wrap"
+        :style="[parseImgStyle(item), { margin: data.space + 'px' }]"
+        @tap="sheep.$router.go(item.url)"
+      >
+        <image class="cube-img" :src="sheep.$url.cdn(item.imgUrl)" mode="aspectFill"></image>
+      </view>
+    </view>
+  </view>
+</template>
+<script setup>
+  /**
+/**
+ * 广告魔方
+ *
+ * @property {Array<Object>} list 			- 魔方列表
+ * @property {Object} styles 				- 组件样式
+ * @property {String} background 			- 组件背景色
+ * @property {Number} topSpace 				- 组件顶部间距
+ * @property {Number} bottomSpace 			- 组件底部间距
+ * @property {Number} leftSpace 			- 容器左间距
+ * @property {Number} rightSpace 			- 容器右间距
+ * @property {Number} imgSpace 				- 图片间距
+ * @property {Number} imgTopRadius 			- 图片上圆角
+ * @property {Number} imgBottomRadius 		- 图片下圆角
+ *
+ */
+
+  import { computed, inject, unref } from 'vue';
+  import sheep from '@/common';
+
+  // 参数
+  const props = defineProps({
+    data: {
+      type: Object,
+      default() {},
+    },
+    styles: {
+      type: Object,
+      default() {},
+    },
+  });
+
+  // 单元格大小
+  const windowWidth = sheep.$platform.device.windowWidth;
+  const cell = computed(() => {
+    return (
+      (windowWidth -
+        ((props.styles.marginLeft || 0) + (props.styles.marginRight || 0) + (props.styles.padding || 0) * 2)) /
+      4
+    );
+  });
+
+  //包裹容器高度
+  const parseAdWrap = computed(() => {
+    let heightArr = props.data.list.reduce(
+      (prev, cur) => (prev.includes(cur.height + cur.top) ? prev : [...prev, cur.height + cur.top]),
+      [],
+    );
+    let heightMax = Math.max(...heightArr);
+    return {
+      height: heightMax * cell.value + 'px',
+      width:
+        windowWidth -
+        (props.data?.style?.marginLeft +
+          props.data?.style?.marginRight +
+          props.styles.padding * 2) *
+          2 +
+        'px',
+    };
+  });
+
+  // 解析图片大小位置
+  const parseImgStyle = (item) => {
+    let obj = {
+      width: item.width * cell.value - props.data.space + 'px',
+      height: item.height * cell.value - props.data.space + 'px',
+      left: item.left * cell.value + 'px',
+      top: item.top * cell.value + 'px',
+      'border-top-left-radius': props.data.borderRadiusTop + 'px',
+      'border-top-right-radius': props.data.borderRadiusTop + 'px',
+      'border-bottom-left-radius': props.data.borderRadiusBottom + 'px',
+      'border-bottom-right-radius': props.data.borderRadiusBottom + 'px',
+    };
+    return obj;
+  };
+</script>
+
+<style lang="scss" scoped>
+  .ss-cube-wrap {
+    position: relative;
+    z-index: 2;
+    width: 750rpx;
+  }
+
+  .cube-img-wrap {
+    position: absolute;
+    z-index: 3;
+    overflow: hidden;
+  }
+
+  .cube-img {
+    width: 100%;
+    height: 100%;
+  }
+</style>

+ 279 - 0
common/components/s-layout/s-layout.vue

@@ -0,0 +1,279 @@
+<template>
+  <view
+    class="page-app"
+  >
+    <view class="page-main" :style="[bgMain]">
+      <!-- 顶部导航栏-情况1:默认通用顶部导航栏 -->
+      <su-navbar
+        v-if="navbar === 'normal'"
+        :title="title"
+        statusBar
+        :color="color"
+        :tools="tools"
+        :opacityBgUi="opacityBgUi"
+        @search="(e) => emits('search', e)"
+        :defaultSearch="defaultSearch"
+		:showBackButton="showBackButton"
+      />
+
+      <!-- 顶部导航栏-情况2:装修组件导航栏-标准 -->
+      <s-custom-navbar
+        v-else-if="navbar === 'custom' && navbarMode === 'normal'"
+        :data="navbarStyle"
+        :showLeftButton="showLeftButton"
+      />
+      <view class="page-body" :style="[bgBody]">
+        <!-- 顶部导航栏-情况3:沉浸式头部 -->
+        <su-inner-navbar v-if="navbar === 'inner'" :showBackButton="showBackButton" :showLogoutButton="showLogoutButton" :showRecordButton="showRecordButton" :showLeftButton="showLeftButton" :showLeftLogoutButton="showLeftLogoutButton" :showRecordTextButton="showRecordTextButton" :showImportButton="showImportButton" :showExportButton="showExportButton" :alway="alway" :title="title" @logout="state.showLogout=true" @import="onClickImport" @export="onClickExport" />
+        <view
+          v-if="navbar === 'inner'"
+          :style="[{ paddingTop: sheep?.$platform?.navbar + 'px' }]"
+        ></view>
+
+        <!-- 顶部导航栏-情况4:装修组件导航栏-沉浸式 -->
+        <s-custom-navbar
+          v-if="navbar === 'custom' && navbarMode === 'inner'"
+          :data="navbarStyle"
+          :showLeftButton="showLeftButton"
+        />
+
+        <!-- 页面内容插槽 -->
+        <slot />
+
+        <!-- 底部导航 -->
+        <s-tabbar v-if="tabbar !== ''" :path="tabbar" />
+      </view>
+    </view>
+
+    <view class="page-modal">
+	  <!-- 全局授权弹窗 -->
+	  <s-auth-modal />
+      <!-- 全局快捷入口 -->
+      <s-menu-tools />
+	  <su-popup :show="state.showLogout" round="20" type="center" :isMaskClick="false" :showClose="false" @close="state.showLogout=false">
+	  		<view class="tips_wrap">
+	  			<view class="tips_cont">
+	  				<view class="label">退出登录</view>
+	  				<view class="cont">确认要退出当前账号吗?</view>
+	  			</view>
+	  			<view class="group_btn">
+	  				<view class="item_btn cancel_btn" @tap="state.showLogout=false">取消</view>
+	  				<view class="item_btn logout-btn" @tap="logout">确认</view>
+	  			</view>
+	  		</view>
+	  </su-popup>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  /**
+   * 模板组件 - 提供页面公共组件,属性,方法
+   */
+  import { computed, onMounted, reactive } from 'vue';
+  import AuthUtil from '@/common/api/member/auth';
+  import sheep from '@/common';
+  import { isEmpty } from 'lodash-es';
+  // #ifdef MP-WEIXIN
+  import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app';
+  // #endif
+
+  const props = defineProps({
+    title: {
+      type: String,
+      default: '',
+    },
+    navbar: {
+      type: String,
+      default: 'normal',
+    },
+    opacityBgUi: {
+      type: String,
+      default: 'bg-white',
+    },
+    color: {
+      type: String,
+      default: '',
+    },
+    tools: {
+      type: String,
+      default: 'title',
+    },
+    keyword: {
+      type: String,
+      default: '',
+    },
+    navbarStyle: {
+      type: Object,
+      default: () => ({
+        styleType: '',
+        type: '',
+        color: '',
+        src: '',
+        list: [],
+        alwaysShow: 0,
+      }),
+    },
+    bgStyle: {
+      type: Object,
+      default: () => ({
+        src: '',
+        color: 'var(--ui-BG-1)',
+      }),
+    },
+    tabbar: {
+      type: [String, Boolean],
+      default: '',
+    },
+    onShareAppMessage: {
+      type: [Boolean, Object],
+      default: true,
+    },
+    leftWidth: {
+      type: [Number, String],
+      default: 100,
+    },
+    rightWidth: {
+      type: [Number, String],
+      default: 100,
+    },
+    defaultSearch: {
+      type: String,
+      default: '',
+    },
+    //展示返回按钮
+    showLeftButton: {
+      type: Boolean,
+      default: false,
+    },
+	showBackButton: {
+	  type: Boolean,
+	  default: false,
+	},
+	showRecordButton: {
+	  type: Boolean,
+	  default: false,
+	},
+	showLogoutButton: {
+	  type: Boolean,
+	  default: false,
+	},
+	showLeftLogoutButton: {
+	  type: Boolean,
+	  default: false,
+	},
+	showRecordTextButton: {
+	  type: Boolean,
+	  default: false,
+	},
+	showImportButton: {
+	  type: Boolean,
+	  default: false,
+	},
+	showExportButton: {
+	  type: Boolean,
+	  default: false,
+	},
+	alway: {
+	  type: Boolean,
+	  default: true,
+	},
+	opacity: {
+	  type: Boolean,
+	  default: true,
+	},
+  });
+  const state = reactive({
+  	showLogout: false
+  })
+  const emits = defineEmits(['search','import', 'export']);
+
+  const userStore = sheep.$store('user');
+  const modalStore = sheep.$store('modal');
+
+  // 导航栏模式(因为有自定义导航栏 需要计算)
+  const navbarMode = computed(() => {
+    if (props.navbar === 'normal' || props.navbarStyle.styleType === 'normal') {
+      return 'normal';
+    }
+    return 'inner';
+  });
+
+  // 背景1
+  const bgMain = computed(() => {
+    if (navbarMode.value === 'inner') {
+      return {
+        background: `${props.bgStyle.backgroundColor || props.bgStyle.color}${props.bgStyle.backgroundImage?` url(${props.bgStyle.backgroundImage}) no-repeat top -88rpx center / 100% auto`:''}`,
+		...(props.bgStyle.backgroundImage?{height: '100vh',overflowY: 'auto'}:{})
+      };
+    }
+    return {};
+  });
+
+  // 背景2
+  const bgBody = computed(() => {
+    if (navbarMode.value === 'normal') {
+      return {
+        background: `${props.bgStyle.backgroundColor || props.bgStyle.color}${props.bgStyle.backgroundImage?` url(${props.bgStyle.backgroundImage}) no-repeat top -88rpx center / 100% auto`:''}`,
+		...(props.bgStyle.backgroundImage?{height: '100vh',overflowY: 'auto'}:{})
+      };
+    }
+    return {};
+  });
+  async function logout(){
+  	  const { code } = await AuthUtil.logout()
+  	  if (code !== 1) {
+  	    return;
+  	  }
+  	  sheep.$store('user').logout()
+	  state.showLogout = false
+  	  sheep.$router.go('/pages/index/index')
+  }
+  function onClickImport(){
+  	  emits('import')
+  }
+  function onClickExport(){
+  	  emits('export')
+  }
+  // 组件中使用 onMounted 监听页面加载,不是页面组件不使用 onShow
+  onMounted(()=>{
+  })
+</script>
+
+<style lang="scss" scoped>
+  .page-app {
+    position: relative;
+    color: var(--ui-TC);
+    background-color: var(--ui-BG-1) !important;
+    z-index: 2;
+    display: flex;
+    width: 750rpx;
+    height: 100vh;
+	margin: 0 auto;
+    .page-main {
+	  position: absolute;
+	  z-index: 1;
+	  width: 100%;
+	  min-height: 100%;
+	  display: flex;
+	  flex-direction: column;
+	  
+	  .page-body {
+		height: 100%;
+	    width: 100%;
+	    position: relative;
+	    z-index: 1;
+		display: flex;
+		flex-direction: column;
+	  }
+      .page-img {
+        width: 100vw;
+        height: 100vh;
+        position: absolute;
+        top: 0;
+        left: 0;
+        z-index: 0;
+      }
+    }
+  }
+</style>

+ 15 - 0
common/components/s-line-block/s-line-block.vue

@@ -0,0 +1,15 @@
+<!-- 装修基础组件:分割线 -->
+<template>
+  <su-subline v-bind="data"></su-subline>
+</template>
+
+<script setup>
+  const props = defineProps({
+    data: {
+      type: Object,
+      default: {},
+    },
+  });
+</script>
+
+<style></style>

+ 144 - 0
common/components/s-live-block/s-live-block.vue

@@ -0,0 +1,144 @@
+<template>
+  <view>
+    <view
+      v-if="mode === 2 && state.liveList.length"
+      class="goods-md-wrap ss-flex ss-flex-wrap ss-col-top"
+      :style="[{ margin: '-' + data.space + 'rpx' }]"
+    >
+      <view
+        :style="[
+          {
+            padding: data.space + 'rpx',
+          },
+        ]"
+        class="goods-list-box"
+        v-for="item in state.liveList"
+        :key="item.id"
+      >
+        <s-live-card
+          class="goods-md-box"
+          size="md"
+          :goodsFields="goodsFields"
+          :data="item"
+          :titleColor="goodsFields.name?.color"
+          :subTitleColor="goodsFields.anchor_name?.color"
+          :topRadius="data.borderRadiusTop"
+          :bottomRadius="data.borderRadiusBottom"
+          @click="goRoom(item.roomid)"
+        >
+        </s-live-card>
+      </view>
+    </view>
+    <view v-if="mode === 1 && state.liveList.length" class="goods-lg-box">
+      <view
+        class="goods-box"
+        :style="[{ marginBottom: data.space + 'px' }]"
+        v-for="item in state.liveList"
+        :key="item.id"
+      >
+        <s-live-card
+          class="goods-card"
+          size="sl"
+          :goodsFields="goodsFields"
+          :data="item"
+          :titleColor="goodsFields.name?.color"
+          :subTitleColor="goodsFields.anchor_name.color"
+          :topRadius="data.borderRadiusTop"
+          :bottomRadius="data.borderRadiusBottom"
+          @tap="goRoom(item.roomid)"
+        >
+        </s-live-card>
+      </view>
+    </view>
+  </view>
+</template>
+<script setup>
+  import { reactive, onMounted } from 'vue';
+  import sheep from '@/common';
+
+  const state = reactive({
+    liveList: [],
+    mpLink: '',
+  });
+  const props = defineProps({
+    data: {
+      type: Object,
+      default() {},
+    },
+    styles: {
+      type: Object,
+      default() {},
+    },
+  });
+  const { mode, goodsFields, mpliveIds } = props.data || {};
+  const { marginLeft, marginRight } = props.styles || {};
+
+  async function getLiveListByIds(ids) {
+    const { data } = await sheep.$api.app.mplive.getRoomList(ids);
+    return data;
+  }
+  function goRoom(id) {
+    // #ifdef MP-WEIXIN
+    uni.navigateTo({
+      url: `plugin-private://wx2b03c6e691cd7370/pages/live-player-plugin?room_id=${id}`,
+    });
+    // #endif
+
+    // #ifndef MP-WEIXIN
+    uni.showModal({
+      title: '提示',
+      confirmText: '允许',
+      content: '将打开小程序访问',
+      success: async function (res) {
+        if (res.confirm) {
+          getMpLink();
+        }
+      },
+    });
+    // #endif
+  }
+
+  function goMpLink() {
+    // #ifdef H5
+    window.location = state.mpLink;
+    // #endif
+    // #ifdef APP-PLUS
+    plus.runtime.openURL(state.mpLink);
+    // #endif
+  }
+
+  async function getMpLink() {
+    // #ifndef MP-WEIXIN
+    if (state.mpLink === '') {
+      const { error, data } = await sheep.$api.app.mplive.getMpLink();
+      if (error === 0) {
+        state.mpLink = data;
+      }
+    }
+    goMpLink();
+    // #endif
+  }
+
+  onMounted(async () => {
+    state.liveList = await getLiveListByIds(mpliveIds);
+  });
+</script>
+<style lang="scss" scoped>
+  .goods-list-box {
+    width: 50%;
+    flex-shrink: 0;
+    box-sizing: border-box;
+    overflow: hidden;
+  }
+
+  .goods-box {
+    &:nth-last-of-type(1) {
+      margin-bottom: 0 !important;
+    }
+  }
+
+  .goods-md-box,
+  .goods-sl-box {
+    position: relative;
+  }
+</style>

+ 234 - 0
common/components/s-live-card/s-live-card.vue

@@ -0,0 +1,234 @@
+<template>
+  <view>
+    <!-- md卡片:竖向,一行放两个,图上内容下 -->
+    <view v-if="size === 'md'" class="md-goods-card ss-flex-col" :style="[elStyles]" @tap="onClick">
+      <view class="icon-box ss-flex">
+        <image class="icon" :src="state.liveStatus[data.status].img"></image>
+        <view class="title ss-m-l-16">{{ state.liveStatus[data.status].title }}</view>
+      </view>
+      <img class="md-img-box" :src="sheep.$url.cdn(data.feeds_img)" referrerpolicy="no-referrer">
+      <view class="md-goods-content">
+        <view class="md-goods-title ss-line-1" :style="[{ color: titleColor }]">
+          {{ data.name }}
+        </view>
+        <view class="md-goods-subtitle ss-m-t-14 ss-line-1" :style="[{ color: subTitleColor }]">
+          主播:{{ data.anchor_name }}
+        </view>
+      </view>
+    </view>
+    <!-- sl卡片:竖向型,一行放一个,图片上内容下边 -->
+    <view v-if="size === 'sl'" class="sl-goods-card ss-flex-col" :style="[elStyles]" @tap="onClick">
+      <view class="icon-box ss-flex">
+        <image class="icon" :src="state.liveStatus[data.status].img"></image>
+        <view class="title ss-m-l-16">{{ state.liveStatus[data.status].title }}</view>
+      </view>
+      <img class="sl-img-box" :src="sheep.$url.cdn(data.feeds_img)" referrerpolicy="no-referrer">
+      <view class="sl-goods-content">
+        <view class="sl-goods-title ss-line-1" :style="[{ color: titleColor }]">
+          {{ data.name }}
+        </view>
+        <view class="sl-goods-subtitle ss-m-t-14 ss-line-1" :style="[{ color: subTitleColor }]">
+          主播:{{ data.anchor_name }}
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+<script setup>
+  import { computed, reactive } from 'vue';
+  import sheep from '@/common';
+  /**
+   * 直播卡片
+   *
+   * @property {String} img 											- 图片
+   * @property {String} title 										- 标题
+   * @property {Number} titleWidth = 0								- 标题宽度,默认0,单位rpx
+   * @property {String} skuText 										- 规格
+   * @property {String | Number} score 								- 积分
+   * @property {String | Number} price 								- 价格
+   * @property {String | Number} originalPrice 						- 单购价
+   * @property {String} priceColor 									- 价格颜色
+   * @property {Number | String} num									- 数量
+   *
+   */
+  const props = defineProps({
+    goodsFields: {
+      type: [Array, Object],
+      default() {
+        return {};
+      },
+    },
+    tagStyle: {
+      type: Object,
+      default: {},
+    },
+    data: {
+      type: Object,
+      default: {},
+    },
+    size: {
+      type: String,
+      default: 'sl',
+    },
+    background: {
+      type: String,
+      default: '',
+    },
+    topRadius: {
+      type: Number,
+      default: 0,
+    },
+    bottomRadius: {
+      type: Number,
+      default: 0,
+    },
+    titleColor: {
+      type: String,
+      default: '#333',
+    },
+    subTitleColor: {
+      type: String,
+      default: '#999999',
+    },
+  });
+  // 组件样式
+  const elStyles = computed(() => {
+    return {
+      background: props.background,
+      'border-top-left-radius': props.topRadius + 'px',
+      'border-top-right-radius': props.topRadius + 'px',
+      'border-bottom-left-radius': props.bottomRadius + 'px',
+      'border-bottom-right-radius': props.bottomRadius + 'px',
+    };
+  });
+  const state = reactive({
+    liveStatus: {
+      101: {
+        img: sheep.$url.static('/static/img/shop/app/mplive/living.png'),
+        title: '直播中',
+      },
+      102: {
+        img: sheep.$url.static('/static/img/shop/app/mplive/start.png'),
+        title: '未开始',
+      },
+      103: {
+        img: sheep.$url.static('/static/img/shop/app/mplive/ended.png'),
+        title: '已结束',
+      },
+    },
+  });
+  const emits = defineEmits(['click', 'getHeight']);
+  const onClick = () => {
+    emits('click');
+  };
+</script>
+
+<style lang="scss" scoped>
+  // md
+  .md-goods-card {
+    overflow: hidden;
+    width: 100%;
+    height: 424rpx;
+    position: relative;
+    z-index: 1;
+    background-color: $white;
+    .icon-box {
+      position: absolute;
+      left: 20rpx;
+      top: 10rpx;
+      width: 136rpx;
+      height: 40rpx;
+      background: rgba(#000000, 0.5);
+      border-radius: 20rpx;
+      z-index: 1;
+      .icon {
+        width: 40rpx;
+        height: 40rpx;
+        border-radius: 20rpx 0px 20rpx 20rpx;
+      }
+      .title {
+        font-size: 24rpx;
+        font-weight: 500;
+        color: #ffffff;
+      }
+    }
+    .md-goods-content {
+      position: absolute;
+      left: 0;
+      bottom: 0;
+      padding: 20rpx;
+      width: 100%;
+      background: linear-gradient(360deg, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.02) 100%);
+    }
+
+    .md-img-box {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+    }
+
+    .md-goods-title {
+      font-size: 26rpx;
+      color: #333;
+      width: 100%;
+    }
+    .md-goods-subtitle {
+      font-size: 24rpx;
+      font-weight: 400;
+      color: #999999;
+    }
+  }
+  .sl-goods-card {
+    overflow: hidden;
+    position: relative;
+    z-index: 1;
+    width: 100%;
+    height: 400rpx;
+    background-color: $white;
+    .icon-box {
+      position: absolute;
+      left: 20rpx;
+      top: 10rpx;
+      width: 136rpx;
+      height: 40rpx;
+      background: rgba(#000000, 0.5);
+      border-radius: 20rpx;
+      z-index: 1;
+      .icon {
+        width: 40rpx;
+        height: 40rpx;
+        border-radius: 20rpx 0px 20rpx 20rpx;
+      }
+      .title {
+        font-size: 24rpx;
+        font-weight: 500;
+        color: #ffffff;
+      }
+    }
+    .sl-goods-content {
+      position: absolute;
+      left: 0;
+      bottom: 0;
+      padding: 20rpx;
+      width: 100%;
+      background: linear-gradient(360deg, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.02) 100%);
+    }
+
+    .sl-img-box {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+    }
+
+    .sl-goods-title {
+      font-size: 26rpx;
+      color: #333;
+      width: 100%;
+    }
+    .sl-goods-subtitle {
+      font-size: 24rpx;
+      font-weight: 400;
+      color: #999999;
+    }
+  }
+</style>

+ 343 - 0
common/components/s-menu-button/s-menu-button.vue

@@ -0,0 +1,343 @@
+<!-- 装修基础组件:菜单导航(金刚区) -->
+<template>
+	<!-- 包裹层 -->
+	<view class="ui-swiper" :class="[props.mode, props.ui]"
+		:style="[bgStyle, { height: swiperHeight + (menuList.length > 1 ? 50 : 0) + 'rpx' }]">
+		<!-- 轮播 -->
+		<swiper :circular="props.circular" :current="state.cur" :autoplay="props.autoplay" :interval="props.interval"
+			:duration="props.duration" :style="[{ height: swiperHeight + 'rpx' }]" @change="swiperChange">
+			<swiper-item v-for="(arr, index) in menuList" :key="index" :class="{ cur: state.cur == index }">
+				<!-- 宫格 -->
+				<view class="grid-wrap">
+					<view v-for="(item, index) in arr" :key="index"
+						class="grid-item ss-flex ss-flex-col ss-col-center ss-row-center"
+						:style="[{ width: `${100 * (1 / data.column)}%`, height: '200rpx' }]" hover-class="ss-hover-btn"
+						@tap="sheep.$router.go(item.url)">
+						<view class="menu-box ss-flex ss-flex-col ss-col-center ss-row-center">
+							<view v-if="item.badge.show" class="tag-box"
+								:style="[{ background: item.badge.bgColor, color: item.badge.textColor }]">
+								{{ item.badge.text }}
+							</view>
+							<image v-if="item.iconUrl" class="menu-icon" :style="[
+                  {
+                    width: props.iconSize + 'rpx',
+                    height: props.iconSize + 'rpx',
+                  },
+                ]" :src="sheep.$url.cdn(item.iconUrl)" mode="aspectFill"></image>
+							<view v-if="data.layout === 'iconText'" class="menu-title"
+								:style="[{ color: item.titleColor }]">
+								{{ item.title }}
+							</view>
+						</view>
+					</view>
+				</view>
+			</swiper-item>
+		</swiper>
+		<!-- 指示点 -->
+		<template v-if="menuList.length > 1">
+			<view class="ui-swiper-dot" :class="props.dotStyle" v-if="props.dotStyle != 'tag'">
+				<view class="line-box" v-for="(item, index) in menuList.length" :key="index"
+					:class="[state.cur == index ? 'cur' : '', props.dotCur]"></view>
+			</view>
+			<view class="ui-swiper-dot" :class="props.dotStyle" v-if="props.dotStyle == 'tag'">
+				<view class="ui-tag radius" :class="[props.dotCur]" style="pointer-events: none">
+					<view style="transform: scale(0.7)">{{ state.cur + 1 }} / {{ menuList.length }}</view>
+				</view>
+			</view>
+		</template>
+	</view>
+</template>
+
+<script setup>
+	/**
+	 * 轮播menu
+	 *
+	 * @property {Boolean} circular = false  		- 是否采用衔接滑动,即播放到末尾后重新回到开头
+	 * @property {Boolean} autoplay = true  		- 是否自动切换
+	 * @property {Number} interval = 5000  			- 自动切换时间间隔
+	 * @property {Number} duration = 500  			- 滑动动画时长,app-nvue不支持
+	 * @property {Array} list = [] 					- 轮播数据
+	 * @property {String} ui = ''  					- 样式class
+	 * @property {String} mode  					- 模式
+	 * @property {String} dotStyle  				- 指示点样式
+	 * @property {String} dotCur= 'ui-BG-Main' 		- 当前指示点样式,默认主题色
+	 * @property {String} bg  						- 背景
+	 *
+	 * @property {String|Number} col = 4  			- 一行数量
+	 * @property {String|Number} row = 1 			- 几行
+	 * @property {String} hasBorder 				- 是否有边框
+	 * @property {String} borderColor 				- 边框颜色
+	 * @property {String} background		  		- 背景
+	 * @property {String} hoverClass 				- 按压样式类
+	 * @property {String} hoverStayTime 		  	- 动画时间
+	 *
+	 * @property {Array} list 		  				- 导航列表
+	 * @property {Number} iconSize 		  			- 图标大小
+	 * @property {String} color 		  			- 标题颜色
+	 *
+	 */
+
+	import {
+		reactive,
+		computed
+	} from 'vue';
+	import sheep from '@/common';
+
+	// 数据
+	const state = reactive({
+		cur: 0,
+	});
+
+	// 接收参数
+
+	const props = defineProps({
+		// 装修数据
+		data: {
+			type: Object,
+			default: () => ({}),
+		},
+		// 装修样式
+		styles: {
+			type: Object,
+			default: () => ({}),
+		},
+		circular: {
+			type: Boolean,
+			default: true,
+		},
+		autoplay: {
+			type: Boolean,
+			default: false,
+		},
+		interval: {
+			type: Number,
+			default: 5000,
+		},
+		duration: {
+			type: Number,
+			default: 500,
+		},
+		ui: {
+			type: String,
+			default: '',
+		},
+		mode: {
+			//default
+			type: String,
+			default: 'default',
+		},
+		dotStyle: {
+			type: String,
+			default: 'long', //default long tag
+		},
+		dotCur: {
+			type: String,
+			default: 'ui-BG-Main',
+		},
+		height: {
+			type: Number,
+			default: 300,
+		},
+		// 是否有边框
+		hasBorder: {
+			type: Boolean,
+			default: true,
+		},
+		// 边框颜色
+		borderColor: {
+			type: String,
+			default: 'red',
+		},
+		background: {
+			type: String,
+			default: 'blue',
+		},
+		hoverClass: {
+			type: String,
+			default: 'ss-hover-class', //'none'为没有hover效果
+		},
+		// 一排宫格数
+		col: {
+			type: [Number, String],
+			default: 3,
+		},
+		iconSize: {
+			type: Number,
+			default: 80,
+		},
+		color: {
+			type: String,
+			default: '#000',
+		},
+	});
+
+	// 设置背景样式
+	const bgStyle = computed(() => {
+		// 直接从 props.styles 解构
+		const {
+			bgType,
+			bgImg,
+			bgColor
+		} = props.styles;
+
+		// 根据 bgType 返回相应的样式
+		return {
+			background: bgType === 'img' ? `url(${bgImg}) no-repeat top center / 100% 100%` : bgColor
+		};
+	});
+
+	// 生成数据
+	const menuList = computed(() => splitData(props.data.list, props.data.row * props.data.column));
+	const swiperHeight = computed(() => props.data.row * (props.data.layout === 'iconText' ? 200 : 180));
+	const windowWidth = sheep.$platform.device.windowWidth;
+
+	// current 改变时会触发 change 事件
+	const swiperChange = (e) => {
+		state.cur = e.detail.current;
+	};
+
+	// 重组数据
+	const splitData = (oArr = [], length = 1) => {
+		let arr = [];
+		let minArr = [];
+		oArr.forEach((c) => {
+			if (minArr.length === length) {
+				minArr = [];
+			}
+			if (minArr.length === 0) {
+				arr.push(minArr);
+			}
+			minArr.push(c);
+		});
+
+		return arr;
+	};
+</script>
+
+<style lang="scss" scoped>
+	.grid-wrap {
+		width: 100%;
+		display: flex;
+		position: relative;
+		box-sizing: border-box;
+		overflow: hidden;
+		flex-wrap: wrap;
+		align-items: center;
+	}
+
+	.menu-box {
+		position: relative;
+		z-index: 1;
+		transform: translate(0, 0);
+
+		.tag-box {
+			position: absolute;
+			z-index: 2;
+			top: 0;
+			right: -6rpx;
+			font-size: 2em;
+			line-height: 1;
+			padding: 0.4em 0.6em 0.3em;
+			transform: scale(0.4) translateX(0.5em) translatey(-0.6em);
+			transform-origin: 100% 0;
+			border-radius: 200rpx;
+			white-space: nowrap;
+		}
+
+		.menu-icon {
+			transform: translate(0, 0);
+			width: 80rpx;
+			height: 80rpx;
+			padding-bottom: 10rpx;
+		}
+
+		.menu-title {
+			font-size: 24rpx;
+			color: #333;
+		}
+	}
+
+	::v-deep(.ui-swiper) {
+		position: relative;
+		z-index: 1;
+
+		.ui-swiper-dot {
+			position: absolute;
+			width: 100%;
+			bottom: 20rpx;
+			height: 30rpx;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			z-index: 2;
+
+			&.default .line-box {
+				display: inline-flex;
+				border-radius: 50rpx;
+				width: 6px;
+				height: 6px;
+				border: 2px solid transparent;
+				margin: 0 10rpx;
+				opacity: 0.3;
+				position: relative;
+				justify-content: center;
+				align-items: center;
+
+				&.cur {
+					width: 8px;
+					height: 8px;
+					opacity: 1;
+					border: 0px solid transparent;
+				}
+
+				&.cur::after {
+					content: '';
+					border-radius: 50rpx;
+					width: 4px;
+					height: 4px;
+					background-color: #fff;
+				}
+			}
+
+			&.long .line-box {
+				display: inline-block;
+				border-radius: 100rpx;
+				width: 6px;
+				height: 6px;
+				margin: 0 10rpx;
+				opacity: 0.3;
+				position: relative;
+
+				&.cur {
+					width: 24rpx;
+					opacity: 1;
+				}
+
+				&.cur::after {}
+			}
+
+			&.line {
+				bottom: 20rpx;
+
+				.line-box {
+					display: inline-block;
+					width: 30px;
+					height: 3px;
+					opacity: 0.3;
+					position: relative;
+
+					&.cur {
+						opacity: 1;
+					}
+				}
+			}
+
+			&.tag {
+				justify-content: flex-end;
+				position: absolute;
+				bottom: 20rpx;
+				right: 20rpx;
+			}
+		}
+	}
+</style>

+ 104 - 0
common/components/s-menu-grid/s-menu-grid.vue

@@ -0,0 +1,104 @@
+<!-- 装修基础组件:宫格导航 -->
+<template>
+	<view :style="[bgStyle, { marginLeft: `${data.space}px` }]">
+		<uni-grid :showBorder="Boolean(data.border)" :column="data.column">
+			<uni-grid-item v-for="(item, index) in data.list" :key="index" @tap="sheep.$router.go(item.url)">
+				<view class="grid-item-box ss-flex ss-flex-col ss-row-center ss-col-center">
+					<view class="img-box">
+						<view class="tag-box" v-if="item.badge.show"
+							:style="[{ background: item.badge.bgColor, color: item.badge.textColor }]">
+							{{ item.badge.text }}
+						</view>
+						<image class="menu-image" :src="sheep.$url.cdn(item.iconUrl)"></image>
+					</view>
+
+					<view class="title-box ss-flex ss-flex-col ss-row-center ss-col-center">
+						<view class="grid-text" :style="[{ color: item.titleColor }]">
+							{{ item.title }}
+						</view>
+						<view class="grid-tip" :style="[{ color: item.subtitleColor }]">
+							{{ item.subtitle }}
+						</view>
+					</view>
+				</view>
+			</uni-grid-item>
+		</uni-grid>
+	</view>
+
+</template>
+
+<script setup>
+	import sheep from '@/common';
+	import {
+		computed
+	} from 'vue';
+
+	const props = defineProps({
+		// 装修数据
+		data: {
+			type: Object,
+			default: () => ({}),
+		},
+		// 装修样式
+		styles: {
+			type: Object,
+			default: () => ({}),
+		},
+	});
+	// 设置背景样式
+	const bgStyle = computed(() => {
+		// 直接从 props.styles 解构
+		const {
+			bgType,
+			bgImg,
+			bgColor
+		} = props.styles;
+
+		// 根据 bgType 返回相应的样式
+		return {
+			background: bgType === 'img' ? `url(${bgImg}) no-repeat top center / 100% 100%` : bgColor
+		};
+	});
+</script>
+
+<style lang="scss" scoped>
+	.menu-image {
+		width: 24px;
+		height: 24px;
+	}
+
+	.grid-item-box {
+		flex: 1;
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		justify-content: center;
+		height: 100%;
+
+		.img-box {
+			position: relative;
+
+			.tag-box {
+				position: absolute;
+				z-index: 2;
+				top: 0;
+				right: 0;
+				font-size: 2em;
+				line-height: 1;
+				padding: 0.4em 0.6em 0.3em;
+				transform: scale(0.4) translateX(0.5em) translatey(-0.6em);
+				transform-origin: 100% 0;
+				border-radius: 200rpx;
+				white-space: nowrap;
+			}
+		}
+
+		.title-box {
+			.grid-tip {
+				font-size: 24rpx;
+				white-space: nowrap;
+				text-align: center;
+			}
+		}
+	}
+</style>

+ 66 - 0
common/components/s-menu-list/s-menu-list.vue

@@ -0,0 +1,66 @@
+<!-- 装修基础组件:列表导航 -->
+<template>
+  <view class="menu-list-wrap">
+    <uni-list :border="true">
+      <uni-list-item
+        v-for="(item, index) in data.list"
+        :key="index"
+        showArrow
+        clickable
+        @tap="sheep.$router.go(item.url)"
+      >
+        <template v-slot:header>
+          <view class="ss-flex ss-col-center">
+            <image
+              v-if="item.iconUrl"
+              class="list-icon"
+              :src="sheep.$url.cdn(item.iconUrl)"
+              mode="aspectFit"
+            ></image>
+            <view
+              class="title-text ss-flex ss-row-center ss-col-center ss-m-l-20"
+              :style="[{ color: item.titleColor }]"
+            >
+              {{ item.title }}
+            </view>
+          </view>
+        </template>
+        <template v-slot:footer>
+          <view
+            class="notice-text ss-flex ss-row-center ss-col-center"
+            :style="[{ color: item.subtitleColor }]"
+          >
+            {{ item.subtitle }}
+          </view>
+        </template>
+      </uni-list-item>
+    </uni-list>
+  </view>
+</template>
+
+<script setup>
+  /**
+   * cell
+   */
+  import sheep from '@/common';
+  const props = defineProps({
+    data: {
+      type: Object,
+      default: () => ({}),
+    },
+  });
+</script>
+
+<style lang="scss">
+  .list-icon {
+    width: 20px;
+    height: 20px;
+  }
+  .notice-text {
+  }
+  .menu-list-wrap {
+    ::v-deep .uni-list {
+      background-color: transparent;
+    }
+  }
+</style>

+ 118 - 0
common/components/s-menu-tools/s-menu-tools.vue

@@ -0,0 +1,118 @@
+<!-- 全局 - 快捷入口 -->
+<template>
+  <su-popup :show="show" type="top" round="20" backgroundColor="#F0F0F0" @close="closeMenuTools">
+    <su-status-bar />
+    <view class="tools-wrap ss-m-x-30 ss-m-b-16">
+      <view class="title ss-m-b-34 ss-p-t-20">快捷菜单</view>
+      <view class="container-list ss-flex ss-flex-wrap">
+        <view class="list-item ss-m-b-24" v-for="item in list" :key="item.title">
+          <view class="ss-flex-col ss-col-center">
+            <button
+              class="ss-reset-button list-image ss-flex ss-row-center ss-col-center"
+              @tap="onClick(item)"
+            >
+              <image v-if="show" :src="sheep.$url.static(item.icon)" class="list-icon" />
+            </button>
+            <view class="list-title ss-m-t-20">{{ item.title }}</view>
+          </view>
+        </view>
+      </view>
+    </view>
+  </su-popup>
+</template>
+
+<script setup>
+  import { reactive, computed } from 'vue';
+  import sheep from '@/common';
+  import { showMenuTools, closeMenuTools } from '@/common/hooks/useModal';
+
+  const show = computed(() => sheep.$store('modal').menu);
+
+  function onClick(item) {
+    closeMenuTools();
+    if (item.url) sheep.$router.go(item.url);
+  }
+
+  const list = [
+    {
+      url: '/pages/index/index',
+      icon: '/static/img/shop/tools/home.png',
+      title: '首页',
+    },
+    {
+      url: '/pages/index/search',
+      icon: '/static/img/shop/tools/search.png',
+      title: '搜索',
+    },
+    {
+      url: '/pages/index/user',
+      icon: '/static/img/shop/tools/user.png',
+      title: '个人中心',
+    },
+    {
+      url: '/pages/index/cart',
+      icon: '/static/img/shop/tools/cart.png',
+      title: '购物车',
+    },
+    {
+      url: '/pages/user/goods-log',
+      icon: '/static/img/shop/tools/browse.png',
+      title: '浏览记录',
+    },
+    {
+      url: '/pages/user/goods-collect',
+      icon: '/static/img/shop/tools/collect.png',
+      title: '我的收藏',
+    },
+    {
+      url: '/pages/chat/index',
+      icon: '/static/img/shop/tools/service.png',
+      title: '客服',
+    },
+  ];
+</script>
+
+<style lang="scss" scoped>
+  .tools-wrap {
+    // background: #F0F0F0;
+    // box-shadow: 0px 0px 28rpx 7rpx rgba(0, 0, 0, 0.13);
+    // opacity: 0.98;
+    // border-radius: 0 0 20rpx 20rpx;
+
+    .title {
+      font-size: 36rpx;
+      font-weight: bold;
+      color: #333333;
+    }
+
+    .list-item {
+      width: calc(25vw - 20rpx);
+
+      .list-image {
+        width: 104rpx;
+        height: 104rpx;
+        border-radius: 52rpx;
+        background: var(--ui-BG);
+
+        .list-icon {
+          width: 54rpx;
+          height: 54rpx;
+        }
+      }
+
+      .list-title {
+        font-size: 26rpx;
+        font-weight: 500;
+        color: #333333;
+      }
+    }
+  }
+
+  .uni-popup {
+    top: 0 !important;
+  }
+
+  :deep(.button-hover) {
+    background: #fafafa !important;
+  }
+</style>

+ 38 - 0
common/components/s-notice-block/s-notice-block.vue

@@ -0,0 +1,38 @@
+<template>
+  <view class="ss-flex ss-col-center notice-wrap">
+    <image class="icon-img" :src="sheep.$url.cdn(data.iconUrl)" mode="heightFix"></image>
+    <!-- todo:@owen 暂时只支持一个公告   -->
+    <su-notice-bar
+      style="flex: 1"
+      :showIcon="false"
+      scrollable
+      single
+      :text="data.contents[0].text"
+      :speed="50"
+      :color="data.textColor"
+      @tap="sheep.$router.go(data.contents[0].url)"
+    />
+  </view>
+</template>
+
+<script setup>
+  /**
+   * 装修组件  - 通知栏
+   *
+   */
+  import sheep from '@/common';
+  const props = defineProps({
+    data: {
+      type: Object,
+      default() {},
+    },
+  });
+</script>
+
+<style lang="scss" scoped>
+  .notice-wrap {
+    .icon-img {
+      height: 56rpx;
+    }
+  }
+</style>

+ 132 - 0
common/components/s-order-card/s-order-card.vue

@@ -0,0 +1,132 @@
+<!-- 装修用户组件:用户订单 -->
+<template>
+  <view class="ss-order-menu-wrap ss-flex ss-col-center" :style="[style, { marginLeft: `${data.space}px` }]">
+    <view
+      class="menu-item ss-flex-1 ss-flex-col ss-row-center ss-col-center"
+      v-for="item in orderMap"
+      :key="item.title"
+      @tap="sheep.$router.go(item.path, { type: item.value })"
+    >
+      <uni-badge
+        class="uni-badge-left-margin"
+        :text="numData.orderCount[item.count]"
+        absolute="rightTop"
+        size="small"
+      >
+        <image class="item-icon" :src="sheep.$url.static(item.icon)" mode="aspectFit" />
+      </uni-badge>
+      <view class="menu-title ss-m-t-28">{{ item.title }}</view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  /**
+   * 装修组件 - 订单菜单组
+   */
+  import sheep from '@/common';
+  import { computed } from 'vue';
+
+  const orderMap = [
+    {
+      title: '待付款',
+      value: '1',
+      icon: '/static/img/shop/order/no_pay.png',
+      path: '/pages/order/list',
+      type: 'unpaid',
+      count: 'unpaidCount',
+    },
+    {
+      title: '待收货',
+      value: '3',
+      icon: '/static/img/shop/order/no_take.png',
+      path: '/pages/order/list',
+      type: 'noget',
+      count: 'deliveredCount',
+    },
+    {
+      title: '待评价',
+      value: '4',
+      icon: '/static/img/shop/order/no_comment.png',
+      path: '/pages/order/list',
+      type: 'nocomment',
+      count: 'uncommentedCount',
+    },
+    {
+      title: '售后单',
+      value: '0',
+      icon: '/static/img/shop/order/change_order.png',
+      path: '/pages/order/aftersale/list',
+      type: 'aftersale',
+      count: 'afterSaleCount',
+    },
+    {
+      title: '全部订单',
+      value: '0',
+      icon: '/static/img/shop/order/all_order.png',
+      path: '/pages/order/list',
+    },
+  ];
+  // 接收参数
+  const props = defineProps({
+  	// 装修数据
+  	data: {
+  	  type: Object,
+  	  default: () => ({}),
+  	},
+  	// 装修样式
+  	styles: {
+  	  type: Object,
+  	  default: () => ({}),
+  	},
+  });
+  // 设置角标
+  const numData = computed(() => sheep.$store('user').numData);
+  // 设置背景样式
+  const style = computed(() => {
+    // 直接从 props.styles 解构
+    const { bgType, bgImg, bgColor } = props.styles; 
+    // 根据 bgType 返回相应的样式
+    return {
+  		background: bgType === 'img'
+  			? `url(${bgImg}) no-repeat top center / 100% 100%`
+  			: bgColor
+  	};
+  });
+</script>
+
+<style lang="scss" scoped>
+  .ss-order-menu-wrap {
+    .menu-item {
+      height: 160rpx;
+      position: relative;
+      z-index: 10;
+      .menu-title {
+        font-size: 24rpx;
+        line-height: 24rpx;
+        color: #333333;
+      }
+      .item-icon {
+        width: 44rpx;
+        height: 44rpx;
+      }
+      .num-icon {
+        position: absolute;
+        right: 18rpx;
+        top: 18rpx;
+        // width: 40rpx;
+        padding: 0 8rpx;
+        height: 26rpx;
+        background: #ff4d4f;
+        border-radius: 13rpx;
+        color: #fefefe;
+        display: flex;
+        align-items: center;
+        .num {
+          font-size: 24rpx;
+          transform: scale(0.8);
+        }
+      }
+    }
+  }
+</style>

+ 383 - 0
common/components/s-point-card/s-point-card.vue

@@ -0,0 +1,383 @@
+<!-- 装修商品组件:【积分商城】商品卡片 -->
+<template>
+  <!-- 商品卡片 -->
+  <view>
+    <!-- 布局1. 单列大图(上图,下内容)-->
+    <view
+      v-if="state.property.layoutType === LayoutTypeEnum.ONE_COL_BIG_IMG && state.spuList.length"
+      class="goods-sl-box"
+    >
+      <view
+        class="goods-box"
+        v-for="item in state.spuList"
+        :key="item.id"
+        :style="[{ marginBottom: state.property.space * 2 + 'rpx' }]"
+      >
+        <s-goods-column
+          class=""
+          size="sl"
+          :goodsFields="state.property.fields"
+          :tagStyle="state.property.badge"
+          :data="item"
+          :titleColor="state.property.fields.name?.color"
+          :subTitleColor="state.property.fields.introduction.color"
+          :topRadius="state.property.borderRadiusTop"
+          :bottomRadius="state.property.borderRadiusBottom"
+          @click="sheep.$router.go('/pages/goods/point', { id: item.activityId })"
+        >
+          <!-- 购买按钮 -->
+          <template v-slot:cart>
+            <button class="ss-reset-button cart-btn" :style="[buyStyle]">
+              {{ state.property.btnBuy.type === 'text' ? state.property.btnBuy.text : '' }}
+            </button>
+          </template>
+        </s-goods-column>
+      </view>
+    </view>
+
+    <!-- 布局2. 单列小图(左图,右内容) -->
+    <view
+      v-if="state.property.layoutType === LayoutTypeEnum.ONE_COL_SMALL_IMG && state.spuList.length"
+      class="goods-lg-box"
+    >
+      <view
+        class="goods-box"
+        :style="[{ marginBottom: state.property.space + 'px' }]"
+        v-for="item in state.spuList"
+        :key="item.id"
+      >
+        <s-goods-column
+          class="goods-card"
+          size="lg"
+          :goodsFields="state.property.fields"
+          :data="item"
+          :tagStyle="state.property.badge"
+          :titleColor="state.property.fields.name?.color"
+          :subTitleColor="state.property.fields.introduction.color"
+          :topRadius="state.property.borderRadiusTop"
+          :bottomRadius="state.property.borderRadiusBottom"
+          @tap="sheep.$router.go('/pages/goods/point', { id: item.activityId })"
+        >
+          <!-- 购买按钮 -->
+          <template v-slot:cart>
+            <button class="ss-reset-button cart-btn" :style="[buyStyle]">
+              {{ state.property.btnBuy.type === 'text' ? state.property.btnBuy.text : '' }}
+            </button>
+          </template>
+        </s-goods-column>
+      </view>
+    </view>
+
+    <!-- 布局3. 双列(每一列:上图,下内容)-->
+    <view
+      v-if="state.property.layoutType === LayoutTypeEnum.TWO_COL && state.spuList.length"
+      class="goods-md-wrap ss-flex ss-flex-wrap ss-col-top"
+    >
+      <view class="goods-list-box">
+        <view
+          class="left-list"
+          :style="[
+            {
+              paddingRight: state.property.space + 'rpx',
+              marginBottom: state.property.space + 'px',
+            },
+          ]"
+          v-for="item in state.leftSpuList"
+          :key="item.id"
+        >
+          <s-goods-column
+            class="goods-md-box"
+            size="md"
+            :goodsFields="state.property.fields"
+            :tagStyle="state.property.badge"
+            :data="item"
+            :titleColor="state.property.fields.name?.color"
+            :subTitleColor="state.property.fields.introduction.color"
+            :topRadius="state.property.borderRadiusTop"
+            :bottomRadius="state.property.borderRadiusBottom"
+            :titleWidth="330 - marginLeft - marginRight"
+            @click="sheep.$router.go('/pages/goods/point', { id: item.activityId })"
+            @getHeight="calculateGoodsColumn($event, 'left')"
+          >
+            <!-- 购买按钮 -->
+            <template v-slot:cart>
+              <button class="ss-reset-button cart-btn" :style="[buyStyle]">
+                {{ state.property.btnBuy.type === 'text' ? state.property.btnBuy.text : '' }}
+              </button>
+            </template>
+          </s-goods-column>
+        </view>
+      </view>
+      <view class="goods-list-box">
+        <view
+          class="right-list"
+          :style="[
+            {
+              paddingLeft: state.property.space + 'rpx',
+              marginBottom: state.property.space + 'px',
+            },
+          ]"
+          v-for="item in state.rightSpuList"
+          :key="item.id"
+        >
+          <s-goods-column
+            class="goods-md-box"
+            size="md"
+            :goodsFields="state.property.fields"
+            :tagStyle="state.property.badge"
+            :data="item"
+            :titleColor="state.property.fields.name?.color"
+            :subTitleColor="state.property.fields.introduction.color"
+            :topRadius="state.property.borderRadiusTop"
+            :bottomRadius="state.property.borderRadiusBottom"
+            :titleWidth="330 - marginLeft - marginRight"
+            @click="sheep.$router.go('/pages/goods/point', { id: item.activityId })"
+            @getHeight="calculateGoodsColumn($event, 'right')"
+          >
+            <!-- 购买按钮 -->
+            <template v-slot:cart>
+              <button class="ss-reset-button cart-btn" :style="[buyStyle]">
+                {{ state.property.btnBuy.type === 'text' ? state.property.btnBuy.text : '' }}
+              </button>
+            </template>
+          </s-goods-column>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  /**
+   * 商品卡片
+   */
+  import { computed, reactive, watch } from 'vue';
+  import sheep from '@/common';
+  import SpuApi from '@/common/api/product/spu';
+  import { PromotionActivityTypeEnum } from '@/common/helper/const';
+  import { isEmpty } from '@/common/helper/utils';
+
+  // 布局类型
+  const LayoutTypeEnum = {
+    // 单列大图
+    ONE_COL_BIG_IMG: 'oneColBigImg',
+    // 双列
+    TWO_COL: 'twoCol',
+    // 单列小图
+    ONE_COL_SMALL_IMG: 'oneColSmallImg',
+  };
+
+  const state = reactive({
+    spuList: [],
+    leftSpuList: [],
+    rightSpuList: [],
+    property: {
+      layoutType: 'oneColBigImg',
+      fields: {
+        name: {
+          show: true,
+          color: '#000',
+        },
+        introduction: {
+          show: true,
+          color: '#999',
+        },
+        price: {
+          show: true,
+          color: '#ff3000',
+        },
+        marketPrice: {
+          show: true,
+          color: '#c4c4c4',
+        },
+        salesCount: {
+          show: true,
+          color: '#c4c4c4',
+        },
+        stock: {
+          show: true,
+          color: '#c4c4c4',
+        },
+      },
+      badge: {
+        show: false,
+        imgUrl: '',
+      },
+      btnBuy: {
+        type: 'text',
+        text: '立即兑换',
+        bgBeginColor: '#FF6000',
+        bgEndColor: '#FE832A',
+        imgUrl: '',
+      },
+      borderRadiusTop: 8,
+      borderRadiusBottom: 8,
+      space: 8,
+      style: {
+        bgType: 'color',
+        bgColor: '',
+        marginLeft: 8,
+        marginRight: 8,
+        marginBottom: 8,
+      },
+    },
+  });
+  const props = defineProps({
+    property: {
+      type: Object,
+      default: () => ({}),
+    },
+  });
+  // 动态更新 property
+  watch(
+    () => props.property,
+    (newVal) => {
+      state.property = { ...state.property, ...newVal };
+    },
+    { immediate: true, deep: true },
+  );
+  const { marginLeft, marginRight } = state.property.styles || {};
+
+  // 购买按钮样式
+  const buyStyle = computed(() => {
+    if (state.property.btnBuy.type === 'text') {
+      // 文字按钮:线性渐变背景颜色
+      return {
+        background: `linear-gradient(to right, ${state.property.btnBuy.bgBeginColor}, ${state.property.btnBuy.bgEndColor})`,
+      };
+    }
+    if (state.property.btnBuy.type === 'img') {
+      // 图片按钮
+      return {
+        width: '54rpx',
+        height: '54rpx',
+        background: `url(${sheep.$url.cdn(state.property.btnBuy.imgUrl)}) no-repeat`,
+        backgroundSize: '100% 100%',
+      };
+    }
+  });
+
+  //region 商品瀑布流布局
+  // 下一个要处理的商品索引
+  let count = 0;
+  // 左列的高度
+  let leftHeight = 0;
+  // 右列的高度
+  let rightHeight = 0;
+
+  /**
+   * 计算商品在左列还是右列
+   * @param height 商品的高度
+   * @param where 添加到哪一列
+   */
+  function calculateGoodsColumn(height = 0, where = 'left') {
+    // 处理完
+    if (!state.spuList[count]) return;
+    // 增加列的高度
+    if (where === 'left') leftHeight += height;
+    if (where === 'right') rightHeight += height;
+    // 添加到矮的一列
+    if (leftHeight <= rightHeight) {
+      state.leftSpuList.push(state.spuList[count]);
+    } else {
+      state.rightSpuList.push(state.spuList[count]);
+    }
+    // 计数
+    count++;
+  }
+
+  //endregion
+
+  /**
+   * 根据商品编号,获取商品详情
+   * @param ids 商品编号列表
+   * @return {Promise<undefined>} 商品列表
+   */
+  async function getSpuDetail(ids) {
+    const { data: spu } = await SpuApi.getSpuDetail(ids);
+    return spu;
+  }
+
+  async function concatActivity(list) {
+    if (isEmpty(list)) {
+      return;
+    }
+    // 循环获取活动商品SPU详情并添加到spuList
+    for (const activity of list) {
+      state.spuList.push(await getSpuDetail(activity.spuId));
+    }
+
+    // 循环活动列表
+    list.forEach((activity) => {
+      // 查找对应的 spu 并更新价格
+      const spu = state.spuList.find((spu) => activity.spuId === spu.id);
+      if (spu) {
+        spu.pointStock = activity.stock;
+        spu.pointTotalStock = activity.totalStock;
+        spu.point = activity.point;
+        spu.pointPrice = activity.price;
+        // 赋值活动ID,为了点击跳转详情页
+        spu.activityId = activity.id;
+        // 赋值活动类型
+        spu.activityType = PromotionActivityTypeEnum.POINT.type;
+      }
+    });
+    // 只有双列布局时需要
+    if (state.property.layoutType === LayoutTypeEnum.TWO_COL) {
+      // 分列
+      calculateGoodsColumn();
+    }
+  }
+  function getActivityCount() {
+    return state.spuList.length;
+  }
+  defineExpose({ concatActivity, getActivityCount, calculateGoodsColumn });
+</script>
+
+<style lang="scss" scoped>
+  .goods-md-wrap {
+    width: 100%;
+  }
+
+  .goods-list-box {
+    width: 50%;
+    box-sizing: border-box;
+
+    .left-list {
+      &:nth-last-child(1) {
+        margin-bottom: 0 !important;
+      }
+    }
+
+    .right-list {
+      &:nth-last-child(1) {
+        margin-bottom: 0 !important;
+      }
+    }
+  }
+
+  .goods-box {
+    &:nth-last-of-type(1) {
+      margin-bottom: 0 !important;
+    }
+  }
+
+  .goods-md-box,
+  .goods-sl-box,
+  .goods-lg-box {
+    position: relative;
+
+    .cart-btn {
+      position: absolute;
+      bottom: 18rpx;
+      right: 20rpx;
+      z-index: 11;
+      height: 50rpx;
+      line-height: 50rpx;
+      padding: 0 20rpx;
+      border-radius: 25rpx;
+      font-size: 24rpx;
+      color: #fff;
+    }
+  }
+</style>

+ 85 - 0
common/components/s-popup-image/s-popup-image.vue

@@ -0,0 +1,85 @@
+<template>
+  <view>
+    <view v-for="(item, index) in popupList" :key="index">
+      <su-popup
+        v-if="index === currentIndex"
+        :show="item.isShow"
+        type="center"
+        backgroundColor="none"
+        round="0"
+        :showClose="true"
+        :isMaskClick="false"
+        @close="onClose(index)"
+      >
+        <view class="img-box">
+          <image
+            class="modal-img"
+            :src="sheep.$url.cdn(item.imgUrl)"
+            mode="widthFix"
+            @tap.stop="onPopup(item.url)"
+          />
+        </view>
+      </su-popup>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  import sheep from '@/common';
+  import { computed, ref } from 'vue';
+  import { saveAdvHistory } from '@/common/hooks/useModal';
+
+  // 定义属性
+  const props = defineProps({
+    data: {
+      type: Object,
+      default() {},
+    }
+  })
+
+  // const modalStore = sheep.$store('modal');
+  const modalStore = JSON.parse(uni.getStorageSync('modal-store') || '{}');
+  console.log(modalStore)
+  const advHistory = modalStore.advHistory || [];
+  const currentIndex = ref(0);
+  const popupList = computed(() => {
+    const list = props.data.list || [];
+    const newList = [];
+    if (list.length > 0) {
+      list.forEach((adv) => {
+        if (adv.showType === 'once' && advHistory.includes(adv.imgUrl)) {
+          adv.isShow = false;
+        } else {
+          adv.isShow = true;
+          newList.push(adv);
+        }
+
+        // 记录弹窗已显示过
+        saveAdvHistory(adv);
+      });
+    }
+    return newList;
+  });
+
+  // 跳转链接
+  function onPopup(path) {
+    sheep.$router.go(path);
+  }
+
+  // 关闭
+  function onClose(index) {
+    currentIndex.value = index + 1;
+    popupList.value[index].isShow = false;
+  }
+</script>
+
+<style lang="scss" scoped>
+  .img-box {
+    width: 610rpx;
+    // height: 800rpx;
+  }
+  .modal-img {
+    width: 100%;
+    height: 100%;
+  }
+</style>

+ 164 - 0
common/components/s-search-block/s-search-block.vue

@@ -0,0 +1,164 @@
+<template>
+  <view
+    class="search-content ss-flex ss-col-center ss-row-between"
+    @tap="click"
+    :style="[
+      {
+        borderRadius: radius + 'px',
+        background: elBackground,
+        height: height + 'px',
+        width: width,
+      },
+    ]"
+    :class="[{ 'border-content': navbar }]"
+  >
+    <view class="ss-flex ss-col-center" v-if="navbar">
+      <view class="search-icon _icon-search ss-m-l-10" :style="[{ color: props.iconColor }]"></view>
+      <view class="search-input ss-flex-1 ss-line-1" :style="[{ color: fontColor, width: width }]">
+        {{ placeholder }}
+      </view>
+    </view>
+    <uni-search-bar
+      v-if="!navbar"
+      class="ss-flex-1"
+      :radius="data.borderRadius"
+      :placeholder="data.placeholder"
+      cancelButton="none"
+      clearButton="none"
+      @confirm="onSearch"
+      v-model="state.searchVal"
+    />
+    <view class="keyword-link ss-flex">
+      <view v-for="(item, index) in data.hotKeywords" :key="index">
+        <view
+          class="ss-m-r-16"
+          :style="[{ color: data.textColor }]"
+          @tap.stop="sheep.$router.go('/pages/goods/list', { keyword: item })"
+          >{{ item }}</view
+        >
+      </view>
+    </view>
+    <view v-if="data.hotKeywords && data.hotKeywords.length && navbar" class="ss-flex">
+      <button
+        class="ss-reset-button keyword-btn"
+        v-for="(item, index) in data.hotKeywords"
+        :key="index"
+        :style="[{ color: data.textColor, marginRight: '10rpx' }]"
+      >
+        {{ item }}
+      </button>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  /**
+   * 基础组件 - 搜索栏
+   *
+   * @property {String} elBackground 			- 输入框背景色
+   * @property {String} iconColor 			- 图标颜色
+   * @property {String} fontColor 		  	- 字体颜色
+   * @property {Number} placeholder 			- 默认placeholder
+   * @property {Number} topRadius 			- 组件上圆角
+   * @property {Number} bottomRadius 			- 组件下圆角
+   *
+   * @slot keywords							- 关键字
+   * @event {Function} click 					- 点击组件时触发
+   */
+
+  import { computed, reactive } from 'vue';
+  import sheep from '@/common';
+
+  // 组件数据
+  const state = reactive({
+    searchVal: '',
+  });
+
+  // 事件页面
+  const emits = defineEmits(['click']);
+
+  // 接收参数
+  const props = defineProps({
+    data: {
+      type: Object,
+      default: () => ({}),
+    },
+    // 输入框背景色
+    elBackground: {
+      type: String,
+      default: '',
+    },
+    height: {
+      type: Number,
+      default: 36,
+    },
+    // 图标颜色
+    iconColor: {
+      type: String,
+      default: '#b0b3bf',
+    },
+    // 字体颜色
+    fontColor: {
+      type: String,
+      default: '#b0b3bf',
+    },
+    // placeholder
+    placeholder: {
+      type: String,
+      default: '这是一个搜索框',
+    },
+    radius: {
+      type: Number,
+      default: 10,
+    },
+    width: {
+      type: String,
+      default: '100%',
+    },
+    navbar: {
+      type: Boolean,
+      default: true,
+    },
+  });
+
+  // 点击
+  const click = () => {
+    emits('click');
+  };
+
+  function onSearch(e) {
+    if (e.value) {
+      sheep.$router.go('/pages/goods/list', { keyword: e.value });
+      setTimeout(() => {
+        state.searchVal = '';
+      }, 100);
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  .border-content {
+    border: 2rpx solid #eee;
+  }
+
+  .search-content {
+    flex: 1;
+    // height: 80rpx;
+    position: relative;
+
+    .search-icon {
+      font-size: 38rpx;
+      margin-right: 20rpx;
+    }
+
+    .keyword-link {
+      position: absolute;
+      right: 16rpx;
+      top: 18rpx;
+    }
+
+    .search-input {
+      font-size: 28rpx;
+    }
+  }
+</style>

+ 508 - 0
common/components/s-select-groupon-sku/s-select-groupon-sku.vue

@@ -0,0 +1,508 @@
+<template>
+  <!-- 拼团商品规格弹窗 -->
+  <su-popup :show="show" round="10" @close="emits('close')">
+    <!-- SKU 信息 -->
+    <view class="ss-modal-box bg-white ss-flex-col">
+      <view class="modal-header ss-flex ss-col-center">
+        <view class="header-left ss-m-r-30">
+          <image
+            class="sku-image"
+            :src="sheep.$url.cdn(state.selectedSku.picUrl || goodsInfo.picUrl)"
+            mode="aspectFill"
+          />
+        </view>
+        <view class="header-right ss-flex-col ss-row-between ss-flex-1">
+          <view class="goods-title ss-line-2">
+            <view class="tig ss-flex ss-col-center">
+              <view class="tig-icon ss-flex ss-col-center ss-row-center">
+                <view class="groupon-tag">
+                  <image :src="sheep.$url.static('/static/img/shop/goods/groupon-tag-white.png')" />
+                </view>
+              </view>
+              <view class="tig-title">拼团价</view>
+            </view>
+            <view class="info-title">
+              {{ goodsInfo.name }}
+            </view>
+          </view>
+          <view class="header-right-bottom ss-flex ss-col-center ss-row-between">
+            <view class="price-text">
+              {{
+                fen2yuan(
+                  state.selectedSku.price || goodsInfo.price || state.selectedSku.marketPrice,
+                )
+              }}</view
+            >
+
+            <view class="stock-text ss-m-l-20">
+              库存{{ state.selectedSku.stock || goodsInfo.stock }}件
+            </view>
+          </view>
+        </view>
+      </view>
+      <view class="modal-content ss-flex-1">
+        <scroll-view scroll-y="true" class="modal-content-scroll">
+          <view class="sku-item ss-m-b-20" v-for="property in propertyList" :key="property.id">
+            <view class="label-text ss-m-b-20">{{ property.name }}</view>
+            <view class="ss-flex ss-col-center ss-flex-wrap">
+              <button
+                class="ss-reset-button spec-btn"
+                v-for="value in property.values"
+                :class="[
+                  {
+                    'checked-btn': state.currentPropertyArray[property.id] === value.id,
+                  },
+                  {
+                    'disabled-btn': value.disabled === true,
+                  },
+                ]"
+                :key="value.id"
+                :disabled="value.disabled === true"
+                @tap="onSelectSku(property.id, value.id)"
+              >
+                {{ value.name }}
+              </button>
+            </view>
+          </view>
+          <view class="buy-num-box ss-flex ss-col-center ss-row-between">
+            <view class="label-text">购买数量</view>
+            <su-number-box
+              :min="1"
+              :max="state.selectedSku.stock"
+              :step="1"
+              v-model="state.selectedSku.count"
+              @change="onNumberChange($event)"
+              activity="groupon"
+            />
+          </view>
+        </scroll-view>
+      </view>
+
+      <!-- 操作区 -->
+      <view class="modal-footer ss-p-y-20">
+        <view class="buy-box ss-flex ss-col-center ss-flex ss-col-center ss-row-center">
+          <view class="ss-flex">
+            <button class="ss-reset-button origin-price-btn ss-flex-col">
+              <view class="btn-title">{{ grouponNum + '人团' }}</view>
+            </button>
+            <button class="ss-reset-button btn-tox ss-flex-col" @tap="onBuy">
+              <view class="btn-price">
+                {{
+                  fen2yuan(
+                    state.selectedSku.price * state.selectedSku.count ||
+                      goodsInfo.price * state.selectedSku.count ||
+                      state.selectedSku.marketPrice * state.selectedSku.count ||
+                      goodsInfo.price,
+                  )
+                }}
+              </view>
+              <view v-if="grouponAction === 'create'">立即开团</view>
+              <view v-else-if="grouponAction === 'join'">参与拼团</view>
+            </button>
+          </view>
+        </view>
+      </view>
+    </view>
+  </su-popup>
+</template>
+
+<script setup>
+  import { computed, reactive, watch } from 'vue';
+  import sheep from '@/common';
+  import { convertProductPropertyList, fen2yuan } from '@/common/hooks/useGoods';
+
+  const headerBg = sheep.$url.css('/static/img/shop/goods/groupon-btn-long.png');
+  const emits = defineEmits(['change', 'addCart', 'buy', 'close', 'ladder']);
+  const props = defineProps({
+    show: {
+      type: Boolean,
+      default: false,
+    },
+    goodsInfo: {
+      type: Object,
+      default() {},
+    },
+    grouponAction: {
+      type: String,
+      default: 'create',
+    },
+    grouponNum: {
+      type: [Number, String],
+      default: 0,
+    },
+  });
+  const state = reactive({
+    selectedSku: {}, // 选中的 SKU
+    currentPropertyArray: {}, // 当前选中的属性,实际是个 Map。key 是 property 编号,value 是 value 编号
+    grouponNum: props.grouponNum,
+  });
+
+  const propertyList = convertProductPropertyList(props.goodsInfo.skus);
+
+  // SKU 列表
+  const skuList = computed(() => {
+    let skuPrices = props.goodsInfo.skus;
+    for (let price of skuPrices) {
+      price.value_id_array = price.properties.map((item) => item.valueId);
+    }
+    return skuPrices;
+  });
+
+  watch(
+    () => state.selectedSku,
+    (newVal) => {
+      emits('change', newVal);
+    },
+    {
+      immediate: true, // 立即执行
+      deep: true, // 深度监听
+    },
+  );
+
+  // 输入框改变数量
+  function onNumberChange(e) {
+    if (e === 0) return;
+    if (state.selectedSku.count === e) return;
+    state.selectedSku.count = e;
+  }
+
+  // 点击购买
+  function onBuy() {
+    if (!state.selectedSku.id || state.selectedSku.id <= 0) {
+      sheep.$helper.toast('请选择规格');
+      return;
+    }
+    if (state.selectedSku.stock <= 0) {
+      sheep.$helper.toast('库存不足');
+      return;
+    }
+    emits('buy', state.selectedSku);
+  }
+
+  // 改变禁用状态:计算每个 property 属性值的按钮,是否禁用
+  function changeDisabled(isChecked = false, propertyId = 0, valueId = 0) {
+    let newSkus = []; // 所有可以选择的 sku 数组
+    if (isChecked) {
+      // 情况一:选中 property
+      // 获得当前点击选中 property 的、所有可用 SKU
+      for (let price of skuList.value) {
+        if (price.stock <= 0) {
+          continue;
+        }
+        if (price.value_id_array.indexOf(valueId) >= 0) {
+          newSkus.push(price);
+        }
+      }
+    } else {
+      // 情况二:取消选中 property
+      // 当前所选 property 下,所有可以选择的 SKU
+      newSkus = getCanUseSkuList();
+    }
+
+    // 所有存在并且有库存未选择的 SKU 的 value 属性值 id
+    let noChooseValueIds = [];
+    for (let price of newSkus) {
+      noChooseValueIds = noChooseValueIds.concat(price.value_id_array);
+    }
+    noChooseValueIds = Array.from(new Set(noChooseValueIds)); // 去重
+
+    if (isChecked) {
+      // 去除当前选中的 value 属性值 id
+      let index = noChooseValueIds.indexOf(valueId);
+      noChooseValueIds.splice(index, 1);
+    } else {
+      // 循环去除当前已选择的 value 属性值 id
+      Object.entries(state.currentPropertyArray).forEach(([propertyId, currentPropertyId]) => {
+        if (currentPropertyId.toString() !== '') {
+          return;
+        }
+        // currentPropertyId 为空是反选 填充的
+        let index = noChooseValueIds.indexOf(currentPropertyId);
+        if (index >= 0) {
+          // currentPropertyId 存在于 noChooseValueIds
+          noChooseValueIds.splice(index, 1);
+        }
+      });
+    }
+
+    // 当前已选择的 property 数组
+    let choosePropertyIds = [];
+    if (!isChecked) {
+      // 当前已选择的 property
+      Object.entries(state.currentPropertyArray).forEach(([propertyId, currentValueId]) => {
+        if (currentValueId !== '') {
+          // currentPropertyId 为空是反选 填充的
+          choosePropertyIds.push(currentValueId);
+        }
+      });
+    } else {
+      // 当前点击选择的 property
+      choosePropertyIds = [propertyId];
+    }
+
+    for (let propertyIndex in propertyList) {
+      // 当前点击的 property、或者取消选择时候,已选中的 property 不进行处理
+      if (choosePropertyIds.indexOf(propertyList[propertyIndex]['id']) >= 0) {
+        continue;
+      }
+      // 如果当前 property id 不存在于有库存的 SKU 中,则禁用
+      for (let valueIndex in propertyList[propertyIndex]['values']) {
+        propertyList[propertyIndex]['values'][valueIndex]['disabled'] =
+          noChooseValueIds.indexOf(propertyList[propertyIndex]['values'][valueIndex]['id']) < 0; // true 禁用 or false 不禁用
+      }
+    }
+  }
+
+  // 当前所选属性下,获取所有有库存的 SKU 们
+  function getCanUseSkuList() {
+    let newSkus = [];
+    for (let sku of skuList.value) {
+      if (sku.stock <= 0) {
+        continue;
+      }
+      let isOk = true;
+      Object.entries(state.currentPropertyArray).forEach(([propertyId, valueId]) => {
+        // valueId 不为空,并且,这个 条 sku 没有被选中,则排除
+        if (valueId.toString() !== '' && sku.value_id_array.indexOf(valueId) < 0) {
+          isOk = false;
+        }
+      });
+      if (isOk) {
+        newSkus.push(sku);
+      }
+    }
+    return newSkus;
+  }
+
+  // 选择规格
+  function onSelectSku(propertyId, valueId) {
+    // 清空已选择
+    let isChecked = true; // 选中 or 取消选中
+    if (
+      state.currentPropertyArray[propertyId] !== undefined &&
+      state.currentPropertyArray[propertyId] === valueId
+    ) {
+      // 点击已被选中的,删除并填充 ''
+      isChecked = false;
+      state.currentPropertyArray.splice(propertyId, 1, '');
+    } else {
+      // 选中
+      state.currentPropertyArray[propertyId] = valueId;
+    }
+
+    // 选中的 property 大类
+    let choosePropertyId = [];
+    Object.entries(state.currentPropertyArray).forEach(([propertyId, currentPropertyId]) => {
+      if (currentPropertyId !== '') {
+        // currentPropertyId 为空是反选 填充的
+        choosePropertyId.push(currentPropertyId);
+      }
+    });
+
+    // 当前所选 property 下,所有可以选择的 SKU 们
+    let newSkuList = getCanUseSkuList();
+
+    // 判断所有 property 大类是否选择完成
+    if (choosePropertyId.length === propertyList.length && newSkuList.length) {
+      newSkuList[0].count = state.selectedSku.count || 1;
+      state.selectedSku = newSkuList[0];
+    } else {
+      state.selectedSku = {};
+    }
+
+    // 改变 property 禁用状态
+    changeDisabled(isChecked, propertyId, valueId);
+  }
+
+  changeDisabled(false);
+  // TODO 芋艿:待讨论的优化点:1)单规格,要不要默认选中;2)默认要不要选中第一个规格
+</script>
+
+<style lang="scss" scoped>
+  // 购买
+  .buy-btn {
+    margin: 0 20rpx;
+    width: 100%;
+    height: 80rpx;
+    border-radius: 40rpx;
+    background: linear-gradient(90deg, #ff6000, #fe832a);
+    color: #fff;
+  }
+  .btn-tox {
+    width: 382rpx;
+    height: 80rpx;
+    font-size: 24rpx;
+    font-weight: 600;
+    margin-left: -50rpx;
+    background-image: v-bind(headerBg);
+    background-repeat: no-repeat;
+    background-size: 100% 100%;
+    color: #ffffff;
+    line-height: normal;
+    border-radius: 0px 40rpx 40rpx 0px;
+
+    .btn-price {
+      font-family: OPPOSANS;
+
+      &::before {
+        content: '¥';
+      }
+    }
+  }
+  .origin-price-btn {
+    width: 370rpx;
+    height: 80rpx;
+    background: rgba(#ff5651, 0.1);
+    color: #ff6000;
+    border-radius: 40rpx 0px 0px 40rpx;
+    line-height: normal;
+    font-size: 24rpx;
+    font-weight: 500;
+
+    .btn-price {
+      font-family: OPPOSANS;
+
+      &::before {
+        content: '¥';
+      }
+    }
+
+    .btn-title {
+      font-size: 28rpx;
+    }
+  }
+
+  .ss-modal-box {
+    border-radius: 30rpx 30rpx 0 0;
+    max-height: 1000rpx;
+
+    .modal-header {
+      position: relative;
+      padding: 80rpx 20rpx 40rpx;
+
+      .sku-image {
+        width: 160rpx;
+        height: 160rpx;
+        border-radius: 10rpx;
+      }
+
+      .header-right {
+        height: 160rpx;
+      }
+
+      .close-icon {
+        position: absolute;
+        top: 10rpx;
+        right: 20rpx;
+        font-size: 46rpx;
+        opacity: 0.2;
+      }
+
+      .goods-title {
+        font-size: 28rpx;
+        font-weight: 500;
+        line-height: 42rpx;
+        position: relative;
+        .tig {
+          border: 2rpx solid #ff6000;
+          border-radius: 4rpx;
+          width: 126rpx;
+          height: 38rpx;
+          position: absolute;
+          left: 0;
+          top: 0;
+
+          .tig-icon {
+            width: 40rpx;
+            height: 40rpx;
+            background: #ff6000;
+            margin-left: -2rpx;
+            border-radius: 4rpx 0 0 4rpx;
+
+            .groupon-tag {
+              width: 32rpx;
+              height: 32rpx;
+            }
+          }
+
+          .tig-title {
+            font-size: 24rpx;
+            font-weight: 500;
+            line-height: normal;
+            color: #ff6000;
+            width: 86rpx;
+            display: flex;
+            justify-content: center;
+            align-items: center;
+          }
+        }
+        .info-title {
+          text-indent: 132rpx;
+        }
+      }
+
+      .price-text {
+        font-size: 30rpx;
+        font-weight: 500;
+        color: $red;
+        font-family: OPPOSANS;
+
+        &::before {
+          content: '¥';
+          font-size: 24rpx;
+        }
+      }
+
+      .stock-text {
+        font-size: 26rpx;
+        color: #999999;
+      }
+    }
+
+    .modal-content {
+      padding: 0 20rpx;
+
+      .modal-content-scroll {
+        max-height: 600rpx;
+
+        .label-text {
+          font-size: 26rpx;
+          font-weight: 500;
+        }
+
+        .buy-num-box {
+          height: 100rpx;
+        }
+
+        .spec-btn {
+          height: 60rpx;
+          min-width: 100rpx;
+          padding: 0 30rpx;
+          background: #f4f4f4;
+          border-radius: 30rpx;
+          color: #434343;
+          font-size: 26rpx;
+          margin-right: 10rpx;
+          margin-bottom: 10rpx;
+        }
+
+        .checked-btn {
+          background: linear-gradient(90deg, #ff6000, #fe832a);
+          font-weight: 500;
+          color: #ffffff;
+        }
+
+        .disabled-btn {
+          font-weight: 400;
+          color: #c6c6c6;
+          background: #f8f8f8;
+        }
+      }
+    }
+  }
+
+  image {
+    width: 100%;
+    height: 100%;
+  }
+</style>

+ 453 - 0
common/components/s-select-seckill-sku/s-select-seckill-sku.vue

@@ -0,0 +1,453 @@
+<!-- 秒杀商品的 SKU 选择,和 s-select-sku.vue 类似 -->
+<template>
+  <!-- 规格弹窗 -->
+  <su-popup :show="show" round="10" @close="emits('close')">
+    <!-- SKU 信息 -->
+    <view class="ss-modal-box bg-white ss-flex-col">
+      <view class="modal-header ss-flex ss-col-center">
+        <!-- 规格图片 -->
+        <view class="header-left ss-m-r-30">
+          <image
+            class="sku-image"
+            :src="sheep.$url.cdn(state.selectedSku.picUrl || state.goodsInfo.picUrl)"
+            mode="aspectFill"
+          >
+          </image>
+        </view>
+        <view class="header-right ss-flex-col ss-row-between ss-flex-1">
+          <!-- 名称 -->
+          <view class="goods-title ss-line-2">{{ state.goodsInfo.name }}</view>
+          <view class="header-right-bottom ss-flex ss-col-center ss-row-between">
+            <!-- 价格 -->
+            <view
+              v-if="state.goodsInfo.activity_type === PromotionActivityTypeEnum.POINT.type"
+              class="price-text ss-flex"
+            >
+              <image
+                v-if="!isEmpty(state.selectedSku)"
+                :src="sheep.$url.static('/static/img/shop/goods/score1.svg')"
+                class="point-img"
+              ></image>
+              <text class="point-text ss-m-r-16">
+                {{ getShowPriceText }}
+              </text>
+            </view>
+            <view v-else class="price-text">
+              ¥{{ fen2yuan(state.selectedSku.price || state.goodsInfo.price) }}
+            </view>
+            <!-- 秒杀价格标签 -->
+            <view class="tig ss-flex ss-col-center">
+              <view class="tig-icon ss-flex ss-col-center ss-row-center">
+                <text class="cicon-alarm"></text>
+              </view>
+              <view class="tig-title">秒杀价</view>
+            </view>
+            <!-- 库存 -->
+            <view class="stock-text ss-m-l-20">
+              库存{{ state.selectedSku.stock || state.goodsInfo.stock }}件
+            </view>
+          </view>
+        </view>
+      </view>
+      <view class="modal-content ss-flex-1">
+        <scroll-view scroll-y="true" class="modal-content-scroll">
+          <view class="sku-item ss-m-b-20" v-for="property in propertyList" :key="property.id">
+            <view class="label-text ss-m-b-20">{{ property.name }}</view>
+            <view class="ss-flex ss-col-center ss-flex-wrap">
+              <button
+                class="ss-reset-button spec-btn"
+                v-for="value in property.values"
+                :class="[
+                  {
+                    'checked-btn': state.currentPropertyArray[property.id] === value.id,
+                  },
+                  {
+                    'disabled-btn': value.disabled === true,
+                  },
+                ]"
+                :key="value.id"
+                :disabled="value.disabled === true"
+                @tap="onSelectSku(property.id, value.id)"
+              >
+                {{ value.name }}
+              </button>
+            </view>
+          </view>
+          <view class="buy-num-box ss-flex ss-col-center ss-row-between">
+            <view class="label-text">购买数量</view>
+            <su-number-box
+              :min="1"
+              :max="min([singleLimitCount, state.selectedSku.stock])"
+              :step="1"
+              v-model="state.selectedSku.count"
+              @change="onBuyCountChange($event)"
+              activity="seckill"
+            ></su-number-box>
+          </view>
+        </scroll-view>
+      </view>
+      <view class="modal-footer">
+        <view class="buy-box ss-flex ss-col-center ss-flex ss-col-center ss-row-center">
+          <button class="ss-reset-button buy-btn" @tap="onBuy">确认</button>
+        </view>
+      </view>
+    </view>
+  </su-popup>
+</template>
+
+<script setup>
+  /**
+   * 秒杀活动SKU选择,
+   * 与s-select-sku的区别:多一个秒杀价的标签、没有加入购物车按钮、立即购买按钮叫确认、秒杀有最大购买数量限制
+   */
+  // 按钮状态: active,nostock
+  import { computed, reactive, watch } from 'vue';
+  import sheep from '@/common';
+  import { convertProductPropertyList, fen2yuan } from '@/common/hooks/useGoods';
+  import { isEmpty, min } from 'lodash-es';
+  import { PromotionActivityTypeEnum } from '@/common/helper/const';
+
+  const emits = defineEmits(['change', 'addCart', 'buy', 'close']);
+  const props = defineProps({
+    modelValue: {
+      type: Object,
+      default() {},
+    },
+    show: {
+      type: Boolean,
+      default: false,
+    },
+    // 单次限购数量
+    singleLimitCount: {
+      type: Number,
+      default: 1,
+    },
+  });
+  const state = reactive({
+    goodsInfo: computed(() => props.modelValue),
+    selectedSku: {},
+    currentPropertyArray: {},
+  });
+  const getShowPriceText = computed(() => {
+    let priceText = `¥${fen2yuan(state.goodsInfo.price)}`;
+    if (!isEmpty(state.selectedSku)) {
+      const sku = state.selectedSku;
+      priceText = `${sku.point}${!sku.pointPrice ? '' : `+¥${fen2yuan(sku.pointPrice)}`}`;
+    }
+    return priceText;
+  });
+  const propertyList = convertProductPropertyList(state.goodsInfo.skus);
+  // SKU 列表
+  const skuList = computed(() => {
+    let skuPrices = state.goodsInfo.skus;
+    for (let price of skuPrices) {
+      price.value_id_array = price.properties.map((item) => item.valueId);
+    }
+    return skuPrices;
+  });
+
+  watch(
+    () => state.selectedSku,
+    (newVal) => {
+      emits('change', newVal);
+    },
+    {
+      immediate: true, // 立即执行
+      deep: true, // 深度监听
+    },
+  );
+
+  const onBuy = () => {
+    if (state.selectedSku.id) {
+      if (state.selectedSku.stock <= 0) {
+        sheep.$helper.toast('库存不足');
+      } else {
+        emits('buy', state.selectedSku);
+      }
+    } else {
+      sheep.$helper.toast('请选择规格');
+    }
+  };
+
+  // 购买数量改变
+  function onBuyCountChange(buyCount) {
+    if (buyCount > 0 && state.selectedSku.count !== buyCount) {
+      state.selectedSku.count = buyCount;
+    }
+  }
+
+  // 改变禁用状态
+  const changeDisabled = (isChecked = false, propertyId = 0, valueId = 0) => {
+    let newSkus = []; // 所有可以选择的 sku 数组
+    if (isChecked) {
+      // 情况一:选中 property
+      // 获得当前点击选中 property 的、所有可用 SKU
+      for (let price of skuList.value) {
+        if (price.stock <= 0) {
+          continue;
+        }
+        if (price.value_id_array.indexOf(valueId) >= 0) {
+          newSkus.push(price);
+        }
+      }
+    } else {
+      // 情况二:取消选中 property
+      // 当前所选 property 下,所有可以选择的 SKU
+      newSkus = getCanUseSkuList();
+    }
+
+    // 所有存在并且有库存未选择的 SKU 的 value 属性值 id
+    let noChooseValueIds = [];
+    for (let price of newSkus) {
+      noChooseValueIds = noChooseValueIds.concat(price.value_id_array);
+    }
+    noChooseValueIds = Array.from(new Set(noChooseValueIds)); // 去重
+
+    if (isChecked) {
+      // 去除当前选中的 value 属性值 id
+      let index = noChooseValueIds.indexOf(valueId);
+      noChooseValueIds.splice(index, 1);
+    } else {
+      // 循环去除当前已选择的 value 属性值 id
+      Object.entries(state.currentPropertyArray).forEach(([propertyId, currentPropertyId]) => {
+        if (currentPropertyId.toString() !== '') {
+          return;
+        }
+        // currentPropertyId 为空是反选 填充的
+        let index = noChooseValueIds.indexOf(currentPropertyId);
+        if (index >= 0) {
+          // currentPropertyId 存在于 noChooseValueIds
+          noChooseValueIds.splice(index, 1);
+        }
+      });
+    }
+
+    // 当前已选择的 property 数组
+    let choosePropertyIds = [];
+    if (!isChecked) {
+      // 当前已选择的 property
+      Object.entries(state.currentPropertyArray).forEach(([propertyId, currentValueId]) => {
+        if (currentValueId !== '') {
+          // currentPropertyId 为空是反选 填充的
+          choosePropertyIds.push(currentValueId);
+        }
+      });
+    } else {
+      // 当前点击选择的 property
+      choosePropertyIds = [propertyId];
+    }
+
+    for (let propertyIndex in propertyList) {
+      // 当前点击的 property、或者取消选择时候,已选中的 property 不进行处理
+      if (choosePropertyIds.indexOf(propertyList[propertyIndex]['id']) >= 0) {
+        continue;
+      }
+      // 如果当前 property id 不存在于有库存的 SKU 中,则禁用
+      for (let valueIndex in propertyList[propertyIndex]['values']) {
+        propertyList[propertyIndex]['values'][valueIndex]['disabled'] =
+          noChooseValueIds.indexOf(propertyList[propertyIndex]['values'][valueIndex]['id']) < 0; // true 禁用 or false 不禁用
+      }
+    }
+  };
+
+  // 获取可用的(有库存的)SKU 列表
+  const getCanUseSkuList = () => {
+    let newSkus = [];
+    for (let sku of skuList.value) {
+      if (sku.stock <= 0) {
+        continue;
+      }
+      let isOk = true;
+      Object.entries(state.currentPropertyArray).forEach(([propertyId, valueId]) => {
+        // valueId 不为空,并且,这个 条 sku 没有被选中,则排除
+        if (valueId.toString() !== '' && sku.value_id_array.indexOf(valueId) < 0) {
+          isOk = false;
+        }
+      });
+      if (isOk) {
+        newSkus.push(sku);
+      }
+    }
+    return newSkus;
+  };
+
+  // 选择规格
+  const onSelectSku = (propertyId, valueId) => {
+    // 清空已选择
+    let isChecked = true; // 选中 or 取消选中
+    if (
+      state.currentPropertyArray[propertyId] !== undefined &&
+      state.currentPropertyArray[propertyId] === valueId
+    ) {
+      // 点击已被选中的,删除并填充 ''
+      isChecked = false;
+      state.currentPropertyArray.splice(propertyId, 1, '');
+    } else {
+      // 选中
+      state.currentPropertyArray[propertyId] = valueId;
+    }
+
+    // 选中的 property 大类
+    let choosePropertyId = [];
+    Object.entries(state.currentPropertyArray).forEach(([propertyId, currentPropertyId]) => {
+      if (currentPropertyId !== '') {
+        // currentPropertyId 为空是反选 填充的
+        choosePropertyId.push(currentPropertyId);
+      }
+    });
+
+    // 当前所选 property 下,所有可以选择的 SKU 们
+    let newSkuList = getCanUseSkuList();
+
+    // 判断所有 property 大类是否选择完成
+    if (choosePropertyId.length === propertyList.length && newSkuList.length) {
+      newSkuList[0].count = state.selectedSku.count || 1;
+      state.selectedSku = newSkuList[0];
+    } else {
+      state.selectedSku = {};
+    }
+
+    // 改变 property 禁用状态
+    changeDisabled(isChecked, propertyId, valueId);
+  };
+
+  changeDisabled(false);
+</script>
+
+<style lang="scss" scoped>
+  // 购买
+  .buy-box {
+    padding: 10rpx 20rpx;
+
+    .buy-btn {
+      width: 100%;
+      height: 80rpx;
+      border-radius: 40rpx;
+      background: linear-gradient(90deg, #ff5854, #ff2621);
+      color: #fff;
+    }
+  }
+
+  .point-img {
+    width: 36rpx;
+    height: 36rpx;
+    margin: 0 4rpx;
+  }
+
+  .ss-modal-box {
+    border-radius: 30rpx 30rpx 0 0;
+    max-height: 1000rpx;
+
+    .modal-header {
+      position: relative;
+      padding: 80rpx 20rpx 40rpx;
+
+      .sku-image {
+        width: 160rpx;
+        height: 160rpx;
+        border-radius: 10rpx;
+      }
+
+      .header-right {
+        height: 160rpx;
+      }
+
+      .close-icon {
+        position: absolute;
+        top: 10rpx;
+        right: 20rpx;
+        font-size: 46rpx;
+        opacity: 0.2;
+      }
+
+      .goods-title {
+        font-size: 28rpx;
+        font-weight: 500;
+        line-height: 42rpx;
+      }
+
+      .price-text {
+        font-size: 30rpx;
+        font-weight: 500;
+        color: $red;
+        font-family: OPPOSANS;
+      }
+
+      .stock-text {
+        font-size: 26rpx;
+        color: #999999;
+      }
+    }
+
+    .modal-content {
+      padding: 0 20rpx;
+
+      .modal-content-scroll {
+        max-height: 600rpx;
+
+        .label-text {
+          font-size: 26rpx;
+          font-weight: 500;
+        }
+
+        .buy-num-box {
+          height: 100rpx;
+        }
+
+        .spec-btn {
+          height: 60rpx;
+          min-width: 100rpx;
+          padding: 0 30rpx;
+          background: #f4f4f4;
+          border-radius: 30rpx;
+          color: #434343;
+          font-size: 26rpx;
+          margin-right: 10rpx;
+          margin-bottom: 10rpx;
+        }
+
+        .checked-btn {
+          background: linear-gradient(90deg, #ff5854, #ff2621);
+          font-weight: 500;
+          color: #ffffff;
+        }
+
+        .disabled-btn {
+          font-weight: 400;
+          color: #c6c6c6;
+          background: #f8f8f8;
+        }
+      }
+    }
+  }
+
+  .tig {
+    border: 2rpx solid #ff5854;
+    border-radius: 4rpx;
+    width: 126rpx;
+    height: 38rpx;
+
+    .tig-icon {
+      width: 40rpx;
+      height: 40rpx;
+      background: #ff5854;
+      border-radius: 4rpx 0 0 4rpx;
+
+      .cicon-alarm {
+        font-size: 32rpx;
+        color: #fff;
+      }
+    }
+
+    .tig-title {
+      font-size: 24rpx;
+      font-weight: 500;
+      line-height: normal;
+      color: #ff6000;
+      width: 86rpx;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+    }
+  }
+</style>

+ 464 - 0
common/components/s-select-sku/s-select-sku.vue

@@ -0,0 +1,464 @@
+<template>
+  <!-- 规格弹窗 -->
+  <su-popup :show="show" round="10" @close="emits('close')">
+    <!-- SKU 信息 -->
+    <view class="ss-modal-box bg-white ss-flex-col">
+      <view class="modal-header ss-flex ss-col-center">
+        <view class="header-left ss-m-r-30">
+          <image
+            class="sku-image"
+            :src="state.selectedSku.picUrl || goodsInfo.picUrl"
+            mode="aspectFill"
+          />
+        </view>
+        <view class="header-right ss-flex-col ss-row-between ss-flex-1">
+          <view class="goods-title ss-line-2">{{ goodsInfo.name }}</view>
+          <view class="header-right-bottom ss-flex ss-col-center ss-row-between">
+            <view class="ss-flex">
+              <view class="price-text">
+                {{
+                  fen2yuan(
+                    state.selectedSku.promotionPrice || state.selectedSku.price || goodsInfo.price,
+                  )
+                }}
+                <text v-if="state.selectedSku.promotionType > 0">
+                  <text class="iconBox" v-if="state.selectedSku.promotionType === 4">
+                    限时优惠
+                  </text>
+                  <text class="iconBox" v-else-if="state.selectedSku.promotionType === 6">
+                    会员价
+                  </text>
+                  <text class="origin-price-text">
+                    {{ fen2yuan(state.selectedSku.price) }}
+                  </text>
+                </text>
+              </view>
+            </view>
+            <view class="stock-text ss-m-l-20">
+              {{ formatStock('exact', state.selectedSku.stock || goodsInfo.stock) }}
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 属性选择 -->
+      <view class="modal-content ss-flex-1">
+        <scroll-view scroll-y="true" class="modal-content-scroll" @touchmove.stop>
+          <view class="sku-item ss-m-b-20" v-for="property in propertyList" :key="property.id">
+            <view class="label-text ss-m-b-20">{{ property.name }}</view>
+            <view class="ss-flex ss-col-center ss-flex-wrap">
+              <button
+                class="ss-reset-button spec-btn"
+                v-for="value in property.values"
+                :class="[
+                  {
+                    'ui-BG-Main-Gradient': state.currentPropertyArray[property.id] === value.id,
+                  },
+                  {
+                    'disabled-btn': value.disabled === true,
+                  },
+                ]"
+                :key="value.id"
+                :disabled="value.disabled === true"
+                @tap="onSelectSku(property.id, value.id)"
+              >
+                {{ value.name }}
+              </button>
+            </view>
+          </view>
+          <view class="buy-num-box ss-flex ss-col-center ss-row-between ss-m-b-40">
+            <view class="label-text">购买数量</view>
+            <su-number-box
+              :min="1"
+              :max="state.selectedSku.stock"
+              :step="1"
+              v-model="state.selectedSku.goods_num"
+              @change="onNumberChange($event)"
+            />
+          </view>
+        </scroll-view>
+      </view>
+
+      <!-- 操作区 -->
+      <view class="modal-footer border-top">
+        <view class="buy-box ss-flex ss-col-center ss-flex ss-col-center ss-row-center">
+          <button class="ss-reset-button add-btn ui-Shadow-Main" @tap="onAddCart"
+            >加入购物车</button
+          >
+          <button class="ss-reset-button buy-btn ui-Shadow-Main" @tap="onBuy">立即购买</button>
+        </view>
+      </view>
+    </view>
+  </su-popup>
+</template>
+
+<script setup>
+  import { computed, reactive, watch } from 'vue';
+  import sheep from '@/common';
+  import { formatStock, convertProductPropertyList, fen2yuan } from '@/common/hooks/useGoods';
+
+  const emits = defineEmits(['change', 'addCart', 'buy', 'close']);
+  const props = defineProps({
+    goodsInfo: {
+      type: Object,
+      default() {},
+    },
+    show: {
+      type: Boolean,
+      default: false,
+    },
+  });
+
+  const state = reactive({
+    selectedSku: {}, // 选中的 SKU
+    currentPropertyArray: {}, // 当前选中的属性,实际是个 Map。key 是 property 编号,value 是 value 编号
+  });
+
+  const propertyList = convertProductPropertyList(props.goodsInfo.skus);
+  // SKU 列表
+  const skuList = computed(() => {
+    let skuPrices = props.goodsInfo.skus;
+    for (let price of skuPrices) {
+      price.value_id_array = price.properties.map((item) => item.valueId);
+    }
+    return skuPrices;
+  });
+
+  watch(
+    () => state.selectedSku,
+    (newVal) => {
+      emits('change', newVal);
+    },
+    {
+      immediate: true, // 立即执行
+      deep: true, // 深度监听
+    },
+  );
+
+  // 输入框改变数量
+  function onNumberChange(e) {
+    if (e === 0) return;
+    if (state.selectedSku.goods_num === e) return;
+    state.selectedSku.goods_num = e;
+  }
+
+  // 加入购物车
+  function onAddCart() {
+    if (state.selectedSku.id <= 0) {
+      sheep.$helper.toast('请选择规格');
+      return;
+    }
+    if (state.selectedSku.stock <= 0) {
+      sheep.$helper.toast('库存不足');
+      return;
+    }
+
+    emits('addCart', state.selectedSku);
+  }
+
+  // 立即购买
+  function onBuy() {
+    if (state.selectedSku.id <= 0) {
+      sheep.$helper.toast('请选择规格');
+      return;
+    }
+    if (state.selectedSku.stock <= 0) {
+      sheep.$helper.toast('库存不足');
+      return;
+    }
+    emits('buy', state.selectedSku);
+  }
+
+  // 改变禁用状态:计算每个 property 属性值的按钮,是否禁用
+  function changeDisabled(isChecked = false, propertyId = 0, valueId = 0) {
+    let newSkus = []; // 所有可以选择的 sku 数组
+    if (isChecked) {
+      // 情况一:选中 property
+      // 获得当前点击选中 property 的、所有可用 SKU
+      for (let price of skuList.value) {
+        if (price.stock <= 0) {
+          continue;
+        }
+        if (price.value_id_array.indexOf(valueId) >= 0) {
+          newSkus.push(price);
+        }
+      }
+    } else {
+      // 情况二:取消选中 property
+      // 当前所选 property 下,所有可以选择的 SKU
+      newSkus = getCanUseSkuList();
+    }
+
+    // 所有存在并且有库存未选择的 SKU 的 value 属性值 id
+    let noChooseValueIds = [];
+    for (let price of newSkus) {
+      noChooseValueIds = noChooseValueIds.concat(price.value_id_array);
+    }
+    noChooseValueIds = Array.from(new Set(noChooseValueIds)); // 去重
+
+    if (isChecked) {
+      // 去除当前选中的 value 属性值 id
+      let index = noChooseValueIds.indexOf(valueId);
+      noChooseValueIds.splice(index, 1);
+    } else {
+      // 循环去除当前已选择的 value 属性值 id
+      Object.entries(state.currentPropertyArray).forEach(([propertyId, currentPropertyId]) => {
+        if (currentPropertyId.toString() !== '') {
+          return;
+        }
+        // currentPropertyId 为空是反选 填充的
+        let index = noChooseValueIds.indexOf(currentPropertyId);
+        if (index >= 0) {
+          // currentPropertyId 存在于 noChooseValueIds
+          noChooseValueIds.splice(index, 1);
+        }
+      });
+    }
+
+    // 当前已选择的 property 数组
+    let choosePropertyIds = [];
+    if (!isChecked) {
+      // 当前已选择的 property
+      Object.entries(state.currentPropertyArray).forEach(([propertyId, currentValueId]) => {
+        if (currentValueId !== '') {
+          // currentPropertyId 为空是反选 填充的
+          choosePropertyIds.push(currentValueId);
+        }
+      });
+    } else {
+      // 当前点击选择的 property
+      choosePropertyIds = [propertyId];
+    }
+
+    for (let propertyIndex in propertyList) {
+      // 当前点击的 property、或者取消选择时候,已选中的 property 不进行处理
+      if (choosePropertyIds.indexOf(propertyList[propertyIndex]['id']) >= 0) {
+        continue;
+      }
+      // 如果当前 property id 不存在于有库存的 SKU 中,则禁用
+      for (let valueIndex in propertyList[propertyIndex]['values']) {
+        propertyList[propertyIndex]['values'][valueIndex]['disabled'] =
+          noChooseValueIds.indexOf(propertyList[propertyIndex]['values'][valueIndex]['id']) < 0; // true 禁用 or false 不禁用
+      }
+    }
+  }
+
+  // 当前所选属性下,获取所有有库存的 SKU 们
+  function getCanUseSkuList() {
+    let newSkus = [];
+    for (let sku of skuList.value) {
+      if (sku.stock <= 0) {
+        continue;
+      }
+      let isOk = true;
+      Object.entries(state.currentPropertyArray).forEach(([propertyId, valueId]) => {
+        // valueId 不为空,并且,这个 条 sku 没有被选中,则排除
+        if (valueId.toString() !== '' && sku.value_id_array.indexOf(valueId) < 0) {
+          isOk = false;
+        }
+      });
+      if (isOk) {
+        newSkus.push(sku);
+      }
+    }
+    return newSkus;
+  }
+
+  // 选择规格
+  function onSelectSku(propertyId, valueId) {
+    // 清空已选择
+    let isChecked = true; // 选中 or 取消选中
+    if (
+      state.currentPropertyArray[propertyId] !== undefined &&
+      state.currentPropertyArray[propertyId] === valueId
+    ) {
+      // 点击已被选中的,删除并填充 ''
+      isChecked = false;
+      state.currentPropertyArray.splice(propertyId, 1, '');
+    } else {
+      // 选中
+      state.currentPropertyArray[propertyId] = valueId;
+    }
+
+    // 选中的 property 大类
+    let choosePropertyId = [];
+    Object.entries(state.currentPropertyArray).forEach(([propertyId, currentPropertyId]) => {
+      if (currentPropertyId !== '') {
+        // currentPropertyId 为空是反选 填充的
+        choosePropertyId.push(currentPropertyId);
+      }
+    });
+
+    // 当前所选 property 下,所有可以选择的 SKU 们
+    let newSkuList = getCanUseSkuList();
+
+    // 判断所有 property 大类是否选择完成
+    if (choosePropertyId.length === propertyList.length && newSkuList.length) {
+      newSkuList[0].goods_num = state.selectedSku.goods_num || 1;
+      state.selectedSku = newSkuList[0];
+    } else {
+      state.selectedSku = {};
+    }
+
+    // 改变 property 禁用状态
+    changeDisabled(isChecked, propertyId, valueId);
+  }
+
+  changeDisabled(false);
+  // TODO 芋艿:待讨论的优化点:1)单规格,要不要默认选中;2)默认要不要选中第一个规格
+</script>
+
+<style lang="scss" scoped>
+  // 购买
+  .buy-box {
+    padding: 10rpx 0;
+
+    .add-btn {
+      width: 356rpx;
+      height: 80rpx;
+      border-radius: 40rpx 0 0 40rpx;
+      background-color: var(--ui-BG-Main-light);
+      color: var(--ui-BG-Main);
+    }
+
+    .buy-btn {
+      width: 356rpx;
+      height: 80rpx;
+      border-radius: 0 40rpx 40rpx 0;
+      background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
+      color: #fff;
+    }
+
+    .score-btn {
+      width: 100%;
+      margin: 0 20rpx;
+      height: 80rpx;
+      border-radius: 40rpx;
+      background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
+      color: #fff;
+    }
+  }
+
+  .ss-modal-box {
+    border-radius: 30rpx 30rpx 0 0;
+    max-height: 1000rpx;
+
+    .modal-header {
+      position: relative;
+      padding: 80rpx 20rpx 40rpx;
+
+      .sku-image {
+        width: 160rpx;
+        height: 160rpx;
+        border-radius: 10rpx;
+      }
+
+      .header-right {
+        height: 160rpx;
+      }
+
+      .close-icon {
+        position: absolute;
+        top: 10rpx;
+        right: 20rpx;
+        font-size: 46rpx;
+        opacity: 0.2;
+      }
+
+      .goods-title {
+        font-size: 28rpx;
+        font-weight: 500;
+        line-height: 42rpx;
+      }
+
+      .score-img {
+        width: 36rpx;
+        height: 36rpx;
+        margin: 0 4rpx;
+      }
+
+      .score-text {
+        font-size: 30rpx;
+        font-weight: 500;
+        color: $red;
+        font-family: OPPOSANS;
+      }
+
+      .price-text {
+        font-size: 30rpx;
+        font-weight: 500;
+        color: $red;
+        font-family: OPPOSANS;
+
+        &::before {
+          content: '¥';
+          font-size: 30rpx;
+          font-weight: 500;
+          color: $red;
+        }
+      }
+
+      .stock-text {
+        font-size: 26rpx;
+        color: #999999;
+      }
+    }
+
+    .modal-content {
+      padding: 0 20rpx;
+
+      .modal-content-scroll {
+        max-height: 600rpx;
+
+        .label-text {
+          font-size: 26rpx;
+          font-weight: 500;
+        }
+
+        .buy-num-box {
+          height: 100rpx;
+        }
+
+        .spec-btn {
+          height: 60rpx;
+          min-width: 100rpx;
+          padding: 0 30rpx;
+          background: #f4f4f4;
+          border-radius: 30rpx;
+          color: #434343;
+          font-size: 26rpx;
+          margin-right: 10rpx;
+          margin-bottom: 10rpx;
+        }
+
+        .disabled-btn {
+          font-weight: 400;
+          color: #c6c6c6;
+          background: #f8f8f8;
+        }
+      }
+    }
+  }
+
+  .iconBox {
+    width: fit-content;
+    height: fit-content;
+    padding: 2rpx 10rpx;
+    background-color: rgb(255, 242, 241);
+    color: #ff2621;
+    font-size: 24rpx;
+    margin-left: 5rpx;
+  }
+
+  .origin-price-text {
+    font-size: 26rpx;
+    font-weight: 400;
+    text-decoration: line-through;
+    color: $gray-c;
+    font-family: OPPOSANS;
+
+    &::before {
+      content: '¥';
+    }
+  }
+</style>

+ 168 - 0
common/components/s-share-modal/canvas-poster/index.vue

@@ -0,0 +1,168 @@
+<!-- 海报弹窗 -->
+<template>
+  <su-popup :show="show" round="10" @close="onClosePoster" type="center" class="popup-box">
+    <view class="ss-flex-col ss-col-center ss-row-center">
+      <image
+        v-if="!!painterImageUrl"
+        class="poster-img"
+        :src="painterImageUrl"
+        :style="{
+          height: poster.css.height+ 'px',
+          width: poster.css.width + 'px',
+        }"
+        :show-menu-by-longpress="true"
+      />
+    </view>
+    <view
+      class="poster-btn-box ss-m-t-20 ss-flex ss-row-between ss-col-center"
+      v-if="!!painterImageUrl"
+    >
+      <button class="cancel-btn ss-reset-button" @tap="onClosePoster">取消</button>
+      <button class="save-btn ss-reset-button ui-BG-Main" @tap="onSavePoster">
+        {{
+          ['wechatOfficialAccount', 'H5'].includes(sheep.$platform.name)
+            ? '长按图片保存'
+            : '保存图片'
+        }}
+      </button>
+    </view>
+    <!--  海报画板:默认隐藏只用来生成海报。生成方式为主动调用  -->
+    <l-painter
+      isCanvasToTempFilePath
+      pathType="url"
+      @success="setPainterImageUrl"
+      hidden
+      ref="painterRef"
+    />
+  </su-popup>
+</template>
+
+<script setup>
+  /**
+   * 海报生成和展示
+   * 提示:小程序码默认跳转首页,由首页进行 spm 参数解析后跳转到对应的分享页面
+   * @description 用于生成分享海报,如:分享商品海报。
+   * @tutorial https://ext.dcloud.net.cn/plugin?id=2389
+   * @property {Boolean} show   弹出层控制
+   * @property {Object}  shareInfo 分享信息
+   */
+  import { reactive, ref, unref } from 'vue';
+  import sheep from '@/common';
+  import { getPosterData } from '@/common/components/s-share-modal/canvas-poster/poster';
+
+  const props = defineProps({
+    show: {
+      type: Boolean,
+      default: false,
+    },
+    shareInfo: {
+      type: Object,
+      default: () => {
+      },
+    },
+  });
+
+  const poster = reactive({
+    css: {
+      // 根节点若无尺寸,自动获取父级节点
+      width: sheep.$platform.device.windowWidth * 0.9,
+      height: 600,
+    },
+    views: [],
+  });
+
+  const emits = defineEmits(['success', 'close']);
+
+  const onClosePoster = () => {
+    emits('close');
+  };
+
+  const painterRef = ref(); // 海报画板
+  const painterImageUrl = ref(); // 海报 url
+  // 渲染海报
+  const renderPoster = async () => {
+    await painterRef.value.render(unref(poster));
+  };
+  // 获得生成的图片
+  const setPainterImageUrl = (path) => {
+    painterImageUrl.value = path;
+  };
+  // 保存海报图片
+  const onSavePoster = () => {
+    if (['WechatOfficialAccount', 'H5'].includes(sheep.$platform.name)) {
+      sheep.$helper.toast('请长按图片保存');
+      return;
+    }
+
+    // 非H5 保存到相册
+    uni.saveImageToPhotosAlbum({
+      filePath: painterImageUrl.value,
+      success: (res) => {
+        onClosePoster();
+        sheep.$helper.toast('保存成功');
+      },
+      fail: (err) => {
+        sheep.$helper.toast('保存失败');
+        console.log('图片保存失败:', err);
+      },
+    });
+  };
+
+  // 获得海报数据
+  async function getPoster() {
+    painterImageUrl.value = undefined
+    poster.views = await getPosterData({
+      width: poster.css.width,
+      shareInfo: props.shareInfo,
+    });
+    await renderPoster();
+  }
+
+  defineExpose({
+    getPoster,
+  });
+</script>
+
+<style lang="scss" scoped>
+  .popup-box {
+    position: relative;
+  }
+
+  .poster-title {
+    color: #999;
+  }
+
+  // 分享海报
+  .poster-btn-box {
+    width: 600rpx;
+    position: absolute;
+    left: 50%;
+    transform: translateX(-50%);
+    bottom: -80rpx;
+
+    .cancel-btn {
+      width: 240rpx;
+      height: 70rpx;
+      line-height: 70rpx;
+      background: $white;
+      border-radius: 35rpx;
+      font-size: 28rpx;
+      font-weight: 500;
+      color: $dark-9;
+    }
+
+    .save-btn {
+      width: 240rpx;
+      height: 70rpx;
+      line-height: 70rpx;
+      border-radius: 35rpx;
+      font-size: 28rpx;
+      font-weight: 500;
+    }
+  }
+
+  .poster-img {
+    border-radius: 20rpx;
+  }
+
+</style>

+ 125 - 0
common/components/s-share-modal/canvas-poster/poster/goods.js

@@ -0,0 +1,125 @@
+import sheep from '@/common';
+import { formatImageUrlProtocol, getWxaQrcode } from './index';
+
+const goods = async (poster) => {
+  const width = poster.width;
+  const userInfo = sheep.$store('user').userInfo;
+  const wxa_qrcode = await getWxaQrcode(poster.shareInfo.path, poster.shareInfo.query);
+  return [
+    {
+      type: 'image',
+      src: formatImageUrlProtocol(sheep.$url.cdn(sheep.$store('app').platform.share.posterInfo.goods_bg)),
+      css: {
+        width,
+        position: 'fixed',
+        'object-fit': 'contain',
+        top: '0',
+        left: '0',
+        zIndex: -1,
+      },
+    },
+    {
+      type: 'text',
+      text: userInfo.nickname,
+      css: {
+        color: '#333',
+        fontSize: 16,
+        fontFamily: 'sans-serif',
+        position: 'fixed',
+        top: width * 0.06,
+        left: width * 0.22,
+      },
+    },
+    {
+      type: 'image',
+      src: formatImageUrlProtocol(sheep.$url.cdn(userInfo.avatar)),
+      css: {
+        position: 'fixed',
+        left: width * 0.04,
+        top: width * 0.04,
+        width: width * 0.14,
+        height: width * 0.14,
+      },
+    },
+    {
+      type: 'image',
+      src: formatImageUrlProtocol(poster.shareInfo.poster.image),
+      css: {
+        position: 'fixed',
+        left: width * 0.03,
+        top: width * 0.21,
+        width: width * 0.94,
+        height: width * 0.94,
+      },
+    },
+    {
+      type: 'text',
+      text: poster.shareInfo.poster.title,
+      css: {
+        position: 'fixed',
+        left: width * 0.04,
+        top: width * 1.18,
+        color: '#333',
+        fontSize: 14,
+        lineHeight: 15,
+        maxWidth: width * 0.91,
+      },
+    },
+    {
+      type: 'text',
+      text: '¥' + poster.shareInfo.poster.price,
+      css: {
+        position: 'fixed',
+        left: width * 0.04,
+        top: width * 1.31,
+        fontSize: 20,
+        fontFamily: 'OPPOSANS',
+        color: '#333',
+      },
+    },
+    {
+      type: 'text',
+      text:
+        poster.shareInfo.poster.original_price > 0
+          ? '¥' + poster.shareInfo.poster.original_price
+          : '',
+      css: {
+        position: 'fixed',
+        left: width * 0.3,
+        top: width * 1.33,
+        color: '#999',
+        fontSize: 10,
+        fontFamily: 'OPPOSANS',
+        textDecoration: 'line-through',
+      },
+    },
+    // #ifndef MP-WEIXIN
+    {
+      type: 'qrcode',
+      text: poster.shareInfo.link,
+      css: {
+        position: 'fixed',
+        left: width * 0.75,
+        top: width * 1.3,
+        width: width * 0.2,
+        height: width * 0.2,
+      },
+    },
+    // #endif
+    // #ifdef MP-WEIXIN
+    {
+      type: 'image',
+      src: wxa_qrcode,
+      css: {
+        position: 'fixed',
+        left: width * 0.75,
+        top: width * 1.3,
+        width: width * 0.2,
+        height: width * 0.2,
+      },
+    },
+    // #endif
+  ];
+};
+
+export default goods;

+ 125 - 0
common/components/s-share-modal/canvas-poster/poster/groupon.js

@@ -0,0 +1,125 @@
+import sheep from '@/common';
+import { formatImageUrlProtocol, getWxaQrcode } from './index';
+
+const groupon = async (poster) => {
+  debugger;
+  const width = poster.width;
+  const userInfo = sheep.$store('user').userInfo;
+  const wxa_qrcode = await getWxaQrcode(poster.shareInfo.path, poster.shareInfo.query);
+  return [
+    {
+      type: 'image',
+      src: formatImageUrlProtocol(
+        sheep.$url.cdn(sheep.$store('app').platform.share.posterInfo.groupon_bg),
+      ),
+      css: {
+        width,
+        position: 'fixed',
+        'object-fit': 'contain',
+        top: '0',
+        left: '0',
+        zIndex: -1,
+      },
+    },
+    {
+      type: 'text',
+      text: userInfo.nickname,
+      css: {
+        color: '#333',
+        fontSize: 16,
+        fontFamily: 'sans-serif',
+        position: 'fixed',
+        top: width * 0.06,
+        left: width * 0.22,
+      },
+    },
+    {
+      type: 'image',
+      src: formatImageUrlProtocol(sheep.$url.cdn(userInfo.avatar)),
+      css: {
+        position: 'fixed',
+        left: width * 0.04,
+        top: width * 0.04,
+        width: width * 0.14,
+        height: width * 0.14,
+      },
+    },
+    {
+      type: 'image',
+      src: formatImageUrlProtocol(poster.shareInfo.poster.image),
+      css: {
+        position: 'fixed',
+        left: width * 0.03,
+        top: width * 0.21,
+        width: width * 0.94,
+        height: width * 0.94,
+        borderRadius: 10,
+      },
+    },
+    {
+      type: 'text',
+      text: poster.shareInfo.poster.title,
+      css: {
+        color: '#333',
+        fontSize: 14,
+        position: 'fixed',
+        top: width * 1.18,
+        left: width * 0.04,
+        maxWidth: width * 0.91,
+        lineHeight: 5,
+      },
+    },
+    {
+      type: 'text',
+      text: '¥' + poster.shareInfo.poster.price,
+      css: {
+        color: '#ff0000',
+        fontSize: 20,
+        fontFamily: 'OPPOSANS',
+        position: 'fixed',
+        top: width * 1.3,
+        left: width * 0.04,
+      },
+    },
+    {
+      type: 'text',
+      text: poster.shareInfo.poster.grouponNum + '人团',
+      css: {
+        color: '#fff',
+        fontSize: 12,
+        fontFamily: 'OPPOSANS',
+        position: 'fixed',
+        left: width * 0.84,
+        top: width * 1.3,
+      },
+    },
+    // #ifndef MP-WEIXIN
+    {
+      type: 'qrcode',
+      text: poster.shareInfo.link,
+      css: {
+        position: 'fixed',
+        left: width * 0.75,
+        top: width * 1.4,
+        width: width * 0.2,
+        height: width * 0.2,
+      },
+    },
+    // #endif
+    // #ifdef MP-WEIXIN
+    {
+      type: 'image',
+      src: wxa_qrcode,
+      css: {
+        position: 'fixed',
+        left: width * 0.75,
+        top: width * 1.4,
+        width: width * 0.2,
+        height: width * 0.2,
+      },
+    },
+    // #endif
+  ];
+};
+
+export default groupon;

+ 39 - 0
common/components/s-share-modal/canvas-poster/poster/index.js

@@ -0,0 +1,39 @@
+import user from './user';
+import goods from './goods';
+import groupon from './groupon';
+import SocialApi from '@/common/api/member/social';
+
+export function getPosterData(options) {
+  switch (options.shareInfo.poster.type) {
+    case 'user':
+      return user(options);
+    case 'goods':
+      return goods(options);
+    case 'groupon':
+      return groupon(options);
+  }
+}
+
+export function formatImageUrlProtocol(url) {
+  // #ifdef H5
+  // H5平台 https协议下需要转换
+  if (window.location.protocol === 'https:' && url.indexOf('http:') === 0) {
+    url = url.replace('http:', 'https:');
+  }
+  // #endif
+
+  // #ifdef MP-WEIXIN
+  // 小程序平台 需要强制转换为https协议
+  if (url.indexOf('http:') === 0) {
+    url = url.replace('http:', 'https:');
+  }
+  // #endif
+
+  return url;
+}
+
+// 获得微信小程序码 (Base64 image)
+export async function getWxaQrcode(path, query) {
+  const res = await SocialApi.getWxaQrcode(path, query);
+  return 'data:image/png;base64,' + res.data;
+}

+ 75 - 0
common/components/s-share-modal/canvas-poster/poster/user.js

@@ -0,0 +1,75 @@
+import sheep from '@/common';
+import { formatImageUrlProtocol, getWxaQrcode } from './index';
+import { measureTextWidth } from '@/utils/textUtils'; // 引入新封装的方法
+const user = async (poster) => {
+  const width = poster.width;
+  const userInfo = sheep.$store('user').userInfo;
+  const wxa_qrcode = await getWxaQrcode(poster.shareInfo.path, poster.shareInfo.query);
+  const widthNickName = measureTextWidth(userInfo.nickname, 14); // 使用新方法
+  return [
+    {
+      type: 'image',
+      src: formatImageUrlProtocol(sheep.$url.cdn(sheep.$store('app').platform.share.posterInfo.user_bg)),
+      css: {
+        width,
+        position: 'fixed',
+        'object-fit': 'contain',
+        top: '0',
+        left: '0',
+        zIndex: -1,
+      },
+    },
+    {
+      type: 'text',
+      text: userInfo.nickname,
+      css: {
+        color: '#333',
+        fontSize: 14,
+        textAlign: 'center',
+        fontFamily: 'sans-serif',
+        position: 'fixed',
+        top: width * 0.4,
+        left: (width-widthNickName) / 2,
+      },
+    },
+    {
+      type: 'image',
+      src: formatImageUrlProtocol(sheep.$url.cdn(userInfo.avatar)),
+      css: {
+        position: 'fixed',
+        left: width * 0.4,
+        top: width * 0.16,
+        width: width * 0.2,
+        height: width * 0.2,
+      },
+    },
+    // #ifndef MP-WEIXIN
+    {
+      type: 'qrcode',
+      text: poster.shareInfo.link,
+      css: {
+        position: 'fixed',
+        left: width * 0.35,
+        top: width * 0.84,
+        width: width * 0.3,
+        height: width * 0.3,
+      },
+    },
+    // #endif
+    // #ifdef MP-WEIXIN
+    {
+      type: 'image',
+      src: wxa_qrcode,
+      css: {
+        position: 'fixed',
+        left: width * 0.35,
+        top: width * 0.84,
+        width: width * 0.3,
+        height: width * 0.3,
+      },
+    },
+    // #endif
+  ];
+};
+
+export default user;

+ 195 - 0
common/components/s-share-modal/s-share-modal.vue

@@ -0,0 +1,195 @@
+<!-- 全局分享弹框 -->
+<template>
+  <view>
+    <su-popup :show="state.showShareGuide" :showClose="false" @close="onCloseGuide" />
+    <view v-if="state.showShareGuide" class="guide-wrap">
+      <image class="guide-image" :src="sheep.$url.static('/static/img/shop/share/share_guide.png')" />
+    </view>
+
+    <su-popup :show="show" round="10" :showClose="false" @close="closeShareModal">
+      <!-- 分享 tools -->
+      <view class="share-box">
+        <view class="share-list-box ss-flex">
+          <!-- 操作 ①:发送给微信好友 -->
+          <button
+            v-if="shareConfig.methods.includes('forward')"
+            class="share-item share-btn ss-flex-col ss-col-center"
+            open-type="share"
+            @tap="onShareByForward"
+          >
+            <image class="share-img" :src="sheep.$url.static('/static/img/shop/share/share_wx.png')" mode="" />
+            <text class="share-title">微信好友</text>
+          </button>
+
+          <!-- 操作 ②:生成海报图片 -->
+          <button
+            v-if="shareConfig.methods.includes('poster')"
+            class="share-item share-btn ss-flex-col ss-col-center"
+            @tap="onShareByPoster"
+          >
+            <image
+              class="share-img"
+              :src="sheep.$url.static('/static/img/shop/share/share_poster.png')"
+              mode=""
+            />
+            <text class="share-title">生成海报</text>
+          </button>
+
+          <!-- 操作 ③:生成链接 -->
+          <button
+            v-if="shareConfig.methods.includes('link')"
+            class="share-item share-btn ss-flex-col ss-col-center"
+            @tap="onShareByCopyLink"
+          >
+            <image class="share-img" :src="sheep.$url.static('/static/img/shop/share/share_link.png')" mode="" />
+            <text class="share-title">复制链接</text>
+          </button>
+        </view>
+        <view class="share-foot ss-flex ss-row-center ss-col-center" @tap="closeShareModal">
+          取消
+        </view>
+      </view>
+    </su-popup>
+
+    <!-- 分享海报,对应操作 ② -->
+    <canvas-poster
+      ref="SharePosterRef"
+      :show="state.showPosterModal"
+      :shareInfo="shareInfo"
+      @close="state.showPosterModal = false"
+    />
+  </view>
+</template>
+<script setup>
+  /**
+   * 分享弹窗
+   */
+  import { ref, unref, reactive, computed } from 'vue';
+  import sheep from '@/common';
+  import canvasPoster from './canvas-poster/index.vue';
+  import { closeShareModal, showAuthModal } from '@/common/hooks/useModal';
+
+  const show = computed(() => sheep.$store('modal').share);
+  const shareConfig = computed(() => sheep.$store('app').platform.share);
+  const SharePosterRef = ref('');
+
+  const props = defineProps({
+    shareInfo: {
+      type: Object,
+      default() {},
+    },
+  });
+
+  const state = reactive({
+    showShareGuide: false, // H5 的指引
+    showPosterModal: false, // 海报弹窗
+  });
+
+  // 操作 ②:生成海报分享
+  const onShareByPoster = () => {
+    closeShareModal();
+    if (!sheep.$store('user').isLogin) {
+      showAuthModal();
+      return;
+    }
+    unref(SharePosterRef).getPoster();
+    state.showPosterModal = true;
+  };
+
+  // 操作 ①:直接转发分享
+  const onShareByForward = () => {
+    closeShareModal();
+
+    // #ifdef H5
+    if (['WechatOfficialAccount', 'H5'].includes(sheep.$platform.name)) {
+      state.showShareGuide = true;
+      return;
+    }
+    // #endif
+
+    // #ifdef APP-PLUS
+    uni.share({
+      provider: 'weixin',
+      scene: 'WXSceneSession',
+      type: 0,
+      href: props.shareInfo.link,
+      title: props.shareInfo.title,
+      summary: props.shareInfo.desc,
+      imageUrl: props.shareInfo.image,
+      success: (res) => {
+        console.log('success:' + JSON.stringify(res));
+      },
+      fail: (err) => {
+        console.log('fail:' + JSON.stringify(err));
+      },
+    });
+    // #endif
+  };
+
+  // 操作 ③:复制链接分享
+  const onShareByCopyLink = () => {
+    sheep.$helper.copyText(props.shareInfo.link);
+    closeShareModal();
+  };
+
+  function onCloseGuide() {
+    state.showShareGuide = false;
+  }
+</script>
+
+<style lang="scss" scoped>
+  .guide-image {
+    right: 30rpx;
+    top: 0;
+    position: fixed;
+    width: 580rpx;
+    height: 430rpx;
+    z-index: 10080;
+  }
+
+  // 分享tool
+  .share-box {
+    background: $white;
+    width: 750rpx;
+    border-radius: 30rpx 30rpx 0 0;
+    padding-top: 30rpx;
+
+    .share-foot {
+      font-size: 24rpx;
+      color: $gray-b;
+      height: 80rpx;
+      border-top: 1rpx solid $gray-e;
+    }
+
+    .share-list-box {
+      .share-btn {
+        background: none;
+        border: none;
+        line-height: 1;
+        padding: 0;
+
+        &::after {
+          border: none;
+        }
+      }
+
+      .share-item {
+        flex: 1;
+        padding-bottom: 20rpx;
+
+        .share-img {
+          width: 70rpx;
+          height: 70rpx;
+          background: $gray-f;
+          border-radius: 50%;
+          margin-bottom: 20rpx;
+        }
+
+        .share-title {
+          font-size: 24rpx;
+          color: $dark-6;
+        }
+      }
+    }
+  }
+</style>

+ 10 - 0
common/components/s-statusbar/s-statusbar.vue

@@ -0,0 +1,10 @@
+<template>
+  <view class="status_bar" />
+</template>
+
+<style>
+  .status_bar {
+    height: var(--status-bar-height);
+    width: 100%;
+  }
+</style>

+ 127 - 0
common/components/s-tabbar/s-tabbar.vue

@@ -0,0 +1,127 @@
+<template>
+  <view class="u-page__item">
+    <su-tabbar
+      :value="path"
+      :fixed="true"
+      :placeholder="true"
+      :safeAreaInsetBottom="true"
+	  :inactiveColor="state.tabbar.style.color"
+	  :activeColor="state.tabbar.style.activeColor"
+	  :midTabBar="state.tabbar.mode === 2"
+	  :customStyle="tabbarStyle"
+    >
+      <su-tabbar-item
+		v-for="(item, index) in state.tabbar.items"
+		:key="item.text"
+		:text="item.text"
+		:name="item.url"
+		:badge="item.badge"
+		:dot="item.dot"
+		:badgeStyle="state.tabbar.badgeStyle"
+		:isCenter="getTabbarCenter(index)"
+		:centerImage="item.iconUrl"
+		@tap="sheep.$router.go(item.url)"
+      >
+        <template v-slot:active-icon>
+          <image class="u-page__item__slot-icon" :src="item.activeIconUrl"></image>
+        </template>
+        <template v-slot:inactive-icon>
+          <image class="u-page__item__slot-icon" :src="item.iconUrl"></image>
+        </template>
+      </su-tabbar-item>
+    </su-tabbar>
+  </view>
+</template>
+
+<script setup>
+  import { computed, reactive } from 'vue';
+  import sheep from '@/common';
+  import SuTabbar from '@/common/ui/su-tabbar/su-tabbar.vue';
+
+  const state = reactive({
+    tabbar: {
+		mode: 2,
+		style:{
+			bgType: 'color',
+			bgColor: '#ffffff',
+			bgImg: '',
+			activeColor:'#35E89A',
+			color:'#222222'
+		},
+		badgeStyle: {},
+		items:[
+			{
+				text:'首页',
+				url:'/pages/index/index',
+				badge:0,
+				dot: false,
+				iconUrl:'/static/homeIconUrl.png',
+				activeIconUrl:'/static/homeActiveIconUrl.png'
+			},
+			{
+				text:'任务创建',
+				url:'/pages/task/create',
+				badge:0,
+				dot: false,
+				iconUrl:'/static/createIconUrl.png',
+			},
+			{
+				text:'我的',
+				url:'/pages/index/user',
+				badge:10,
+				dot: false,
+				iconUrl:'/static/userIconUrl.png',
+				activeIconUrl:'/static/userActiveIconUrl.png'
+			}
+		]
+	},
+  })
+
+  const tabbarStyle = computed(() => {
+    const backgroundStyle = state.tabbar.style;
+    if (backgroundStyle.bgType === 'color') {
+      return { background: backgroundStyle.bgColor };
+    }
+    if (backgroundStyle.bgType === 'img')
+      return {
+        background: `url(${backgroundStyle.bgImg}) no-repeat top center / 100% auto`,
+      };
+  });
+  
+  const getTabbarCenter = (index) => {
+    if (state.tabbar.mode !== 2) return false;
+    return state.tabbar.items.length % 2 > 0
+      ? Math.ceil(state.tabbar.items.length / 2) === index + 1
+      : false;
+  };
+  
+  const props = defineProps({
+    path: String,
+    default: '',
+  });
+</script>
+
+<style lang="scss">
+  .u-page {
+    padding: 0;
+
+    &__item {
+      &__title {
+        color: var(--textSize);
+        background-color: #fff;
+        padding: 15px;
+        font-size: 15px;
+
+        &__slot-title {
+          color: var(--textSize);
+          font-size: 14px;
+        }
+      }
+
+      &__slot-icon {
+        width: 25px;
+        height: 25px;
+      }
+    }
+  }
+</style>

+ 109 - 0
common/components/s-title-block/s-title-block.vue

@@ -0,0 +1,109 @@
+<!-- 装修商品组件:标题栏 -->
+<template>
+  <view
+    class="ss-title-wrap ss-flex ss-col-center"
+    :class="[state.typeMap[data.textAlign]]"
+    :style="[elStyles]"
+  >
+    <view class="title-content">
+      <!-- 主标题 -->
+      <view v-if="data.title" class="title-text" :style="[titleStyles]">{{ data.title }}</view>
+      <!-- 副标题 -->
+      <view v-if="data.description" :style="[descStyles]" class="sub-title-text">
+        {{ data.description }}
+      </view>
+    </view>
+    <!-- 查看更多 -->
+    <view
+      v-if="data.more?.show"
+      class="more-box ss-flex ss-col-center"
+      @tap="sheep.$router.go(data.more.url)"
+      :style="{ color: data.descriptionColor }"
+    >
+      <view class="more-text" v-if="data.more.type !== 'icon'">{{ data.more.text }} </view>
+      <text class="_icon-forward" v-if="data.more.type !== 'text'"></text>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  /**
+   * 标题栏
+   */
+  import { reactive } from 'vue';
+  import sheep from '@/common';
+
+  // 数据
+  const state = reactive({
+    typeMap: {
+      left: 'ss-row-left',
+      center: 'ss-row-center',
+    },
+  });
+
+  // 接收参数
+  const props = defineProps({
+    data: {
+      type: Object,
+      default() {},
+    },
+    styles: {
+      type: Object,
+      default() {},
+    },
+  });
+
+  // 组件样式
+  const elStyles = {
+    background: `url(${sheep.$url.cdn(props.data.bgImgUrl)}) no-repeat top center / 100% auto`,
+    fontSize: `${props.data.titleSize}px`,
+    fontWeight: `${props.data.titleWeight}`,
+    height: `${props.data.height || 40}px`,
+  };
+
+  // 标题样式
+  const titleStyles = {
+    color: props.data.titleColor,
+    fontSize: `${props.data.titleSize}px`,
+    textAlign: props.data.textAlign,
+    marginLeft: `${props.data.marginLeft || 0}px`,
+  };
+
+  // 副标题
+  const descStyles = {
+    color: props.data.descriptionColor,
+    textAlign: props.data.textAlign,
+    fontSize: `${props.data.descriptionSize}px`,
+    fontWeight: `${props.data.descriptionWeight}px`,
+    marginLeft: `${props.data.marginLeft || 0}px`,
+  };
+</script>
+
+<style lang="scss" scoped>
+  .ss-title-wrap {
+    height: 80rpx;
+    position: relative;
+
+    .title-content {
+      .title-text {
+        font-size: 30rpx;
+        color: #333;
+      }
+
+      .sub-title-text {
+        font-size: 22rpx;
+        color: #999;
+      }
+    }
+
+    .more-box {
+      white-space: nowrap;
+      font-size: 22rpx;
+      color: #999;
+      position: absolute;
+      top: 50%;
+      transform: translateY(-50%);
+      right: 20rpx;
+    }
+  }
+</style>

+ 304 - 0
common/components/s-uploader/choose-and-upload-file.js

@@ -0,0 +1,304 @@
+'use strict';
+import FileApi from '@/common/api/infra/file';
+
+const ERR_MSG_OK = 'chooseAndUploadFile:ok';
+const ERR_MSG_FAIL = 'chooseAndUploadFile:fail';
+
+function chooseImage(opts) {
+  const {
+    count,
+    sizeType = ['original', 'compressed'],
+    sourceType = ['album', 'camera'],
+    extension,
+  } = opts;
+  return new Promise((resolve, reject) => {
+    uni.chooseImage({
+      count,
+      sizeType,
+      sourceType,
+      extension,
+      success(res) {
+        resolve(normalizeChooseAndUploadFileRes(res, 'image'));
+      },
+      fail(res) {
+        reject({
+          errMsg: res.errMsg.replace('chooseImage:fail', ERR_MSG_FAIL),
+        });
+      },
+    });
+  });
+}
+
+function chooseVideo(opts) {
+  const { camera, compressed, maxDuration, sourceType = ['album', 'camera'], extension } = opts;
+  return new Promise((resolve, reject) => {
+    uni.chooseVideo({
+      camera,
+      compressed,
+      maxDuration,
+      sourceType,
+      extension,
+      success(res) {
+        const { tempFilePath, duration, size, height, width } = res;
+        resolve(
+          normalizeChooseAndUploadFileRes(
+            {
+              errMsg: 'chooseVideo:ok',
+              tempFilePaths: [tempFilePath],
+              tempFiles: [
+                {
+                  name: (res.tempFile && res.tempFile.name) || '',
+                  path: tempFilePath,
+                  size,
+                  type: (res.tempFile && res.tempFile.type) || '',
+                  width,
+                  height,
+                  duration,
+                  fileType: 'video',
+                  cloudPath: '',
+                },
+              ],
+            },
+            'video',
+          ),
+        );
+      },
+      fail(res) {
+        reject({
+          errMsg: res.errMsg.replace('chooseVideo:fail', ERR_MSG_FAIL),
+        });
+      },
+    });
+  });
+}
+
+function chooseAll(opts) {
+  const { count, extension } = opts;
+  return new Promise((resolve, reject) => {
+    let chooseFile = uni.chooseFile;
+    if (typeof wx !== 'undefined' && typeof wx.chooseMessageFile === 'function') {
+      chooseFile = wx.chooseMessageFile;
+    }
+    if (typeof chooseFile !== 'function') {
+      return reject({
+        errMsg: ERR_MSG_FAIL + ' 请指定 type 类型,该平台仅支持选择 image 或 video。',
+      });
+    }
+    chooseFile({
+      type: 'all',
+      count,
+      extension,
+      success(res) {
+        resolve(normalizeChooseAndUploadFileRes(res));
+      },
+      fail(res) {
+        reject({
+          errMsg: res.errMsg.replace('chooseFile:fail', ERR_MSG_FAIL),
+        });
+      },
+    });
+  });
+}
+
+function normalizeChooseAndUploadFileRes(res, fileType) {
+  res.tempFiles.forEach((item, index) => {
+    if (!item.name) {
+      item.name = item.path.substring(item.path.lastIndexOf('/') + 1);
+    }
+    if (fileType) {
+      item.fileType = fileType;
+    }
+    item.cloudPath = Date.now() + '_' + index + item.name.substring(item.name.lastIndexOf('.'));
+  });
+  if (!res.tempFilePaths) {
+    res.tempFilePaths = res.tempFiles.map((file) => file.path);
+  }
+  return res;
+}
+
+async function readFile(uniFile) {
+  // 微信小程序
+  if (uni.getFileSystemManager) {
+    const fs = uni.getFileSystemManager();
+    return fs.readFileSync(uniFile.path);
+  }
+  // H5 等
+  return uniFile.arrayBuffer();
+}
+
+function uploadCloudFiles(files, max = 5, onUploadProgress) {
+  files = JSON.parse(JSON.stringify(files));
+  const len = files.length;
+  let count = 0;
+  let self = this;
+  return new Promise((resolve) => {
+    while (count < max) {
+      next();
+    }
+
+    function next() {
+      let cur = count++;
+      if (cur >= len) {
+        !files.find((item) => !item.url && !item.errMsg) && resolve(files);
+        return;
+      }
+      const fileItem = files[cur];
+      const index = self.files.findIndex((v) => v.uuid === fileItem.uuid);
+      fileItem.url = '';
+      delete fileItem.errMsg;
+
+      uniCloud
+        .uploadFile({
+          filePath: fileItem.path,
+          cloudPath: fileItem.cloudPath,
+          fileType: fileItem.fileType,
+          onUploadProgress: (res) => {
+            res.index = index;
+            onUploadProgress && onUploadProgress(res);
+          },
+        })
+        .then((res) => {
+          fileItem.url = res.fileID;
+          fileItem.index = index;
+          if (cur < len) {
+            next();
+          }
+        })
+        .catch((res) => {
+          fileItem.errMsg = res.errMsg || res.message;
+          fileItem.index = index;
+          if (cur < len) {
+            next();
+          }
+        });
+    }
+  });
+}
+
+function uploadFilesFromPath(path, directory) {
+  // 目的:用于微信小程序,选择图片时,只有 path
+  return uploadFiles(
+    Promise.resolve({
+      tempFiles: [
+        {
+          path,
+          type: 'image/jpeg',
+          name: path.includes('/') ? path.substring(path.lastIndexOf('/') + 1) : path,
+        },
+      ],
+    }),
+    {
+      directory,
+    },
+  );
+}
+
+async function uploadFiles(choosePromise, { onChooseFile, onUploadProgress, directory }) {
+  // 获取选择的文件
+  const res = await choosePromise;
+  // 处理文件选择回调
+  let files = res.tempFiles || [];
+  if (onChooseFile) {
+    const customChooseRes = onChooseFile(res);
+    if (typeof customChooseRes !== 'undefined') {
+      files = await Promise.resolve(customChooseRes);
+      if (typeof files === 'undefined') {
+        files = res.tempFiles || []; // Fallback
+      }
+    }
+  }
+
+  // 如果是前端直连上传
+  if (UPLOAD_TYPE.CLIENT === import.meta.env.SOUND_CHAIN_UPLOAD_TYPE) {
+    // 为上传创建一组 Promise
+    const uploadPromises = files.map(async (file) => {
+      try {
+        // 1.1 获取文件预签名地址
+        const { data: presignedInfo } = await FileApi.getFilePresignedUrl(file.name, directory);
+        // 1.2 获取二进制文件对象
+        const fileBuffer = await readFile(file);
+
+        // 返回上传的 Promise
+        return new Promise((resolve, reject) => {
+          // 1.3. 上传文件到 S3
+          uni.request({
+            url: presignedInfo.uploadUrl,
+            method: 'PUT',
+            header: {
+              'Content-Type': file.type,
+            },
+            data: fileBuffer,
+            success: (res) => {
+              // 1.4. 记录文件信息到后端(异步)
+              createFile(presignedInfo, file);
+              // 1.5. 重新赋值
+              file.url = presignedInfo.url;
+              resolve(file);
+            },
+            fail: (err) => {
+              reject(err);
+            },
+          });
+        });
+      } catch (error) {
+        console.error('上传失败:', error);
+        throw error;
+      }
+    });
+
+    // 等待所有上传完成
+    return await Promise.all(uploadPromises); // 返回已上传的文件列表
+  } else {
+    // 后端上传
+    for (let file of files) {
+      const { data } = await FileApi.uploadFile(file.path, directory);
+      file.url = data;
+    }
+
+    return files;
+  }
+}
+
+function chooseAndUploadFile(
+  opts = {
+    type: 'all',
+    directory: undefined,
+  },
+) {
+  if (opts.type === 'image') {
+    return uploadFiles(chooseImage(opts), opts);
+  } else if (opts.type === 'video') {
+    return uploadFiles(chooseVideo(opts), opts);
+  }
+  return uploadFiles(chooseAll(opts), opts);
+}
+
+/**
+ * 创建文件信息
+ * @param vo 文件预签名信息
+ * @param file 文件
+ */
+function createFile(vo, file) {
+  const fileVo = {
+    configId: vo.configId,
+    url: vo.url,
+    path: vo.path,
+    name: file.name,
+    type: file.fileType,
+    size: file.size,
+  };
+  FileApi.createFile(fileVo);
+  return fileVo;
+}
+
+/**
+ * 上传类型
+ */
+const UPLOAD_TYPE = {
+  // 客户端直接上传(只支持S3服务)
+  CLIENT: 'client',
+  // 客户端发送到后端上传
+  SERVER: 'server',
+};
+
+export { chooseAndUploadFile, uploadCloudFiles, uploadFilesFromPath };

+ 677 - 0
common/components/s-uploader/s-uploader.vue

@@ -0,0 +1,677 @@
+<!-- 文件上传,基于 upload-file 和 upload-image 实现 -->
+<template>
+  <view class="uni-file-picker">
+    <view v-if="title" class="uni-file-picker__header">
+      <text class="file-title">{{ title }}</text>
+      <text class="file-count">{{ filesList.length }}/{{ limitLength }}</text>
+    </view>
+    <view v-if="subtitle" class="file-subtitle">
+      <view>{{ subtitle }}</view>
+    </view>
+    <upload-image
+      v-if="fileMediatype === 'image' && showType === 'grid'"
+      :readonly="readonly"
+      :image-styles="imageStyles"
+      :files-list="url"
+      :limit="limitLength"
+      :disablePreview="disablePreview"
+      :delIcon="delIcon"
+      @uploadFiles="uploadFiles"
+      @choose="choose"
+      @delFile="delFile"
+    >
+      <slot>
+        <view class="is-add">
+          <image :src="imgsrc" class="add-icon"></image>
+        </view>
+      </slot>
+    </upload-image>
+    <upload-file
+      v-if="fileMediatype !== 'image' || showType !== 'grid'"
+      :readonly="readonly"
+      :list-styles="listStyles"
+      :files-list="filesList"
+      :showType="showType"
+      :delIcon="delIcon"
+      @uploadFiles="uploadFiles"
+      @choose="choose"
+      @delFile="delFile"
+    >
+      <slot><button type="primary" size="mini">选择文件</button></slot>
+    </upload-file>
+  </view>
+</template>
+
+<script>
+  import { chooseAndUploadFile, uploadCloudFiles } from './choose-and-upload-file.js';
+  import { get_extname, get_files_and_is_max, get_file_data } from './utils.js';
+  import uploadImage from './upload-image.vue';
+  import uploadFile from './upload-file.vue';
+  import sheep from '@/common';
+  import { isEmpty } from 'lodash-es';
+
+  let fileInput = null;
+  /**
+   * FilePicker 文件选择上传
+   * @description 文件选择上传组件,可以选择图片、视频等任意文件并上传到当前绑定的服务空间
+   * @tutorial https://ext.dcloud.net.cn/plugin?id=4079
+   * @property {Object|Array}	value	组件数据,通常用来回显 ,类型由return-type属性决定
+   * @property {String|Array}	url	  url数据
+   * @property {Boolean}	disabled = [true|false]	组件禁用
+   * 	@value true 	禁用
+   * 	@value false 	取消禁用
+   * @property {Boolean}	readonly = [true|false]	组件只读,不可选择,不显示进度,不显示删除按钮
+   * 	@value true 	只读
+   * 	@value false 	取消只读
+   * @property {Boolean}	disable-preview = [true|false]	禁用图片预览,仅 mode:grid 时生效
+   * 	@value true 	禁用图片预览
+   * 	@value false 	取消禁用图片预览
+   * @property {Boolean}	del-icon = [true|false]	是否显示删除按钮
+   * 	@value true 	显示删除按钮
+   * 	@value false 	不显示删除按钮
+   * @property {Boolean}	auto-upload = [true|false]	是否自动上传,值为true则只触发@select,可自行上传
+   * 	@value true 	自动上传
+   * 	@value false 	取消自动上传
+   * @property {Number|String}	limit	最大选择个数 ,h5 会自动忽略多选的部分
+   * @property {String}	title	组件标题,右侧显示上传计数
+   * @property {String}	mode = [list|grid]	选择文件后的文件列表样式
+   * 	@value list 	列表显示
+   * 	@value grid 	宫格显示
+   * @property {String}	file-mediatype = [image|video|all]	选择文件类型
+   * 	@value image	只选择图片
+   * 	@value video	只选择视频
+   * 	@value all		选择所有文件
+   * @property {Array}	file-extname	选择文件后缀,根据 file-mediatype 属性而不同
+   * @property {Object}	list-style	mode:list 时的样式
+   * @property {Object}	image-styles	选择文件后缀,根据 file-mediatype 属性而不同
+   * @event {Function} select 	选择文件后触发
+   * @event {Function} progress 文件上传时触发
+   * @event {Function} success 	上传成功触发
+   * @event {Function} fail 		上传失败触发
+   * @event {Function} delete 	文件从列表移除时触发
+   */
+  export default {
+    name: 'sUploader',
+    components: {
+      uploadImage,
+      uploadFile,
+    },
+    options: {
+      virtualHost: true,
+    },
+    emits: ['select', 'success', 'fail', 'progress', 'delete', 'update:modelValue', 'update:url'],
+    props: {
+      modelValue: {
+        type: [Array, Object],
+        default() {
+          return [];
+        },
+      },
+      url: {
+        type: [Array, String],
+        default() {
+          return [];
+        },
+      },
+      disabled: {
+        type: Boolean,
+        default: false,
+      },
+      disablePreview: {
+        type: Boolean,
+        default: false,
+      },
+      delIcon: {
+        type: Boolean,
+        default: true,
+      },
+      // 自动上传
+      autoUpload: {
+        type: Boolean,
+        default: true,
+      },
+      // 最大选择个数 ,h5只能限制单选或是多选
+      limit: {
+        type: [Number, String],
+        default: 9,
+      },
+      // 列表样式 grid | list | list-card
+      mode: {
+        type: String,
+        default: 'grid',
+      },
+      // 选择文件类型  image/video/all
+      fileMediatype: {
+        type: String,
+        default: 'image',
+      },
+      // 文件类型筛选
+      fileExtname: {
+        type: [Array, String],
+        default() {
+          return [];
+        },
+      },
+      title: {
+        type: String,
+        default: '',
+      },
+      listStyles: {
+        type: Object,
+        default() {
+          return {
+            // 是否显示边框
+            border: true,
+            // 是否显示分隔线
+            dividline: true,
+            // 线条样式
+            borderStyle: {},
+          };
+        },
+      },
+      imageStyles: {
+        type: Object,
+        default() {
+          return {
+            width: 'auto',
+            height: 'auto',
+          };
+        },
+      },
+      readonly: {
+        type: Boolean,
+        default: false,
+      },
+      sizeType: {
+        type: Array,
+        default() {
+          return ['original', 'compressed'];
+        },
+      },
+      driver: {
+        type: String,
+        default: 'local', // local=本地 | oss | unicloud
+      },
+      subtitle: {
+        type: String,
+        default: '',
+      },
+    },
+    data() {
+      return {
+        files: [],
+        localValue: [],
+        imgsrc: sheep.$url.static('/static/img/shop/upload-camera.png'),
+      };
+    },
+    watch: {
+      modelValue: {
+        handler(newVal, oldVal) {
+          this.setValue(newVal, oldVal);
+        },
+        immediate: true,
+      },
+    },
+    computed: {
+      returnType() {
+        if (this.limit > 1) {
+          return 'array';
+        }
+        return 'object';
+      },
+      filesList() {
+        let files = [];
+        this.files.forEach((v) => {
+          files.push(v);
+        });
+        return files;
+      },
+      showType() {
+        if (this.fileMediatype === 'image') {
+          return this.mode;
+        }
+        return 'list';
+      },
+      limitLength() {
+        if (this.returnType === 'object') {
+          return 1;
+        }
+        if (!this.limit) {
+          return 1;
+        }
+        if (this.limit >= 9) {
+          return 9;
+        }
+        return this.limit;
+      },
+    },
+    created() {
+      if (this.driver === 'local') {
+        uniCloud.chooseAndUploadFile = chooseAndUploadFile;
+      }
+      this.form = this.getForm('uniForms');
+      this.formItem = this.getForm('uniFormsItem');
+      if (this.form && this.formItem) {
+        if (this.formItem.name) {
+          this.rename = this.formItem.name;
+          this.form.inputChildrens.push(this);
+        }
+      }
+    },
+    methods: {
+      /**
+       * 公开用户使用,清空文件
+       * @param {Object} index
+       */
+      clearFiles(index) {
+        if (index !== 0 && !index) {
+          this.files = [];
+          this.$nextTick(() => {
+            this.setEmit();
+          });
+        } else {
+          this.files.splice(index, 1);
+        }
+        this.$nextTick(() => {
+          this.setEmit();
+        });
+      },
+      /**
+       * 公开用户使用,继续上传
+       */
+      upload() {
+        let files = [];
+        this.files.forEach((v, index) => {
+          if (v.status === 'ready' || v.status === 'error') {
+            files.push(Object.assign({}, v));
+          }
+        });
+        return this.uploadFiles(files);
+      },
+      async setValue(newVal, oldVal) {
+        const newData = async (v) => {
+          const reg = /cloud:\/\/([\w.]+\/?)\S*/;
+          let url = '';
+          if (v.fileID) {
+            url = v.fileID;
+          } else {
+            url = v.url;
+          }
+          if (reg.test(url)) {
+            v.fileID = url;
+            v.url = await this.getTempFileURL(url);
+          }
+          if (v.url) v.path = v.url;
+          return v;
+        };
+        if (this.returnType === 'object') {
+          if (newVal) {
+            await newData(newVal);
+          } else {
+            newVal = {};
+          }
+        } else {
+          if (!newVal) newVal = [];
+          for (let i = 0; i < newVal.length; i++) {
+            let v = newVal[i];
+            await newData(v);
+          }
+        }
+        this.localValue = newVal;
+        if (this.form && this.formItem && !this.is_reset) {
+          this.is_reset = false;
+          this.formItem.setValue(this.localValue);
+        }
+        let filesData = Object.keys(newVal).length > 0 ? newVal : [];
+        this.files = [].concat(filesData);
+      },
+
+      /**
+       * 选择文件
+       */
+      choose() {
+        if (this.disabled) return;
+        if (
+          this.files.length >= Number(this.limitLength) &&
+          this.showType !== 'grid' &&
+          this.returnType === 'array'
+        ) {
+          uni.showToast({
+            title: `您最多选择 ${this.limitLength} 个文件`,
+            icon: 'none',
+          });
+          return;
+        }
+        this.chooseFiles();
+      },
+
+      /**
+       * 选择文件并上传
+       */
+      async chooseFiles() {
+        const _extname = get_extname(this.fileExtname);
+        // 获取后缀
+        await chooseAndUploadFile({
+          type: this.fileMediatype,
+          compressed: false,
+          sizeType: this.sizeType,
+          // TODO 如果为空,video 有问题
+          extension: _extname.length > 0 ? _extname : undefined,
+          count: this.limitLength - this.files.length, //默认9
+          onChooseFile: this.chooseFileCallback,
+          onUploadProgress: (progressEvent) => {
+            this.setProgress(progressEvent, progressEvent.index);
+          },
+        })
+          .then((result) => {
+            this.setSuccessAndError(result);
+          })
+          .catch((err) => {
+            console.log('选择失败', err);
+          });
+      },
+
+      /**
+       * 选择文件回调
+       * @param {Object} res
+       */
+      async chooseFileCallback(res) {
+        const _extname = get_extname(this.fileExtname);
+        const is_one =
+          (Number(this.limitLength) === 1 && this.disablePreview && !this.disabled) ||
+          this.returnType === 'object';
+        // 如果这有一个文件 ,需要清空本地缓存数据
+        if (is_one) {
+          this.files = [];
+        }
+
+        let { filePaths, files } = get_files_and_is_max(res, _extname);
+        if (!(_extname && _extname.length > 0)) {
+          filePaths = res.tempFilePaths;
+          files = res.tempFiles;
+        }
+
+        let currentData = [];
+        for (let i = 0; i < files.length; i++) {
+          if (this.limitLength - this.files.length <= 0) break;
+          files[i].uuid = Date.now();
+          let filedata = await get_file_data(files[i], this.fileMediatype);
+          filedata.progress = 0;
+          filedata.status = 'ready';
+          this.files.push(filedata);
+          currentData.push({
+            ...filedata,
+            file: files[i],
+          });
+        }
+        this.$emit('select', {
+          tempFiles: currentData,
+          tempFilePaths: filePaths,
+        });
+        res.tempFiles = files;
+        // 停止自动上传
+        if (!this.autoUpload) {
+          res.tempFiles = [];
+        }
+      },
+
+      /**
+       * 批传
+       * @param {Object} e
+       */
+      uploadFiles(files) {
+        files = [].concat(files);
+        return uploadCloudFiles
+          .call(this, files, 5, (res) => {
+            this.setProgress(res, res.index, true);
+          })
+          .then((result) => {
+            this.setSuccessAndError(result);
+            return result;
+          })
+          .catch((err) => {
+            console.log(err);
+          });
+      },
+
+      /**
+       * 成功或失败
+       */
+      async setSuccessAndError(res, fn) {
+        let successData = [];
+        let errorData = [];
+        let tempFilePath = [];
+        let errorTempFilePath = [];
+        for (let i = 0; i < res.length; i++) {
+          const item = res[i];
+          const index = item.uuid ? this.files.findIndex((p) => p.uuid === item.uuid) : item.index;
+
+          if (index === -1 || !this.files) break;
+          if (item.errMsg === 'request:fail') {
+            this.files[index].url = item.url;
+            this.files[index].status = 'error';
+            this.files[index].errMsg = item.errMsg;
+            // this.files[index].progress = -1
+            errorData.push(this.files[index]);
+            errorTempFilePath.push(this.files[index].url);
+          } else {
+            this.files[index].errMsg = '';
+            this.files[index].fileID = item.url;
+            const reg = /cloud:\/\/([\w.]+\/?)\S*/;
+            if (reg.test(item.url)) {
+              this.files[index].url = await this.getTempFileURL(item.url);
+            } else {
+              this.files[index].url = item.url;
+            }
+
+            this.files[index].status = 'success';
+            this.files[index].progress += 1;
+            successData.push(this.files[index]);
+            tempFilePath.push(this.files[index].fileID);
+          }
+        }
+
+        if (successData.length > 0) {
+          this.setEmit();
+          // 状态改变返回
+          this.$emit('success', {
+            tempFiles: this.backObject(successData),
+            tempFilePaths: tempFilePath,
+          });
+        }
+
+        if (errorData.length > 0) {
+          this.$emit('fail', {
+            tempFiles: this.backObject(errorData),
+            tempFilePaths: errorTempFilePath,
+          });
+        }
+      },
+
+      /**
+       * 获取进度
+       * @param {Object} progressEvent
+       * @param {Object} index
+       * @param {Object} type
+       */
+      setProgress(progressEvent, index, type) {
+        const fileLenth = this.files.length;
+        const percentNum = (index / fileLenth) * 100;
+        const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
+        let idx = index;
+        if (!type) {
+          idx = this.files.findIndex((p) => p.uuid === progressEvent.tempFile.uuid);
+        }
+        if (idx === -1 || !this.files[idx]) return;
+        // fix by mehaotian 100 就会消失,-1 是为了让进度条消失
+        this.files[idx].progress = percentCompleted - 1;
+        // 上传中
+        this.$emit('progress', {
+          index: idx,
+          progress: parseInt(percentCompleted),
+          tempFile: this.files[idx],
+        });
+      },
+
+      /**
+       * 删除文件
+       * @param {Object} index
+       */
+      delFile(index) {
+        if (!isEmpty(this.files)) {
+          this.$emit('delete', {
+            tempFile: this.files[index],
+            tempFilePath: this.files[index].url,
+          });
+          this.files.splice(index, 1);
+        } else {
+          this.$emit('delete', {
+            tempFilePath: this.url,
+          });
+        }
+
+        this.$nextTick(() => {
+          this.setEmit();
+        });
+      },
+
+      /**
+       * 获取文件名和后缀
+       * @param {Object} name
+       */
+      getFileExt(name) {
+        const last_len = name.lastIndexOf('.');
+        const len = name.length;
+        return {
+          name: name.substring(0, last_len),
+          ext: name.substring(last_len + 1, len),
+        };
+      },
+
+      /**
+       * 处理返回事件
+       */
+      setEmit() {
+        let data = [];
+        let updateUrl = [];
+        if (this.returnType === 'object') {
+          data = this.backObject(this.files)[0];
+          this.localValue = data ? data : null;
+          updateUrl = data ? data.url : '';
+        } else {
+          data = this.backObject(this.files);
+          if (!this.localValue) {
+            this.localValue = [];
+          }
+          this.localValue = [...data];
+          if (this.localValue.length > 0) {
+            this.localValue.forEach((item) => {
+              updateUrl.push(item.url);
+            });
+          }
+        }
+        this.$emit('update:modelValue', this.localValue);
+        this.$emit('update:url', updateUrl);
+      },
+
+      /**
+       * 处理返回参数
+       * @param {Object} files
+       */
+      backObject(files) {
+        let newFilesData = [];
+        files.forEach((v) => {
+          newFilesData.push({
+            extname: v.extname,
+            fileType: v.fileType,
+            image: v.image,
+            name: v.name,
+            path: v.path,
+            size: v.size,
+            fileID: v.fileID,
+            url: v.url,
+          });
+        });
+        return newFilesData;
+      },
+      async getTempFileURL(fileList) {
+        fileList = {
+          fileList: [].concat(fileList),
+        };
+        const urls = await uniCloud.getTempFileURL(fileList);
+        return urls.fileList[0].tempFileURL || '';
+      },
+      /**
+       * 获取父元素实例
+       */
+      getForm(name = 'uniForms') {
+        let parent = this.$parent;
+        let parentName = parent.$options.name;
+        while (parentName !== name) {
+          parent = parent.$parent;
+          if (!parent) return false;
+          parentName = parent.$options.name;
+        }
+        return parent;
+      },
+    },
+  };
+</script>
+
+<style lang="scss" scoped>
+  .uni-file-picker {
+    /* #ifndef APP-NVUE */
+    box-sizing: border-box;
+    overflow: hidden;
+    /* width: 100%; */
+    /* #endif */
+    /* flex: 1; */
+    position: relative;
+  }
+
+  .uni-file-picker__header {
+    padding-top: 5px;
+    padding-bottom: 10px;
+    /* #ifndef APP-NVUE */
+    display: flex;
+    /* #endif */
+    justify-content: space-between;
+  }
+
+  .file-title {
+    font-size: 14px;
+    color: #333;
+  }
+
+  .file-count {
+    font-size: 14px;
+    color: #999;
+  }
+
+  .is-add {
+    /* #ifndef APP-NVUE */
+    display: flex;
+    /* #endif */
+    align-items: center;
+    justify-content: center;
+  }
+  .add-icon {
+    width: 57rpx;
+    height: 49rpx;
+  }
+  .file-subtitle {
+    position: absolute;
+    left: 50%;
+    transform: translateX(-50%);
+    bottom: 0;
+    width: 140rpx;
+    height: 36rpx;
+    z-index: 1;
+    display: flex;
+    justify-content: center;
+    color: #fff;
+    font-weight: 500;
+    background: rgba(#000, 0.3);
+    font-size: 24rpx;
+  }
+</style>

+ 335 - 0
common/components/s-uploader/upload-file.vue

@@ -0,0 +1,335 @@
+<template>
+  <view class="uni-file-picker__files">
+    <view v-if="!readonly" class="files-button" @click="choose">
+      <slot></slot>
+    </view>
+    <!-- :class="{'is-text-box':showType === 'list'}" -->
+    <view v-if="list.length > 0" class="uni-file-picker__lists is-text-box" :style="borderStyle">
+      <!-- ,'is-list-card':showType === 'list-card' -->
+
+      <view
+        class="uni-file-picker__lists-box"
+        v-for="(item, index) in list"
+        :key="index"
+        :class="{
+          'files-border': index !== 0 && styles.dividline,
+        }"
+        :style="index !== 0 && styles.dividline && borderLineStyle"
+      >
+        <view class="uni-file-picker__item">
+          <!-- :class="{'is-text-image':showType === 'list'}" -->
+          <!-- 	<view class="files__image is-text-image">
+						<image class="header-image" :src="item.logo" mode="aspectFit"></image>
+					</view> -->
+          <view class="files__name">{{ item.name }}</view>
+          <view v-if="delIcon && !readonly" class="icon-del-box icon-files" @click="delFile(index)">
+            <view class="icon-del icon-files"></view>
+            <view class="icon-del rotate"></view>
+          </view>
+        </view>
+        <view
+          v-if="(item.progress && item.progress !== 100) || item.progress === 0"
+          class="file-picker__progress"
+        >
+          <progress
+            class="file-picker__progress-item"
+            :percent="item.progress === -1 ? 0 : item.progress"
+            stroke-width="4"
+            :backgroundColor="item.errMsg ? '#ff5a5f' : '#EBEBEB'"
+          />
+        </view>
+        <view
+          v-if="item.status === 'error'"
+          class="file-picker__mask"
+          @click.stop="uploadFiles(item, index)"
+        >
+          点击重试
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'uploadFile',
+    emits: ['uploadFiles', 'choose', 'delFile'],
+    props: {
+      filesList: {
+        type: Array,
+        default() {
+          return [];
+        },
+      },
+      delIcon: {
+        type: Boolean,
+        default: true,
+      },
+      limit: {
+        type: [Number, String],
+        default: 9,
+      },
+      showType: {
+        type: String,
+        default: '',
+      },
+      listStyles: {
+        type: Object,
+        default() {
+          return {
+            // 是否显示边框
+            border: true,
+            // 是否显示分隔线
+            dividline: true,
+            // 线条样式
+            borderStyle: {},
+          };
+        },
+      },
+      readonly: {
+        type: Boolean,
+        default: false,
+      },
+    },
+    computed: {
+      list() {
+        let files = [];
+        this.filesList.forEach((v) => {
+          files.push(v);
+        });
+        return files;
+      },
+      styles() {
+        let styles = {
+          border: true,
+          dividline: true,
+          'border-style': {},
+        };
+        return Object.assign(styles, this.listStyles);
+      },
+      borderStyle() {
+        let { borderStyle, border } = this.styles;
+        let obj = {};
+        if (!border) {
+          obj.border = 'none';
+        } else {
+          let width = (borderStyle && borderStyle.width) || 1;
+          width = this.value2px(width);
+          let radius = (borderStyle && borderStyle.radius) || 5;
+          radius = this.value2px(radius);
+          obj = {
+            'border-width': width,
+            'border-style': (borderStyle && borderStyle.style) || 'solid',
+            'border-color': (borderStyle && borderStyle.color) || '#eee',
+            'border-radius': radius,
+          };
+        }
+        let classles = '';
+        for (let i in obj) {
+          classles += `${i}:${obj[i]};`;
+        }
+        return classles;
+      },
+      borderLineStyle() {
+        let obj = {};
+        let { borderStyle } = this.styles;
+        if (borderStyle && borderStyle.color) {
+          obj['border-color'] = borderStyle.color;
+        }
+        if (borderStyle && borderStyle.width) {
+          let width = (borderStyle && borderStyle.width) || 1;
+          let style = (borderStyle && borderStyle.style) || 0;
+          if (typeof width === 'number') {
+            width += 'px';
+          } else {
+            width = width.indexOf('px') ? width : width + 'px';
+          }
+          obj['border-width'] = width;
+
+          if (typeof style === 'number') {
+            style += 'px';
+          } else {
+            style = style.indexOf('px') ? style : style + 'px';
+          }
+          obj['border-top-style'] = style;
+        }
+        let classles = '';
+        for (let i in obj) {
+          classles += `${i}:${obj[i]};`;
+        }
+        return classles;
+      },
+    },
+
+    methods: {
+      uploadFiles(item, index) {
+        this.$emit('uploadFiles', {
+          item,
+          index,
+        });
+      },
+      choose() {
+        this.$emit('choose');
+      },
+      delFile(index) {
+        this.$emit('delFile', index);
+      },
+      value2px(value) {
+        if (typeof value === 'number') {
+          value += 'px';
+        } else {
+          value = value.indexOf('px') !== -1 ? value : value + 'px';
+        }
+        return value;
+      },
+    },
+  };
+</script>
+
+<style lang="scss">
+  .uni-file-picker__files {
+    /* #ifndef APP-NVUE */
+    display: flex;
+    /* #endif */
+    flex-direction: column;
+    justify-content: flex-start;
+  }
+
+  .files-button {
+    // border: 1px red solid;
+  }
+
+  .uni-file-picker__lists {
+    position: relative;
+    margin-top: 5px;
+    overflow: hidden;
+  }
+
+  .file-picker__mask {
+    /* #ifndef APP-NVUE */
+    display: flex;
+    /* #endif */
+    justify-content: center;
+    align-items: center;
+    position: absolute;
+    right: 0;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    color: #fff;
+    font-size: 14px;
+    background-color: rgba(0, 0, 0, 0.4);
+  }
+
+  .uni-file-picker__lists-box {
+    position: relative;
+  }
+
+  .uni-file-picker__item {
+    /* #ifndef APP-NVUE */
+    display: flex;
+    /* #endif */
+    align-items: center;
+    padding: 8px 10px;
+    padding-right: 5px;
+    padding-left: 10px;
+  }
+
+  .files-border {
+    border-top: 1px #eee solid;
+  }
+
+  .files__name {
+    flex: 1;
+    font-size: 14px;
+    color: #666;
+    margin-right: 25px;
+    /* #ifndef APP-NVUE */
+    word-break: break-all;
+    word-wrap: break-word;
+    /* #endif */
+  }
+
+  .icon-files {
+    /* #ifndef APP-NVUE */
+    position: static;
+    background-color: initial;
+    /* #endif */
+  }
+
+  // .icon-files .icon-del {
+  // 	background-color: #333;
+  // 	width: 12px;
+  // 	height: 1px;
+  // }
+
+  .is-list-card {
+    border: 1px #eee solid;
+    margin-bottom: 5px;
+    border-radius: 5px;
+    box-shadow: 0 0 2px 0px rgba(0, 0, 0, 0.1);
+    padding: 5px;
+  }
+
+  .files__image {
+    width: 40px;
+    height: 40px;
+    margin-right: 10px;
+  }
+
+  .header-image {
+    width: 100%;
+    height: 100%;
+  }
+
+  .is-text-box {
+    border: 1px #eee solid;
+    border-radius: 5px;
+  }
+
+  .is-text-image {
+    width: 25px;
+    height: 25px;
+    margin-left: 5px;
+  }
+
+  .rotate {
+    position: absolute;
+    transform: rotate(90deg);
+  }
+
+  .icon-del-box {
+    /* #ifndef APP-NVUE */
+    display: flex;
+    margin: auto 0;
+    /* #endif */
+    align-items: center;
+    justify-content: center;
+    position: absolute;
+    top: 0px;
+    bottom: 0;
+    right: 5px;
+    height: 26px;
+    width: 26px;
+    // border-radius: 50%;
+    // background-color: rgba(0, 0, 0, 0.5);
+    z-index: 2;
+    transform: rotate(-45deg);
+  }
+
+  .icon-del {
+    width: 15px;
+    height: 1px;
+    background-color: #333;
+    // border-radius: 1px;
+  }
+
+  /* #ifdef H5 */
+  @media all and (min-width: 768px) {
+    .uni-file-picker__files {
+      max-width: 375px;
+    }
+  }
+
+  /* #endif */
+</style>

+ 306 - 0
common/components/s-uploader/upload-image.vue

@@ -0,0 +1,306 @@
+<template>
+  <view class="uni-file-picker__container">
+    <view class="file-picker__box" v-for="(url, index) in list" :key="index" :style="boxStyle">
+      <view class="file-picker__box-content" :style="borderStyle">
+        <image
+          class="file-image"
+          :src="getImageUrl(url)"
+          mode="aspectFill"
+          @click.stop="previewImage(url, index)"
+        ></image>
+        <view v-if="delIcon && !readonly" class="icon-del-box" @click.stop="delFile(index)">
+          <view class="icon-del"></view>
+          <view class="icon-del rotate"></view>
+        </view>
+        <!-- <view v-if="item.errMsg" class="file-picker__mask" @click.stop="uploadFiles(item, index)">
+          点击重试
+        </view> -->
+      </view>
+    </view>
+    <view v-if="list.length < limit && !readonly" class="file-picker__box" :style="boxStyle">
+      <view class="file-picker__box-content is-add" :style="borderStyle" @click="choose">
+        <slot>
+          <view class="icon-add"></view>
+          <view class="icon-add rotate"></view>
+        </slot>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+  import sheep from '@/common';
+  export default {
+    name: 'uploadImage',
+    emits: ['uploadFiles', 'choose', 'delFile'],
+    props: {
+      filesList: {
+        type: [Array, String],
+        default() {
+          return [];
+        },
+      },
+      disabled: {
+        type: Boolean,
+        default: false,
+      },
+      disablePreview: {
+        type: Boolean,
+        default: false,
+      },
+      limit: {
+        type: [Number, String],
+        default: 9,
+      },
+      imageStyles: {
+        type: Object,
+        default() {
+          return {
+            width: 'auto',
+            height: 'auto',
+            border: {},
+          };
+        },
+      },
+      delIcon: {
+        type: Boolean,
+        default: true,
+      },
+      readonly: {
+        type: Boolean,
+        default: false,
+      },
+    },
+    computed: {
+      list() {
+        if (typeof this.filesList === 'string') {
+          if (this.filesList) {
+            return [this.filesList];
+          } else {
+            return [];
+          }
+        }
+        return this.filesList;
+      },
+      styles() {
+        let styles = {
+          width: 'auto',
+          height: 'auto',
+          border: {},
+        };
+        return Object.assign(styles, this.imageStyles);
+      },
+      boxStyle() {
+        const { width = 'auto', height = 'auto' } = this.styles;
+        let obj = {};
+        if (height === 'auto') {
+          if (width !== 'auto') {
+            obj.height = this.value2px(width);
+            obj['padding-top'] = 0;
+          } else {
+            obj.height = 0;
+          }
+        } else {
+          obj.height = this.value2px(height);
+          obj['padding-top'] = 0;
+        }
+
+        if (width === 'auto') {
+          if (height !== 'auto') {
+            obj.width = this.value2px(height);
+          } else {
+            obj.width = '33.3%';
+          }
+        } else {
+          obj.width = this.value2px(width);
+        }
+
+        let classles = '';
+        for (let i in obj) {
+          classles += `${i}:${obj[i]};`;
+        }
+        return classles;
+      },
+      borderStyle() {
+        let { border } = this.styles;
+        let obj = {};
+        const widthDefaultValue = 1;
+        const radiusDefaultValue = 3;
+        if (typeof border === 'boolean') {
+          obj.border = border ? '1px #eee solid' : 'none';
+        } else {
+          let width = (border && border.width) || widthDefaultValue;
+          width = this.value2px(width);
+          let radius = (border && border.radius) || radiusDefaultValue;
+          radius = this.value2px(radius);
+          obj = {
+            'border-width': width,
+            'border-style': (border && border.style) || 'solid',
+            'border-color': (border && border.color) || '#eee',
+            'border-radius': radius,
+          };
+        }
+        let classles = '';
+        for (let i in obj) {
+          classles += `${i}:${obj[i]};`;
+        }
+        return classles;
+      },
+    },
+    methods: {
+      getImageUrl(url) {
+        if ('blob:http:' === url.substr(0, 10)) {
+          return url;
+        } else {
+          return sheep.$url.cdn(url);
+        }
+      },
+      uploadFiles(item, index) {
+        this.$emit('uploadFiles', item);
+      },
+      choose() {
+        this.$emit('choose');
+      },
+      delFile(index) {
+        this.$emit('delFile', index);
+      },
+      previewImage(img, index) {
+        let urls = [];
+        if (Number(this.limit) === 1 && this.disablePreview && !this.disabled) {
+          this.$emit('choose');
+        }
+        if (this.disablePreview) return;
+        this.list.forEach((i) => {
+          urls.push(this.getImageUrl(i));
+        });
+
+        uni.previewImage({
+          urls: urls,
+          current: index,
+        });
+      },
+      value2px(value) {
+        if (typeof value === 'number') {
+          value += 'px';
+        } else {
+          if (value.indexOf('%') === -1) {
+            value = value.indexOf('px') !== -1 ? value : value + 'px';
+          }
+        }
+        return value;
+      },
+    },
+  };
+</script>
+
+<style lang="scss">
+  .uni-file-picker__container {
+    /* #ifndef APP-NVUE */
+    display: flex;
+    box-sizing: border-box;
+    /* #endif */
+    flex-wrap: wrap;
+    margin: -5px;
+  }
+
+  .file-picker__box {
+    position: relative;
+    // flex: 0 0 33.3%;
+    width: 33.3%;
+    height: 0;
+    padding-top: 33.33%;
+    /* #ifndef APP-NVUE */
+    box-sizing: border-box;
+    /* #endif */
+  }
+
+  .file-picker__box-content {
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    margin: 5px;
+    border: 1px #eee solid;
+    border-radius: 5px;
+    overflow: hidden;
+  }
+
+  .file-picker__progress {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    /* border: 1px red solid; */
+    z-index: 2;
+  }
+
+  .file-picker__progress-item {
+    width: 100%;
+  }
+
+  .file-picker__mask {
+    /* #ifndef APP-NVUE */
+    display: flex;
+    /* #endif */
+    justify-content: center;
+    align-items: center;
+    position: absolute;
+    right: 0;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    color: #fff;
+    font-size: 12px;
+    background-color: rgba(0, 0, 0, 0.4);
+  }
+
+  .file-image {
+    width: 100%;
+    height: 100%;
+  }
+
+  .is-add {
+    /* #ifndef APP-NVUE */
+    display: flex;
+    /* #endif */
+    align-items: center;
+    justify-content: center;
+  }
+
+  .icon-add {
+    width: 50px;
+    height: 5px;
+    background-color: #f1f1f1;
+    border-radius: 2px;
+  }
+
+  .rotate {
+    position: absolute;
+    transform: rotate(90deg);
+  }
+
+  .icon-del-box {
+    /* #ifndef APP-NVUE */
+    display: flex;
+    /* #endif */
+    align-items: center;
+    justify-content: center;
+    position: absolute;
+    top: 3px;
+    right: 3px;
+    height: 26px;
+    width: 26px;
+    border-radius: 50%;
+    background-color: rgba(0, 0, 0, 0.5);
+    z-index: 2;
+    transform: rotate(-45deg);
+  }
+
+  .icon-del {
+    width: 15px;
+    height: 2px;
+    background-color: #fff;
+    border-radius: 2px;
+  }
+</style>

+ 109 - 0
common/components/s-uploader/utils.js

@@ -0,0 +1,109 @@
+/**
+ * 获取文件名和后缀
+ * @param {String} name
+ */
+export const get_file_ext = (name) => {
+  const last_len = name.lastIndexOf('.');
+  const len = name.length;
+  return {
+    name: name.substring(0, last_len),
+    ext: name.substring(last_len + 1, len),
+  };
+};
+
+/**
+ * 获取扩展名
+ * @param {Array} fileExtname
+ */
+export const get_extname = (fileExtname) => {
+  if (!Array.isArray(fileExtname)) {
+    let extname = fileExtname.replace(/([\[\]])/g, '');
+    return extname.split(',');
+  } else {
+    return fileExtname;
+  }
+};
+
+/**
+ * 获取文件和检测是否可选
+ */
+export const get_files_and_is_max = (res, _extname) => {
+  let filePaths = [];
+  let files = [];
+  if (!_extname || _extname.length === 0) {
+    return {
+      filePaths,
+      files,
+    };
+  }
+  res.tempFiles.forEach((v) => {
+    let fileFullName = get_file_ext(v.name);
+    const extname = fileFullName.ext.toLowerCase();
+    if (_extname.indexOf(extname) !== -1) {
+      files.push(v);
+      filePaths.push(v.path);
+    }
+  });
+  if (files.length !== res.tempFiles.length) {
+    uni.showToast({
+      title: `当前选择了${res.tempFiles.length}个文件 ,${
+        res.tempFiles.length - files.length
+      } 个文件格式不正确`,
+      icon: 'none',
+      duration: 5000,
+    });
+  }
+
+  return {
+    filePaths,
+    files,
+  };
+};
+
+/**
+ * 获取图片信息
+ * @param {Object} filepath
+ */
+export const get_file_info = (filepath) => {
+  return new Promise((resolve, reject) => {
+    uni.getImageInfo({
+      src: filepath,
+      success(res) {
+        resolve(res);
+      },
+      fail(err) {
+        reject(err);
+      },
+    });
+  });
+};
+/**
+ * 获取封装数据
+ */
+export const get_file_data = async (files, type = 'image') => {
+  // 最终需要上传数据库的数据
+  let fileFullName = get_file_ext(files.name);
+  const extname = fileFullName.ext.toLowerCase();
+  let filedata = {
+    name: files.name,
+    uuid: files.uuid,
+    extname: extname || '',
+    cloudPath: files.cloudPath,
+    fileType: files.fileType,
+    url: files.url || files.path,
+    size: files.size, //单位是字节
+    image: {},
+    path: files.path,
+    video: {},
+  };
+  if (type === 'image') {
+    const imageinfo = await get_file_info(files.path);
+    delete filedata.video;
+    filedata.image.width = imageinfo.width;
+    filedata.image.height = imageinfo.height;
+    filedata.image.location = imageinfo.path;
+  } else {
+    delete filedata.image;
+  }
+  return filedata;
+};

+ 184 - 0
common/components/s-user-card/s-user-card.vue

@@ -0,0 +1,184 @@
+<!-- 装修用户组件:用户卡片 -->
+<template>
+  <view class="ss-user-info-wrap ss-p-t-50" :style="[bgStyle, { marginLeft: `${data.space}px` }]">
+    <view class="ss-flex ss-col-center ss-row-between ss-m-b-20">
+      <view class="left-box ss-flex ss-col-center ss-m-l-36">
+        <view class="avatar-box ss-m-r-24">
+          <image class="avatar-img" :src="
+              isLogin && userInfo.avatar
+                ? sheep.$url.cdn(userInfo.avatar)
+                : sheep.$url.static('/static/img/shop/default_avatar.png')"
+                 mode="aspectFill" @tap="sheep.$router.go('/pages/user/info')">
+          </image>
+        </view>
+        <view>
+          <view class="nickname-box ss-flex ss-col-center">
+            <view class="nick-name ss-m-r-20">{{ userInfo?.nickname || nickname }}</view>
+          </view>
+        </view>
+      </view>
+      <view class="right-box ss-m-r-52">
+        <button class="ss-reset-button" @tap="showShareModal">
+          <text class="sicon-qrcode"></text>
+        </button>
+      </view>
+    </view>
+
+    <!-- 提示绑定手机号 先隐藏 yudao 需要再修改 -->
+    <view
+      class="bind-mobile-box ss-flex ss-row-between ss-col-center"
+      v-if="isLogin && !userInfo.mobile"
+    >
+      <view class="ss-flex">
+        <text class="cicon-mobile-o" />
+        <view class="mobile-title ss-m-l-20"> 点击绑定手机号确保账户安全</view>
+      </view>
+      <button class="ss-reset-button bind-btn" @tap="onBind">去绑定</button>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  /**
+   * 用户卡片
+   *
+   * @property {Number} leftSpace                  - 容器左间距
+   * @property {Number} rightSpace                  - 容器右间距
+   *
+   * @property {String} avatar          - 头像
+   * @property {String} nickname          - 昵称
+   * @property {String} vip              - 等级
+   * @property {String} collectNum        - 收藏数
+   * @property {String} likeNum          - 点赞数
+   *
+   *
+   */
+  import { computed } from 'vue';
+  import sheep from '@/common';
+  import {
+    showShareModal,
+    showAuthModal,
+  } from '@/common/hooks/useModal';
+
+  // 用户信息
+  const userInfo = computed(() => sheep.$store('user').userInfo);
+
+  // 是否登录
+  const isLogin = computed(() => sheep.$store('user').isLogin);
+  // 接收参数
+  const props = defineProps({
+    // 装修数据
+    data: {
+      type: Object,
+      default: () => ({}),
+    },
+    // 装修样式
+    styles: {
+      type: Object,
+      default: () => ({}),
+    },
+    // 头像
+    avatar: {
+      type: String,
+      default: '',
+    },
+    nickname: {
+      type: String,
+      default: '请先登录',
+    },
+    vip: {
+      type: [String, Number],
+      default: '1',
+    },
+    collectNum: {
+      type: [String, Number],
+      default: '1',
+    },
+    likeNum: {
+      type: [String, Number],
+      default: '1',
+    },
+  });
+
+  // 设置背景样式
+  const bgStyle = computed(() => {
+    // 直接从 props.styles 解构
+    const { bgType, bgImg, bgColor } = props.styles;
+
+    // 根据 bgType 返回相应的样式
+    return {
+      background: bgType === 'img'
+        ? `url(${bgImg}) no-repeat top center / 100% 100%`
+        : bgColor,
+    };
+  });
+
+  // 绑定手机号
+  function onBind() {
+    showAuthModal('changeMobile');
+  }
+</script>
+
+<style lang="scss" scoped>
+  .ss-user-info-wrap {
+    box-sizing: border-box;
+
+    .avatar-box {
+      width: 100rpx;
+      height: 100rpx;
+      border-radius: 50%;
+      overflow: hidden;
+
+      .avatar-img {
+        width: 100%;
+        height: 100%;
+      }
+    }
+
+    .nick-name {
+      font-size: 34rpx;
+      font-weight: 400;
+      color: #333333;
+      line-height: normal;
+    }
+
+    .vip-img {
+      width: 30rpx;
+      height: 30rpx;
+    }
+
+    .sicon-qrcode {
+      font-size: 40rpx;
+    }
+  }
+
+  .bind-mobile-box {
+    width: 100%;
+    height: 84rpx;
+    padding: 0 34rpx 0 44rpx;
+    box-sizing: border-box;
+    background: #ffffff;
+    box-shadow: 0px -8rpx 9rpx 0px rgba(#e0e0e0, 0.3);
+
+    .cicon-mobile-o {
+      font-size: 30rpx;
+      color: #ff690d;
+    }
+
+    .mobile-title {
+      font-size: 24rpx;
+      font-weight: 500;
+      color: #ff690d;
+    }
+
+    .bind-btn {
+      width: 100rpx;
+      height: 50rpx;
+      background: #ff6100;
+      border-radius: 25rpx;
+      font-size: 24rpx;
+      font-weight: 500;
+      color: #ffffff;
+    }
+  }
+</style>

+ 32 - 0
common/components/s-video-block/s-video-block.vue

@@ -0,0 +1,32 @@
+<!-- 装修图文组件:视频播放 -->
+<template>
+  <su-video
+    class="sss"
+    :uid="guid()"
+    :src="sheep.$url.cdn(data.videoUrl)"
+    :poster="sheep.$url.cdn(data.posterUrl)"
+    :height="styles.height * 2"
+    :autoplay="data.autoplay"
+  ></su-video>
+</template>
+
+<script setup>
+  import sheep from '@/common';
+  import { guid } from '@/common/helper';
+  const props = defineProps({
+    data: {
+      type: Object,
+      default() {},
+    },
+    styles: {
+      type: Object,
+      default() {},
+    },
+  });
+</script>
+
+<style lang="scss" scoped>
+  .sss {
+    z-index: -100;
+  }
+</style>

+ 119 - 0
common/components/s-wallet-card/s-wallet-card.vue

@@ -0,0 +1,119 @@
+<!-- 装修用户组件:用户资产 -->
+<template>
+	<view class="ss-wallet-menu-wrap ss-flex ss-col-center" :style="[bgStyle, { marginLeft: `${data.space}px` }]">
+		<view class="menu-item ss-flex-1 ss-flex-col ss-row-center ss-col-center"
+			@tap="sheep.$router.go('/pages/user/wallet/money')">
+			<view class="value-box ss-flex ss-col-bottom">
+				<view class="value-text ss-line-1">{{ fen2yuan(userWallet.balance) || '0.00' }}</view>
+				<view class="unit-text ss-m-l-6">元</view>
+			</view>
+			<view class="menu-title ss-m-t-28">账户余额</view>
+		</view>
+		<view class="menu-item ss-flex-1 ss-flex-col ss-row-center ss-col-center"
+			@tap="sheep.$router.go('/pages/user/wallet/score')">
+			<view class="value-box ss-flex ss-col-bottom">
+				<view class="value-text">{{ userInfo.point || 0 }}</view>
+				<view class="unit-text ss-m-l-6">个</view>
+			</view>
+			<view class="menu-title ss-m-t-28">积分</view>
+		</view>
+		<view class="menu-item ss-flex-1 ss-flex-col ss-row-center ss-col-center" @tap="
+        sheep.$router.go('/pages/coupon/list', {
+          type: 'geted',
+        })
+      ">
+			<view class="value-box ss-flex ss-col-bottom">
+				<view class="value-text">{{ numData.unusedCouponCount }}</view>
+				<view class="unit-text ss-m-l-6">张</view>
+			</view>
+			<view class="menu-title ss-m-t-28">优惠券</view>
+		</view>
+		<view class="menu-item ss-flex-col ss-row-center ss-col-center menu-wallet"
+          @tap="sheep.$router.go('/pages/user/wallet/money')">
+			<image class="item-icon" :src="sheep.$url.static('/static/img/shop/user/wallet_icon.png')" mode="aspectFit" />
+			<view class="menu-title ss-m-t-30">我的钱包</view>
+		</view>
+	</view>
+</template>
+
+<script setup>
+	/**
+	 * 装修组件 - 订单菜单组
+	 */
+	import { computed } from 'vue';
+	import sheep from '@/common';
+	import { fen2yuan } from '../../hooks/useGoods';
+
+	// 接收参数
+	const props = defineProps({
+		// 装修数据
+		data: {
+		  type: Object,
+		  default: () => ({}),
+		},
+		// 装修样式
+		styles: {
+		  type: Object,
+		  default: () => ({}),
+		},
+	});
+	// 设置背景样式
+	const bgStyle = computed(() => {
+	  // 直接从 props.styles 解构
+	  const { bgType, bgImg, bgColor } = props.styles; 
+	
+	  // 根据 bgType 返回相应的样式
+	  return {
+		background: bgType === 'img'
+			? `url(${bgImg}) no-repeat top center / 100% 100%`
+			: bgColor
+		};
+	});
+	
+	const userWallet = computed(() => sheep.$store('user').userWallet);
+	const userInfo = computed(() => sheep.$store('user').userInfo);
+	const numData = computed(() => sheep.$store('user').numData);
+</script>
+
+<style lang="scss" scoped>
+	.ss-wallet-menu-wrap {
+		.menu-wallet {
+			width: 144rpx;
+		}
+
+		.menu-item {
+			height: 160rpx;
+
+			.menu-title {
+				font-size: 24rpx;
+				line-height: 24rpx;
+				color: #333333;
+			}
+
+			.item-icon {
+				width: 44rpx;
+				height: 44rpx;
+			}
+
+			.value-box {
+				height: 50rpx;
+				text-align: center;
+
+				.value-text {
+					font-size: 28rpx;
+					color: #000000;
+					line-height: 28rpx;
+					vertical-align: text-bottom;
+					font-family: OPPOSANS;
+				}
+
+				.unit-text {
+					font-size: 24rpx;
+					color: #343434;
+					line-height: 24rpx;
+				}
+			}
+		}
+	}
+</style>
+

+ 25 - 0
common/config/index.js

@@ -0,0 +1,25 @@
+import packageInfo from '@/package.json';
+
+const { version } = packageInfo;
+
+// 开发环境配置
+export let baseUrl;
+if (process.env.NODE_ENV === 'development') {
+  baseUrl = import.meta.env.SOUND_CHAIN_DEV_BASE_URL;
+} else {
+  baseUrl = import.meta.env.SOUND_CHAIN_BASE_URL;
+}
+if (typeof baseUrl === 'undefined') {
+  console.error('请检查.env配置文件是否存在');
+} else {
+  console.log(`[音链 ${version}]  https://doc.iocoder.cn`);
+}
+
+export const apiPath = import.meta.env.SOUND_CHAIN_API_PATH;
+export const staticUrl = import.meta.env.SOUND_CHAIN_STATIC_URL;
+
+export default {
+  baseUrl,
+  apiPath,
+  staticUrl,
+};

+ 20 - 0
common/config/zIndex.js

@@ -0,0 +1,20 @@
+// uniapp在H5中各API的z-index值如下:
+/**
+ * actionsheet: 999
+ * modal: 999
+ * navigate: 998
+ * tabbar: 998
+ * toast: 999
+ */
+
+export default {
+  toast: 10090,
+  noNetwork: 10080,
+  popup: 10075, // popup包含popup,actionsheet,keyboard,picker的值
+  mask: 10070,
+  navbar: 980,
+  topTips: 975,
+  sticky: 970,
+  indexListSticky: 965,
+  popover: 960,
+};

+ 153 - 0
common/helper/const.js

@@ -0,0 +1,153 @@
+// ========== COMMON - 公共模块 ==========
+
+/**
+ * 与后端Terminal枚举一一对应
+ */
+export const TerminalEnum = {
+  UNKNOWN: 0, // 未知, 目的:在无法解析到 terminal 时,使用它
+  WECHAT_MINI_PROGRAM: 10, //微信小程序
+  WECHAT_WAP: 11, // 微信公众号
+  H5: 20, // H5 网页
+  APP: 31, // 手机 App
+};
+
+/**
+ * 将 uni-app 提供的平台转换为后端所需的 terminal值
+ *
+ * @return 终端
+ */
+export const getTerminal = () => {
+  const platformType = uni.getAppBaseInfo().uniPlatform;
+  // 与后端terminal枚举一一对应
+  switch (platformType) {
+    case 'app':
+      return TerminalEnum.APP;
+    case 'web':
+      return TerminalEnum.H5;
+    case 'mp-weixin':
+      return TerminalEnum.WECHAT_MINI_PROGRAM;
+    default:
+      return TerminalEnum.UNKNOWN;
+  }
+};
+
+// ========== MALL - 营销模块 ==========
+
+import dayjs from 'dayjs';
+
+/**
+ * 优惠类型枚举
+ */
+export const PromotionDiscountTypeEnum = {
+  PRICE: {
+    type: 1,
+    name: '满减',
+  },
+  PERCENT: {
+    type: 2,
+    name: '折扣',
+  },
+};
+
+/**
+ * 优惠劵模板的有限期类型的枚举
+ */
+export const CouponTemplateValidityTypeEnum = {
+  DATE: {
+    type: 1,
+    name: '固定日期可用',
+  },
+  TERM: {
+    type: 2,
+    name: '领取之后可用',
+  },
+};
+
+// 时间段的状态枚举
+export const TimeStatusEnum = {
+  WAIT_START: '即将开始',
+  STARTED: '进行中',
+  END: '已结束',
+};
+
+/**
+ * 微信小程序的订阅模版
+ */
+export const WxaSubscribeTemplate = {
+  TRADE_ORDER_DELIVERY: '订单发货通知',
+  PROMOTION_COMBINATION_SUCCESS: '拼团结果通知',
+  PAY_WALLET_RECHARGER_SUCCESS: '充值成功通知',
+};
+export const PromotionActivityTypeEnum = {
+  NORMAL: {
+    type: 0,
+    name: '普通',
+  },
+  SECKILL: {
+    type: 1,
+    name: '秒杀',
+  },
+  BARGAIN: {
+    type: 2,
+    name: '砍价',
+  },
+  COMBINATION: {
+    type: 3,
+    name: '拼团',
+  },
+  POINT: {
+    type: 4,
+    name: '积分商城',
+  },
+};
+/** 配送方式枚举 */
+export const DeliveryTypeEnum = {
+  EXPRESS: { type: 1, name: '快递发货' },
+  PICK_UP: { type: 2, name: '用户自提' },
+};
+export const getTimeStatusEnum = (startTime, endTime) => {
+  const now = dayjs();
+  if (now.isBefore(startTime)) {
+    return TimeStatusEnum.WAIT_START;
+  } else if (now.isAfter(endTime)) {
+    return TimeStatusEnum.END;
+  } else {
+    return TimeStatusEnum.STARTED;
+  }
+};
+/**
+ * 分享页枚举
+ * 按需扩展
+ * */
+export const SharePageEnum = {
+  HOME: {
+    name: '首页',
+    page: '/pages/index/index',
+    value: '1',
+  },
+  GOODS: {
+    name: '普通商品页',
+    page: '/pages/goods/index',
+    value: '2',
+  },
+  GROUPON: {
+    name: '拼团商品页',
+    page: '/pages/goods/groupon',
+    value: '3',
+  },
+  SECKILL: {
+    name: '秒杀商品页',
+    page: '/pages/goods/seckill',
+    value: '4',
+  },
+  GROUPON_DETAIL: {
+    name: '参与拼团页',
+    page: '/pages/activity/groupon/detail',
+    value: '5',
+  },
+  POINT: {
+    name: '积分商品页',
+    page: '/pages/goods/point',
+    value: '6',
+  },
+};

+ 168 - 0
common/helper/digit.js

@@ -0,0 +1,168 @@
+let _boundaryCheckingState = true; // 是否进行越界检查的全局开关
+
+/**
+ * 把错误的数据转正
+ * @private
+ * @example strip(0.09999999999999998)=0.1
+ */
+function strip(num, precision = 15) {
+  return +parseFloat(Number(num).toPrecision(precision));
+}
+
+/**
+ * Return digits length of a number
+ * @private
+ * @param {*number} num Input number
+ */
+function digitLength(num) {
+  // Get digit length of e
+  const eSplit = num.toString().split(/[eE]/);
+  const len = (eSplit[0].split('.')[1] || '').length - +(eSplit[1] || 0);
+  return len > 0 ? len : 0;
+}
+
+/**
+ * 把小数转成整数,如果是小数则放大成整数
+ * @private
+ * @param {*number} num 输入数
+ */
+function float2Fixed(num) {
+  if (num.toString().indexOf('e') === -1) {
+    return Number(num.toString().replace('.', ''));
+  }
+  const dLen = digitLength(num);
+  return dLen > 0 ? strip(Number(num) * Math.pow(10, dLen)) : Number(num);
+}
+
+/**
+ * 检测数字是否越界,如果越界给出提示
+ * @private
+ * @param {*number} num 输入数
+ */
+function checkBoundary(num) {
+  if (_boundaryCheckingState) {
+    if (num > Number.MAX_SAFE_INTEGER || num < Number.MIN_SAFE_INTEGER) {
+      console.warn(`${num} 超出了精度限制,结果可能不正确`);
+    }
+  }
+}
+
+/**
+ * 把递归操作扁平迭代化
+ * @param {number[]} arr 要操作的数字数组
+ * @param {function} operation 迭代操作
+ * @private
+ */
+function iteratorOperation(arr, operation) {
+  const [num1, num2, ...others] = arr;
+  let res = operation(num1, num2);
+
+  others.forEach((num) => {
+    res = operation(res, num);
+  });
+
+  return res;
+}
+
+/**
+ * 高精度乘法
+ * @export
+ */
+export function times(...nums) {
+  if (nums.length > 2) {
+    return iteratorOperation(nums, times);
+  }
+
+  const [num1, num2] = nums;
+  const num1Changed = float2Fixed(num1);
+  const num2Changed = float2Fixed(num2);
+  const baseNum = digitLength(num1) + digitLength(num2);
+  const leftValue = num1Changed * num2Changed;
+
+  checkBoundary(leftValue);
+
+  return leftValue / Math.pow(10, baseNum);
+}
+
+/**
+ * 高精度加法
+ * @export
+ */
+export function plus(...nums) {
+  if (nums.length > 2) {
+    return iteratorOperation(nums, plus);
+  }
+
+  const [num1, num2] = nums;
+  // 取最大的小数位
+  const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
+  // 把小数都转为整数然后再计算
+  return (times(num1, baseNum) + times(num2, baseNum)) / baseNum;
+}
+
+/**
+ * 高精度减法
+ * @export
+ */
+export function minus(...nums) {
+  if (nums.length > 2) {
+    return iteratorOperation(nums, minus);
+  }
+
+  const [num1, num2] = nums;
+  const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
+  return (times(num1, baseNum) - times(num2, baseNum)) / baseNum;
+}
+
+/**
+ * 高精度除法
+ * @export
+ */
+export function divide(...nums) {
+  if (nums.length > 2) {
+    return iteratorOperation(nums, divide);
+  }
+
+  const [num1, num2] = nums;
+  const num1Changed = float2Fixed(num1);
+  const num2Changed = float2Fixed(num2);
+  checkBoundary(num1Changed);
+  checkBoundary(num2Changed);
+  // 重要,这里必须用strip进行修正
+  return times(
+    num1Changed / num2Changed,
+    strip(Math.pow(10, digitLength(num2) - digitLength(num1))),
+  );
+}
+
+/**
+ * 四舍五入
+ * @export
+ */
+export function round(num, ratio) {
+  const base = Math.pow(10, ratio);
+  let result = divide(Math.round(Math.abs(times(num, base))), base);
+  if (num < 0 && result !== 0) {
+    result = times(result, -1);
+  }
+  // 位数不足则补0
+  return result;
+}
+
+/**
+ * 是否进行边界检查,默认开启
+ * @param flag 标记开关,true 为开启,false 为关闭,默认为 true
+ * @export
+ */
+export function enableBoundaryChecking(flag = true) {
+  _boundaryCheckingState = flag;
+}
+
+export default {
+  times,
+  plus,
+  minus,
+  divide,
+  round,
+  enableBoundaryChecking,
+};

+ 700 - 0
common/helper/index.js

@@ -0,0 +1,700 @@
+import test from './test.js';
+import { round } from './digit.js';
+/**
+ * @description 如果value小于min,取min;如果value大于max,取max
+ * @param {number} min
+ * @param {number} max
+ * @param {number} value
+ */
+function range(min = 0, max = 0, value = 0) {
+  return Math.max(min, Math.min(max, Number(value)));
+}
+
+/**
+ * @description 用于获取用户传递值的px值  如果用户传递了"xxpx"或者"xxrpx",取出其数值部分,如果是"xxxrpx"还需要用过uni.upx2px进行转换
+ * @param {number|string} value 用户传递值的px值
+ * @param {boolean} unit
+ * @returns {number|string}
+ */
+export function getPx(value, unit = false) {
+  if (test.number(value)) {
+    return unit ? `${value}px` : Number(value);
+  }
+  // 如果带有rpx,先取出其数值部分,再转为px值
+  if (/(rpx|upx)$/.test(value)) {
+    return unit ? `${uni.upx2px(parseInt(value))}px` : Number(uni.upx2px(parseInt(value)));
+  }
+  return unit ? `${parseInt(value)}px` : parseInt(value);
+}
+
+/**
+ * @description 进行延时,以达到可以简写代码的目的
+ * @param {number} value 堵塞时间 单位ms 毫秒
+ * @returns {Promise} 返回promise
+ */
+export function sleep(value = 30) {
+  return new Promise((resolve) => {
+    setTimeout(() => {
+      resolve();
+    }, value);
+  });
+}
+/**
+ * @description 运行期判断平台
+ * @returns {string} 返回所在平台(小写)
+ * @link 运行期判断平台 https://uniapp.dcloud.io/frame?id=判断平台
+ */
+export function os() {
+  return uni.getDeviceInfo().platform.toLowerCase();
+}
+
+/**
+ * @description 取一个区间数
+ * @param {Number} min 最小值
+ * @param {Number} max 最大值
+ */
+function random(min, max) {
+  if (min >= 0 && max > 0 && max >= min) {
+    const gab = max - min + 1;
+    return Math.floor(Math.random() * gab + min);
+  }
+  return 0;
+}
+
+/**
+ * @param {Number} len uuid的长度
+ * @param {Boolean} firstU 将返回的首字母置为"u"
+ * @param {Nubmer} radix 生成uuid的基数(意味着返回的字符串都是这个基数),2-二进制,8-八进制,10-十进制,16-十六进制
+ */
+export function guid(len = 32, firstU = true, radix = null) {
+  const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
+  const uuid = [];
+  radix = radix || chars.length;
+
+  if (len) {
+    // 如果指定uuid长度,只是取随机的字符,0|x为位运算,能去掉x的小数位,返回整数位
+    for (let i = 0; i < len; i++) uuid[i] = chars[0 | (Math.random() * radix)];
+  } else {
+    let r;
+    // rfc4122标准要求返回的uuid中,某些位为固定的字符
+    uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-';
+    uuid[14] = '4';
+
+    for (let i = 0; i < 36; i++) {
+      if (!uuid[i]) {
+        r = 0 | (Math.random() * 16);
+        uuid[i] = chars[i == 19 ? (r & 0x3) | 0x8 : r];
+      }
+    }
+  }
+  // 移除第一个字符,并用u替代,因为第一个字符为数值时,该guuid不能用作id或者class
+  if (firstU) {
+    uuid.shift();
+    return `u${uuid.join('')}`;
+  }
+  return uuid.join('');
+}
+
+/**
+* @description 获取父组件的参数,因为支付宝小程序不支持provide/inject的写法
+   this.$parent在非H5中,可以准确获取到父组件,但是在H5中,需要多次this.$parent.$parent.xxx
+   这里默认值等于undefined有它的含义,因为最顶层元素(组件)的$parent就是undefined,意味着不传name
+   值(默认为undefined),就是查找最顶层的$parent
+*  @param {string|undefined} name 父组件的参数名
+*/
+export function $parent(name = undefined) {
+  let parent = this.$parent;
+  // 通过while历遍,这里主要是为了H5需要多层解析的问题
+  while (parent) {
+    // 父组件
+    if (parent.$options && parent.$options.name !== name) {
+      // 如果组件的name不相等,继续上一级寻找
+      parent = parent.$parent;
+    } else {
+      return parent;
+    }
+  }
+  return false;
+}
+
+/**
+ * @description 样式转换
+ * 对象转字符串,或者字符串转对象
+ * @param {object | string} customStyle 需要转换的目标
+ * @param {String} target 转换的目的,object-转为对象,string-转为字符串
+ * @returns {object|string}
+ */
+export function addStyle(customStyle, target = 'object') {
+  // 字符串转字符串,对象转对象情形,直接返回
+  if (
+    test.empty(customStyle) ||
+    (typeof customStyle === 'object' && target === 'object') ||
+    (target === 'string' && typeof customStyle === 'string')
+  ) {
+    return customStyle;
+  }
+  // 字符串转对象
+  if (target === 'object') {
+    // 去除字符串样式中的两端空格(中间的空格不能去掉,比如padding: 20px 0如果去掉了就错了),空格是无用的
+    customStyle = trim(customStyle);
+    // 根据";"将字符串转为数组形式
+    const styleArray = customStyle.split(';');
+    const style = {};
+    // 历遍数组,拼接成对象
+    for (let i = 0; i < styleArray.length; i++) {
+      // 'font-size:20px;color:red;',如此最后字符串有";"的话,会导致styleArray最后一个元素为空字符串,这里需要过滤
+      if (styleArray[i]) {
+        const item = styleArray[i].split(':');
+        style[trim(item[0])] = trim(item[1]);
+      }
+    }
+    return style;
+  }
+  // 这里为对象转字符串形式
+  let string = '';
+  for (const i in customStyle) {
+    // 驼峰转为中划线的形式,否则css内联样式,无法识别驼峰样式属性名
+    const key = i.replace(/([A-Z])/g, '-$1').toLowerCase();
+    string += `${key}:${customStyle[i]};`;
+  }
+  // 去除两端空格
+  return trim(string);
+}
+
+/**
+ * @description 添加单位,如果有rpx,upx,%,px等单位结尾或者值为auto,直接返回,否则加上px单位结尾
+ * @param {string|number} value 需要添加单位的值
+ * @param {string} unit 添加的单位名 比如px
+ */
+export function addUnit(value = 'auto', unit = 'px') {
+  value = String(value);
+  return test.number(value) ? `${value}${unit}` : value;
+}
+
+/**
+ * @description 深度克隆
+ * @param {object} obj 需要深度克隆的对象
+ * @returns {*} 克隆后的对象或者原值(不是对象)
+ */
+function deepClone(obj) {
+  // 对常见的“非”值,直接返回原来值
+  if ([null, undefined, NaN, false].includes(obj)) return obj;
+  if (typeof obj !== 'object' && typeof obj !== 'function') {
+    // 原始类型直接返回
+    return obj;
+  }
+  const o = test.array(obj) ? [] : {};
+  for (const i in obj) {
+    if (obj.hasOwnProperty(i)) {
+      o[i] = typeof obj[i] === 'object' ? deepClone(obj[i]) : obj[i];
+    }
+  }
+  return o;
+}
+
+/**
+ * @description JS对象深度合并
+ * @param {object} target 需要拷贝的对象
+ * @param {object} source 拷贝的来源对象
+ * @returns {object|boolean} 深度合并后的对象或者false(入参有不是对象)
+ */
+export function deepMerge(target = {}, source = {}) {
+  target = deepClone(target);
+  if (typeof target !== 'object' || typeof source !== 'object') return false;
+  for (const prop in source) {
+    if (!source.hasOwnProperty(prop)) continue;
+    if (prop in target) {
+      if (typeof target[prop] !== 'object') {
+        target[prop] = source[prop];
+      } else if (typeof source[prop] !== 'object') {
+        target[prop] = source[prop];
+      } else if (target[prop].concat && source[prop].concat) {
+        target[prop] = target[prop].concat(source[prop]);
+      } else {
+        target[prop] = deepMerge(target[prop], source[prop]);
+      }
+    } else {
+      target[prop] = source[prop];
+    }
+  }
+  return target;
+}
+
+/**
+ * @description error提示
+ * @param {*} err 错误内容
+ */
+function error(err) {
+  // 开发环境才提示,生产环境不会提示
+  if (process.env.NODE_ENV === 'development') {
+    console.error(`SheepJS:${err}`);
+  }
+}
+
+/**
+ * @description 打乱数组
+ * @param {array} array 需要打乱的数组
+ * @returns {array} 打乱后的数组
+ */
+function randomArray(array = []) {
+  // 原理是sort排序,Math.random()产生0<= x < 1之间的数,会导致x-0.05大于或者小于0
+  return array.sort(() => Math.random() - 0.5);
+}
+
+// padStart 的 polyfill,因为某些机型或情况,还无法支持es7的padStart,比如电脑版的微信小程序
+// 所以这里做一个兼容polyfill的兼容处理
+if (!String.prototype.padStart) {
+  // 为了方便表示这里 fillString 用了ES6 的默认参数,不影响理解
+  String.prototype.padStart = function (maxLength, fillString = ' ') {
+    if (Object.prototype.toString.call(fillString) !== '[object String]') {
+      throw new TypeError('fillString must be String');
+    }
+    const str = this;
+    // 返回 String(str) 这里是为了使返回的值是字符串字面量,在控制台中更符合直觉
+    if (str.length >= maxLength) return String(str);
+
+    const fillLength = maxLength - str.length;
+    let times = Math.ceil(fillLength / fillString.length);
+    while ((times >>= 1)) {
+      fillString += fillString;
+      if (times === 1) {
+        fillString += fillString;
+      }
+    }
+    return fillString.slice(0, fillLength) + str;
+  };
+}
+
+/**
+ * @description 格式化时间
+ * @param {String|Number} dateTime 需要格式化的时间戳
+ * @param {String} fmt 格式化规则 yyyy:mm:dd|yyyy:mm|yyyy年mm月dd日|yyyy年mm月dd日 hh时MM分等,可自定义组合 默认yyyy-mm-dd
+ * @returns {string} 返回格式化后的字符串
+ */
+function timeFormat(dateTime = null, formatStr = 'yyyy-mm-dd') {
+  let date;
+  // 若传入时间为假值,则取当前时间
+  if (!dateTime) {
+    date = new Date();
+  }
+  // 若为unix秒时间戳,则转为毫秒时间戳(逻辑有点奇怪,但不敢改,以保证历史兼容)
+  else if (/^\d{10}$/.test(dateTime?.toString().trim())) {
+    date = new Date(dateTime * 1000);
+  }
+  // 若用户传入字符串格式时间戳,new Date无法解析,需做兼容
+  else if (typeof dateTime === 'string' && /^\d+$/.test(dateTime.trim())) {
+    date = new Date(Number(dateTime));
+  }
+  // 其他都认为符合 RFC 2822 规范
+  else {
+    // 处理平台性差异,在Safari/Webkit中,new Date仅支持/作为分割符的字符串时间
+    date = new Date(typeof dateTime === 'string' ? dateTime.replace(/-/g, '/') : dateTime);
+  }
+
+  const timeSource = {
+    y: date.getFullYear().toString(), // 年
+    m: (date.getMonth() + 1).toString().padStart(2, '0'), // 月
+    d: date.getDate().toString().padStart(2, '0'), // 日
+    h: date.getHours().toString().padStart(2, '0'), // 时
+    M: date.getMinutes().toString().padStart(2, '0'), // 分
+    s: date.getSeconds().toString().padStart(2, '0'), // 秒
+    // 有其他格式化字符需求可以继续添加,必须转化成字符串
+  };
+
+  for (const key in timeSource) {
+    const [ret] = new RegExp(`${key}+`).exec(formatStr) || [];
+    if (ret) {
+      // 年可能只需展示两位
+      const beginIndex = key === 'y' && ret.length === 2 ? 2 : 0;
+      formatStr = formatStr.replace(ret, timeSource[key].slice(beginIndex));
+    }
+  }
+
+  return formatStr;
+}
+
+/**
+ * @description 时间戳转为多久之前
+ * @param {String|Number} timestamp 时间戳
+ * @param {String|Boolean} format
+ * 格式化规则如果为时间格式字符串,超出一定时间范围,返回固定的时间格式;
+ * 如果为布尔值false,无论什么时间,都返回多久以前的格式
+ * @returns {string} 转化后的内容
+ */
+function timeFrom(timestamp = null, format = 'yyyy-mm-dd') {
+  if (timestamp == null) timestamp = Number(new Date());
+  timestamp = parseInt(timestamp);
+  // 判断用户输入的时间戳是秒还是毫秒,一般前端js获取的时间戳是毫秒(13位),后端传过来的为秒(10位)
+  if (timestamp.toString().length == 10) timestamp *= 1000;
+  let timer = new Date().getTime() - timestamp;
+  timer = parseInt(timer / 1000);
+  // 如果小于5分钟,则返回"刚刚",其他以此类推
+  let tips = '';
+  switch (true) {
+    case timer < 300:
+      tips = '刚刚';
+      break;
+    case timer >= 300 && timer < 3600:
+      tips = `${parseInt(timer / 60)}分钟前`;
+      break;
+    case timer >= 3600 && timer < 86400:
+      tips = `${parseInt(timer / 3600)}小时前`;
+      break;
+    case timer >= 86400 && timer < 2592000:
+      tips = `${parseInt(timer / 86400)}天前`;
+      break;
+    default:
+      // 如果format为false,则无论什么时间戳,都显示xx之前
+      if (format === false) {
+        if (timer >= 2592000 && timer < 365 * 86400) {
+          tips = `${parseInt(timer / (86400 * 30))}个月前`;
+        } else {
+          tips = `${parseInt(timer / (86400 * 365))}年前`;
+        }
+      } else {
+        tips = timeFormat(timestamp, format);
+      }
+  }
+  return tips;
+}
+
+/**
+ * @description 去除空格
+ * @param String str 需要去除空格的字符串
+ * @param String pos both(左右)|left|right|all 默认both
+ */
+function trim(str, pos = 'both') {
+  str = String(str);
+  if (pos == 'both') {
+    return str.replace(/^\s+|\s+$/g, '');
+  }
+  if (pos == 'left') {
+    return str.replace(/^\s*/, '');
+  }
+  if (pos == 'right') {
+    return str.replace(/(\s*$)/g, '');
+  }
+  if (pos == 'all') {
+    return str.replace(/\s+/g, '');
+  }
+  return str;
+}
+
+/**
+ * @description 对象转url参数
+ * @param {object} data,对象
+ * @param {Boolean} isPrefix,是否自动加上"?"
+ * @param {string} arrayFormat 规则 indices|brackets|repeat|comma
+ */
+function queryParams(data = {}, isPrefix = true, arrayFormat = 'brackets') {
+  const prefix = isPrefix ? '?' : '';
+  const _result = [];
+  if (['indices', 'brackets', 'repeat', 'comma'].indexOf(arrayFormat) == -1)
+    arrayFormat = 'brackets';
+  for (const key in data) {
+    const value = data[key];
+    // 去掉为空的参数
+    if (['', undefined, null].indexOf(value) >= 0) {
+      continue;
+    }
+    // 如果值为数组,另行处理
+    if (value.constructor === Array) {
+      // e.g. {ids: [1, 2, 3]}
+      switch (arrayFormat) {
+        case 'indices':
+          // 结果: ids[0]=1&ids[1]=2&ids[2]=3
+          for (let i = 0; i < value.length; i++) {
+            _result.push(`${key}[${i}]=${value[i]}`);
+          }
+          break;
+        case 'brackets':
+          // 结果: ids[]=1&ids[]=2&ids[]=3
+          value.forEach((_value) => {
+            _result.push(`${key}[]=${_value}`);
+          });
+          break;
+        case 'repeat':
+          // 结果: ids=1&ids=2&ids=3
+          value.forEach((_value) => {
+            _result.push(`${key}=${_value}`);
+          });
+          break;
+        case 'comma':
+          // 结果: ids=1,2,3
+          let commaStr = '';
+          value.forEach((_value) => {
+            commaStr += (commaStr ? ',' : '') + _value;
+          });
+          _result.push(`${key}=${commaStr}`);
+          break;
+        default:
+          value.forEach((_value) => {
+            _result.push(`${key}[]=${_value}`);
+          });
+      }
+    } else {
+      _result.push(`${key}=${value}`);
+    }
+  }
+  return _result.length ? prefix + _result.join('&') : '';
+}
+
+/**
+ * 显示消息提示框
+ * @param {String} title 提示的内容,长度与 icon 取值有关。
+ * @param {Number} duration 提示的延迟时间,单位毫秒,默认:2000
+ */
+function toast(title, duration = 2000) {
+  uni.showToast({
+    title: String(title),
+    icon: 'none',
+    duration,
+  });
+}
+
+/**
+ * @description 根据主题type值,获取对应的图标
+ * @param {String} type 主题名称,primary|info|error|warning|success
+ * @param {boolean} fill 是否使用fill填充实体的图标
+ */
+function type2icon(type = 'success', fill = false) {
+  // 如果非预置值,默认为success
+  if (['primary', 'info', 'error', 'warning', 'success'].indexOf(type) == -1) type = 'success';
+  let iconName = '';
+  // 目前(2019-12-12),info和primary使用同一个图标
+  switch (type) {
+    case 'primary':
+      iconName = 'info-circle';
+      break;
+    case 'info':
+      iconName = 'info-circle';
+      break;
+    case 'error':
+      iconName = 'close-circle';
+      break;
+    case 'warning':
+      iconName = 'error-circle';
+      break;
+    case 'success':
+      iconName = 'checkmark-circle';
+      break;
+    default:
+      iconName = 'checkmark-circle';
+  }
+  // 是否是实体类型,加上-fill,在icon组件库中,实体的类名是后面加-fill的
+  if (fill) iconName += '-fill';
+  return iconName;
+}
+
+/**
+ * @description 数字格式化
+ * @param {number|string} number 要格式化的数字
+ * @param {number} decimals 保留几位小数
+ * @param {string} decimalPoint 小数点符号
+ * @param {string} thousandsSeparator 千分位符号
+ * @returns {string} 格式化后的数字
+ */
+function priceFormat(number, decimals = 0, decimalPoint = '.', thousandsSeparator = ',') {
+  number = `${number}`.replace(/[^0-9+-Ee.]/g, '');
+  const n = !isFinite(+number) ? 0 : +number;
+  const prec = !isFinite(+decimals) ? 0 : Math.abs(decimals);
+  const sep = typeof thousandsSeparator === 'undefined' ? ',' : thousandsSeparator;
+  const dec = typeof decimalPoint === 'undefined' ? '.' : decimalPoint;
+  let s = '';
+
+  s = (prec ? round(n, prec) + '' : `${Math.round(n)}`).split('.');
+  const re = /(-?\d+)(\d{3})/;
+  while (re.test(s[0])) {
+    s[0] = s[0].replace(re, `$1${sep}$2`);
+  }
+
+  if ((s[1] || '').length < prec) {
+    s[1] = s[1] || '';
+    s[1] += new Array(prec - s[1].length + 1).join('0');
+  }
+  return s.join(dec);
+}
+
+/**
+ * @description 获取duration值
+ * 如果带有ms或者s直接返回,如果大于一定值,认为是ms单位,小于一定值,认为是s单位
+ * 比如以30位阈值,那么300大于30,可以理解为用户想要的是300ms,而不是想花300s去执行一个动画
+ * @param {String|number} value 比如: "1s"|"100ms"|1|100
+ * @param {boolean} unit  提示: 如果是false 默认返回number
+ * @return {string|number}
+ */
+function getDuration(value, unit = true) {
+  const valueNum = parseInt(value);
+  if (unit) {
+    if (/s$/.test(value)) return value;
+    return value > 30 ? `${value}ms` : `${value}s`;
+  }
+  if (/ms$/.test(value)) return valueNum;
+  if (/s$/.test(value)) return valueNum > 30 ? valueNum : valueNum * 1000;
+  return valueNum;
+}
+
+/**
+ * @description 日期的月或日补零操作
+ * @param {String} value 需要补零的值
+ */
+function padZero(value) {
+  return `00${value}`.slice(-2);
+}
+
+/**
+ * @description 获取某个对象下的属性,用于通过类似'a.b.c'的形式去获取一个对象的的属性的形式
+ * @param {object} obj 对象
+ * @param {string} key 需要获取的属性字段
+ * @returns {*}
+ */
+function getProperty(obj, key) {
+  if (!obj) {
+    return;
+  }
+  if (typeof key !== 'string' || key === '') {
+    return '';
+  }
+  if (key.indexOf('.') !== -1) {
+    const keys = key.split('.');
+    let firstObj = obj[keys[0]] || {};
+
+    for (let i = 1; i < keys.length; i++) {
+      if (firstObj) {
+        firstObj = firstObj[keys[i]];
+      }
+    }
+    return firstObj;
+  }
+  return obj[key];
+}
+
+/**
+ * @description 设置对象的属性值,如果'a.b.c'的形式进行设置
+ * @param {object} obj 对象
+ * @param {string} key 需要设置的属性
+ * @param {string} value 设置的值
+ */
+function setProperty(obj, key, value) {
+  if (!obj) {
+    return;
+  }
+  // 递归赋值
+  const inFn = function (_obj, keys, v) {
+    // 最后一个属性key
+    if (keys.length === 1) {
+      _obj[keys[0]] = v;
+      return;
+    }
+    // 0~length-1个key
+    while (keys.length > 1) {
+      const k = keys[0];
+      if (!_obj[k] || typeof _obj[k] !== 'object') {
+        _obj[k] = {};
+      }
+      const key = keys.shift();
+      // 自调用判断是否存在属性,不存在则自动创建对象
+      inFn(_obj[k], keys, v);
+    }
+  };
+
+  if (typeof key !== 'string' || key === '') {
+  } else if (key.indexOf('.') !== -1) {
+    // 支持多层级赋值操作
+    const keys = key.split('.');
+    inFn(obj, keys, value);
+  } else {
+    obj[key] = value;
+  }
+}
+
+/**
+ * @description 获取当前页面路径
+ */
+function page() {
+  const pages = getCurrentPages();
+  // 某些特殊情况下(比如页面进行redirectTo时的一些时机),pages可能为空数组
+  return `/${pages[pages.length - 1]?.route || ''}`;
+}
+
+/**
+ * @description 获取当前路由栈实例数组
+ */
+function pages() {
+  const pages = getCurrentPages();
+  return pages;
+}
+
+/**
+ * 获取H5-真实根地址 兼容hash+history模式
+ */
+export function getRootUrl() {
+  let url = '';
+  // #ifdef H5
+  url = location.origin + location.pathname;
+
+  if (location.hash !== '') {
+    url += '#/';
+  }
+  // #endif
+  return url;
+}
+
+/**
+ * copyText 多端复制文本
+ */
+export function copyText(text) {
+  // #ifndef H5
+  uni.setClipboardData({
+    data: text,
+    success: function () {
+      toast('复制成功!');
+    },
+    fail: function () {
+      toast('复制失败!');
+    },
+  });
+  // #endif
+  // #ifdef H5
+  var createInput = document.createElement('textarea');
+  createInput.value = text;
+  document.body.appendChild(createInput);
+  createInput.select();
+  document.execCommand('Copy');
+  createInput.className = 'createInput';
+  createInput.style.display = 'none';
+  toast('复制成功');
+  // #endif
+}
+
+export default {
+  range,
+  getPx,
+  sleep,
+  os,
+  random,
+  guid,
+  $parent,
+  addStyle,
+  addUnit,
+  deepClone,
+  deepMerge,
+  error,
+  randomArray,
+  timeFormat,
+  timeFrom,
+  trim,
+  queryParams,
+  toast,
+  type2icon,
+  priceFormat,
+  getDuration,
+  padZero,
+  getProperty,
+  setProperty,
+  page,
+  pages,
+  test,
+  getRootUrl,
+  copyText,
+};

+ 285 - 0
common/helper/test.js

@@ -0,0 +1,285 @@
+/**
+ * 验证电子邮箱格式
+ */
+function email(value) {
+  return /^\w+((-\w+)|(\.\w+))*\@[A-Za-z0-9]+((\.|-)[A-Za-z0-9]+)*\.[A-Za-z0-9]+$/.test(value);
+}
+
+/**
+ * 验证手机格式
+ */
+function mobile(value) {
+  return /^1[23456789]\d{9}$/.test(value);
+}
+
+/**
+ * 验证URL格式
+ */
+function url(value) {
+  return /^((https|http|ftp|rtsp|mms):\/\/)(([0-9a-zA-Z_!~*'().&=+$%-]+: )?[0-9a-zA-Z_!~*'().&=+$%-]+@)?(([0-9]{1,3}.){3}[0-9]{1,3}|([0-9a-zA-Z_!~*'()-]+.)*([0-9a-zA-Z][0-9a-zA-Z-]{0,61})?[0-9a-zA-Z].[a-zA-Z]{2,6})(:[0-9]{1,4})?((\/?)|(\/[0-9a-zA-Z_!~*'().;?:@&=+$,%#-]+)+\/?)$/.test(
+    value,
+  );
+}
+
+/**
+ * 验证日期格式
+ */
+function date(value) {
+  if (!value) return false;
+  // 判断是否数值或者字符串数值(意味着为时间戳),转为数值,否则new Date无法识别字符串时间戳
+  if (number(value)) value = +value;
+  return !/Invalid|NaN/.test(new Date(value).toString());
+}
+
+/**
+ * 验证ISO类型的日期格式
+ */
+function dateISO(value) {
+  return /^\d{4}[\/\-](0?[1-9]|1[012])[\/\-](0?[1-9]|[12][0-9]|3[01])$/.test(value);
+}
+
+/**
+ * 验证十进制数字
+ */
+function number(value) {
+  return /^[\+-]?(\d+\.?\d*|\.\d+|\d\.\d+e\+\d+)$/.test(value);
+}
+
+/**
+ * 验证字符串
+ */
+function string(value) {
+  return typeof value === 'string';
+}
+
+/**
+ * 验证整数
+ */
+function digits(value) {
+  return /^\d+$/.test(value);
+}
+
+/**
+ * 验证身份证号码
+ */
+function idCard(value) {
+  return /^[1-9]\d{5}[1-9]\d{3}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}([0-9]|X)$/.test(value);
+}
+
+/**
+ * 是否车牌号
+ */
+function carNo(value) {
+  // 新能源车牌
+  const xreg =
+    /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}(([0-9]{5}[DF]$)|([DF][A-HJ-NP-Z0-9][0-9]{4}$))/;
+  // 旧车牌
+  const creg =
+    /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳]{1}$/;
+  if (value.length === 7) {
+    return creg.test(value);
+  }
+  if (value.length === 8) {
+    return xreg.test(value);
+  }
+  return false;
+}
+
+/**
+ * 金额,只允许2位小数
+ */
+function amount(value) {
+  // 金额,只允许保留两位小数
+  return /^[1-9]\d*(,\d{3})*(\.\d{1,2})?$|^0\.\d{1,2}$/.test(value);
+}
+
+/**
+ * 中文
+ */
+function chinese(value) {
+  const reg = /^[\u4e00-\u9fa5]+$/gi;
+  return reg.test(value);
+}
+
+/**
+ * 只能输入字母
+ */
+function letter(value) {
+  return /^[a-zA-Z]*$/.test(value);
+}
+
+/**
+ * 只能是字母或者数字
+ */
+function enOrNum(value) {
+  // 英文或者数字
+  const reg = /^[0-9a-zA-Z]*$/g;
+  return reg.test(value);
+}
+
+/**
+ * 验证是否包含某个值
+ */
+function contains(value, param) {
+  return value.indexOf(param) >= 0;
+}
+
+/**
+ * 验证一个值范围[min, max]
+ */
+function range(value, param) {
+  return value >= param[0] && value <= param[1];
+}
+
+/**
+ * 验证一个长度范围[min, max]
+ */
+function rangeLength(value, param) {
+  return value.length >= param[0] && value.length <= param[1];
+}
+
+/**
+ * 是否固定电话
+ */
+function landline(value) {
+  const reg = /^\d{3,4}-\d{7,8}(-\d{3,4})?$/;
+  return reg.test(value);
+}
+
+/**
+ * 判断是否为空
+ */
+function empty(value) {
+  switch (typeof value) {
+    case 'undefined':
+      return true;
+    case 'string':
+      if (value.replace(/(^[ \t\n\r]*)|([ \t\n\r]*$)/g, '').length == 0) return true;
+      break;
+    case 'boolean':
+      if (!value) return true;
+      break;
+    case 'number':
+      if (value === 0 || isNaN(value)) return true;
+      break;
+    case 'object':
+      if (value === null || value.length === 0) return true;
+      for (const i in value) {
+        return false;
+      }
+      return true;
+  }
+  return false;
+}
+
+/**
+ * 是否json字符串
+ */
+function jsonString(value) {
+  if (typeof value === 'string') {
+    try {
+      const obj = JSON.parse(value);
+      if (typeof obj === 'object' && obj) {
+        return true;
+      }
+      return false;
+    } catch (e) {
+      return false;
+    }
+  }
+  return false;
+}
+
+/**
+ * 是否数组
+ */
+function array(value) {
+  if (typeof Array.isArray === 'function') {
+    return Array.isArray(value);
+  }
+  return Object.prototype.toString.call(value) === '[object Array]';
+}
+
+/**
+ * 是否对象
+ */
+function object(value) {
+  return Object.prototype.toString.call(value) === '[object Object]';
+}
+
+/**
+ * 是否短信验证码
+ */
+function code(value, len = 6) {
+  return new RegExp(`^\\d{${len}}$`).test(value);
+}
+
+/**
+ * 是否函数方法
+ * @param {Object} value
+ */
+function func(value) {
+  return typeof value === 'function';
+}
+
+/**
+ * 是否promise对象
+ * @param {Object} value
+ */
+function promise(value) {
+  return object(value) && func(value.then) && func(value.catch);
+}
+
+/** 是否图片格式
+ * @param {Object} value
+ */
+function image(value) {
+  const newValue = value.split('?')[0];
+  const IMAGE_REGEXP = /\.(jpeg|jpg|gif|png|svg|webp|jfif|bmp|dpg)/i;
+  return IMAGE_REGEXP.test(newValue);
+}
+
+/**
+ * 是否视频格式
+ * @param {Object} value
+ */
+function video(value) {
+  const VIDEO_REGEXP = /\.(mp4|mpg|mpeg|dat|asf|avi|rm|rmvb|mov|wmv|flv|mkv|m3u8)/i;
+  return VIDEO_REGEXP.test(value);
+}
+
+/**
+ * 是否为正则对象
+ * @param {Object}
+ * @return {Boolean}
+ */
+function regExp(o) {
+  return o && Object.prototype.toString.call(o) === '[object RegExp]';
+}
+
+export default {
+  email,
+  mobile,
+  url,
+  date,
+  dateISO,
+  number,
+  digits,
+  idCard,
+  carNo,
+  amount,
+  chinese,
+  letter,
+  enOrNum,
+  contains,
+  range,
+  rangeLength,
+  empty,
+  isEmpty: empty,
+  isNumber: number,
+  jsonString,
+  landline,
+  object,
+  array,
+  code,
+};

+ 31 - 0
common/helper/throttle.js

@@ -0,0 +1,31 @@
+let timer;
+let flag;
+/**
+ * 节流原理:在一定时间内,只能触发一次
+ *
+ * @param {Function} func 要执行的回调函数
+ * @param {Number} wait 延时的时间
+ * @param {Boolean} immediate 是否立即执行
+ * @return null
+ */
+function throttle(func, wait = 500, immediate = true) {
+  if (immediate) {
+    if (!flag) {
+      flag = true;
+      // 如果是立即执行,则在wait毫秒内开始时执行
+      typeof func === 'function' && func();
+      timer = setTimeout(() => {
+        flag = false;
+      }, wait);
+    } else {
+    }
+  } else if (!flag) {
+    flag = true;
+    // 如果是非立即执行,则在wait毫秒内的结束处执行
+    timer = setTimeout(() => {
+      flag = false;
+      typeof func === 'function' && func();
+    }, wait);
+  }
+}
+export default throttle;

+ 67 - 0
common/helper/tools.js

@@ -0,0 +1,67 @@
+import router from '@/common/router';
+export default {
+  /**
+   * 打电话
+   * @param {String<Number>} phoneNumber - 数字字符串
+   */
+  callPhone(phoneNumber = '') {
+    let num = phoneNumber.toString();
+    uni.makePhoneCall({
+      phoneNumber: num,
+      fail(err) {
+        console.log('makePhoneCall出错', err);
+      },
+    });
+  },
+
+  /**
+   * 微信头像
+   * @param {String} url -图片地址
+   */
+  checkMPUrl(url) {
+    // #ifdef MP
+    if (
+      url.substring(0, 4) === 'http' &&
+      url.substring(0, 5) !== 'https' &&
+      url.substring(0, 12) !== 'http://store' &&
+      url.substring(0, 10) !== 'http://tmp' &&
+      url.substring(0, 10) !== 'http://usr'
+    ) {
+      url = 'https' + url.substring(4, url.length);
+    }
+    // #endif
+    return url;
+  },
+
+  /**
+   * getUuid 生成唯一id
+   */
+  getUuid(len = 32, firstU = true, radix = null) {
+    const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
+    const uuid = [];
+    radix = radix || chars.length;
+
+    if (len) {
+      // 如果指定uuid长度,只是取随机的字符,0|x为位运算,能去掉x的小数位,返回整数位
+      for (let i = 0; i < len; i++) uuid[i] = chars[0 | (Math.random() * radix)];
+    } else {
+      let r;
+      // rfc4122标准要求返回的uuid中,某些位为固定的字符
+      uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-';
+      uuid[14] = '4';
+
+      for (let i = 0; i < 36; i++) {
+        if (!uuid[i]) {
+          r = 0 | (Math.random() * 16);
+          uuid[i] = chars[i == 19 ? (r & 0x3) | 0x8 : r];
+        }
+      }
+    }
+    // 移除第一个字符,并用u替代,因为第一个字符为数值时,该guuid不能用作id或者class
+    if (firstU) {
+      uuid.shift();
+      return `u${uuid.join('')}`;
+    }
+    return uuid.join('');
+  },
+};

+ 336 - 0
common/helper/utils.js

@@ -0,0 +1,336 @@
+export function isArray(value) {
+  if (typeof Array.isArray === 'function') {
+    return Array.isArray(value);
+  } else {
+    return Object.prototype.toString.call(value) === '[object Array]';
+  }
+}
+
+export function isObject(value) {
+  return Object.prototype.toString.call(value) === '[object Object]';
+}
+
+export function isNumber(value) {
+  return !isNaN(Number(value));
+}
+
+export function isFunction(value) {
+  return typeof value == 'function';
+}
+
+export function isString(value) {
+  return typeof value == 'string';
+}
+
+export function isEmpty(value) {
+  if (value === '' || value === undefined || value === null) {
+    return true;
+  }
+
+  if (isArray(value)) {
+    return value.length === 0;
+  }
+
+  if (isObject(value)) {
+    return Object.keys(value).length === 0;
+  }
+
+  return false;
+}
+
+export function isBoolean(value) {
+  return typeof value === 'boolean';
+}
+
+export function last(data) {
+  if (isArray(data) || isString(data)) {
+    return data[data.length - 1];
+  }
+}
+
+export function cloneDeep(obj) {
+  const d = isArray(obj) ? [...obj] : {};
+
+  if (isObject(obj)) {
+    for (const key in obj) {
+      if (obj[key]) {
+        if (obj[key] && typeof obj[key] === 'object') {
+          d[key] = cloneDeep(obj[key]);
+        } else {
+          d[key] = obj[key];
+        }
+      }
+    }
+  }
+
+  return d;
+}
+
+export function clone(obj) {
+  return Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj));
+}
+
+export function deepMerge(a, b) {
+  let k;
+  for (k in b) {
+    a[k] = a[k] && a[k].toString() === '[object Object]' ? deepMerge(a[k], b[k]) : (a[k] = b[k]);
+  }
+  return a;
+}
+
+export function contains(parent, node) {
+  while (node && (node = node.parentNode)) if (node === parent) return true;
+  return false;
+}
+
+export function orderBy(list, key) {
+  return list.sort((a, b) => a[key] - b[key]);
+}
+
+export function deepTree(list) {
+  const newList = [];
+  const map = {};
+
+  list.forEach((e) => (map[e.id] = e));
+
+  list.forEach((e) => {
+    const parent = map[e.parentId];
+
+    if (parent) {
+      (parent.children || (parent.children = [])).push(e);
+    } else {
+      newList.push(e);
+    }
+  });
+
+  const fn = (list) => {
+    list.map((e) => {
+      if (e.children instanceof Array) {
+        e.children = orderBy(e.children, 'orderNum');
+
+        fn(e.children);
+      }
+    });
+  };
+
+  fn(newList);
+
+  return orderBy(newList, 'orderNum');
+}
+
+export function revDeepTree(list = []) {
+  const d = [];
+  let id = 0;
+
+  const deep = (list, parentId) => {
+    list.forEach((e) => {
+      if (!e.id) {
+        e.id = id++;
+      }
+
+      e.parentId = parentId;
+
+      d.push(e);
+
+      if (e.children && isArray(e.children)) {
+        deep(e.children, e.id);
+      }
+    });
+  };
+
+  deep(list || [], null);
+
+  return d;
+}
+
+export function basename(path) {
+  let index = path.lastIndexOf('/');
+  index = index > -1 ? index : path.lastIndexOf('\\');
+  if (index < 0) {
+    return path;
+  }
+  return path.substring(index + 1);
+}
+
+export function isWxBrowser() {
+  const ua = navigator.userAgent.toLowerCase();
+  if (ua.match(/MicroMessenger/i) == 'micromessenger') {
+    return true;
+  } else {
+    return false;
+  }
+}
+
+/**
+ * @description 如果value小于min,取min;如果value大于max,取max
+ * @param {number} min
+ * @param {number} max
+ * @param {number} value
+ */
+export function range(min = 0, max = 0, value = 0) {
+  return Math.max(min, Math.min(max, Number(value)));
+}
+
+import dayjs from 'dayjs';
+
+/**
+ * 将一个整数转换为分数保留两位小数
+ * @param {number | string | undefined} num 整数
+ * @return {number} 分数
+ */
+export const formatToFraction = (num) => {
+  if (typeof num === 'undefined') return 0;
+  const parsedNumber = typeof num === 'string' ? parseFloat(num) : num;
+  return parseFloat((parsedNumber / 100).toFixed(2));
+};
+
+/**
+ * 将一个数转换为 1.00 这样
+ * 数据呈现的时候使用
+ *
+ * @param {number | string | undefined} num 整数
+ * @return {string} 分数
+ */
+export const floatToFixed2 = (num) => {
+  let str = '0.00';
+  if (typeof num === 'undefined') {
+    return str;
+  }
+  const f = formatToFraction(num);
+  const decimalPart = f.toString().split('.')[1];
+  const len = decimalPart ? decimalPart.length : 0;
+  switch (len) {
+    case 0:
+      str = f.toString() + '.00';
+      break;
+    case 1:
+      str = f.toString() + '.0';
+      break;
+    case 2:
+      str = f.toString();
+      break;
+  }
+  return str;
+};
+
+/**
+ * 时间日期转换
+ * @param {dayjs.ConfigType} date 当前时间,new Date() 格式
+ * @param {string} format 需要转换的时间格式字符串
+ * @description format 字符串随意,如 `YYYY-mm、YYYY-mm-dd`
+ * @description format 季度:"YYYY-mm-dd HH:MM:SS QQQQ"
+ * @description format 星期:"YYYY-mm-dd HH:MM:SS WWW"
+ * @description format 几周:"YYYY-mm-dd HH:MM:SS ZZZ"
+ * @description format 季度 + 星期 + 几周:"YYYY-mm-dd HH:MM:SS WWW QQQQ ZZZ"
+ * @returns {string} 返回拼接后的时间字符串
+ */
+export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
+  // 日期不存在,则返回空
+  if (!date) {
+    return '';
+  }
+  // 日期存在,则进行格式化
+  if (format === undefined) {
+    format = 'YYYY-MM-DD HH:mm:ss';
+  }
+  return dayjs(date).format(format);
+}
+
+/**
+ * 构造树型结构数据
+ *
+ * @param {*} data 数据源
+ * @param {*} id id字段 默认 'id'
+ * @param {*} parentId 父节点字段 默认 'parentId'
+ * @param {*} children 孩子节点字段 默认 'children'
+ * @param {*} rootId 根Id 默认 0
+ */
+export function handleTree(
+  data,
+  id = 'id',
+  parentId = 'parentId',
+  children = 'children',
+  rootId = 0,
+) {
+  // 对源数据深度克隆
+  const cloneData = JSON.parse(JSON.stringify(data));
+  // 循环所有项
+  const treeData = cloneData.filter((father) => {
+    let branchArr = cloneData.filter((child) => {
+      //返回每一项的子级数组
+      return father[id] === child[parentId];
+    });
+    branchArr.length > 0 ? (father.children = branchArr) : '';
+    //返回第一层
+    return father[parentId] === rootId;
+  });
+  return treeData !== '' ? treeData : data;
+}
+
+/**
+ * 重置分页对象
+ *
+ * @param pagination 分页对象
+ */
+export function resetPagination(pagination) {
+  pagination.list = [];
+  pagination.total = 0;
+  pagination.page = 1;
+}
+
+/**
+ * 将值复制到目标对象,且以目标对象属性为准,例:target: {a:1} source:{a:2,b:3} 结果为:{a:2}
+ * @param target 目标对象
+ * @param source 源对象
+ */
+export const copyValueToTarget = (target, source) => {
+  const newObj = Object.assign({}, target, source);
+  // 删除多余属性
+  Object.keys(newObj).forEach((key) => {
+    // 如果不是target中的属性则删除
+    if (Object.keys(target).indexOf(key) === -1) {
+      delete newObj[key];
+    }
+  });
+  // 更新目标对象值
+  Object.assign(target, newObj);
+};
+
+/**
+ * 解析 JSON 字符串
+ *
+ * @param str
+ */
+export function jsonParse(str) {
+  try {
+    return JSON.parse(str);
+  } catch (e) {
+    console.warn(`str[${str}] 不是一个 JSON 字符串`);
+    return str;
+  }
+}
+
+/**
+ * 获得当前周的开始和结束时间
+ */
+export function getWeekTimes() {
+  const today = new Date();
+  const dayOfWeek = today.getDay();
+  return [
+    new Date(today.getFullYear(), today.getMonth(), today.getDate() - dayOfWeek, 0, 0, 0),
+    new Date(today.getFullYear(), today.getMonth(), today.getDate() + (6 - dayOfWeek), 23, 59, 59),
+  ];
+}
+
+/**
+ * 获得当前月的开始和结束时间
+ */
+export function getMonthTimes() {
+  const today = new Date();
+  const year = today.getFullYear();
+  const month = today.getMonth();
+  const startDate = new Date(year, month, 1, 0, 0, 0);
+  const nextMonth = new Date(year, month + 1, 1);
+  const endDate = new Date(nextMonth.getTime() - 1);
+  return [startDate, endDate];
+}

+ 499 - 0
common/hooks/useGoods.js

@@ -0,0 +1,499 @@
+import { ref } from 'vue';
+import dayjs from 'dayjs';
+import $url from '@/common/url';
+import { formatDate } from '@/common/helper/utils';
+
+/**
+ * 格式化销量
+ * @param {'exact' | string} type 格式类型:exact=精确值,其它=大致数量
+ * @param {number} num 销量
+ * @return {string} 格式化后的销量字符串
+ */
+export function formatSales(type, num) {
+  let prefix = type !== 'exact' && num < 10 ? '销量' : '已售';
+  return formatNum(prefix, type, num);
+}
+
+/**
+ * 格式化兑换量
+ * @param {'exact' | string} type 格式类型:exact=精确值,其它=大致数量
+ * @param {number} num 销量
+ * @return {string} 格式化后的销量字符串
+ */
+export function formatExchange(type, num) {
+  return formatNum('已兑换', type, num);
+}
+
+/**
+ * 格式化库存
+ * @param {'exact' | any} type 格式类型:exact=精确值,其它=大致数量
+ * @param {number} num 销量
+ * @return {string} 格式化后的销量字符串
+ */
+export function formatStock(type, num) {
+  return formatNum('库存', type, num);
+}
+
+/**
+ * 格式化数字
+ * @param {string} prefix 前缀
+ * @param {'exact' | string} type 格式类型:exact=精确值,其它=大致数量
+ * @param {number} num 销量
+ * @return {string} 格式化后的销量字符串
+ */
+export function formatNum(prefix, type, num) {
+  num = num || 0;
+  // 情况一:精确数值
+  if (type === 'exact') {
+    return prefix + num;
+  }
+  // 情况二:小于等于 10
+  if (num < 10) {
+    return `${prefix}≤10`;
+  }
+  // 情况三:大于 10,除第一位外,其它位都显示为0
+  // 例如:100  - 199  显示为 100+
+  //      9000 - 9999 显示为 9000+
+  const numStr = num.toString();
+  const first = numStr[0];
+  const other = '0'.repeat(numStr.length - 1);
+  return `${prefix}${first}${other}+`;
+}
+
+// 格式化价格
+export function formatPrice(e) {
+  return e.length === 1 ? e[0] : e.join('~');
+}
+
+// 视频格式后缀列表
+const VIDEO_SUFFIX_LIST = ['.avi', '.mp4'];
+
+/**
+ * 转换商品轮播的链接列表:根据链接的后缀,判断是视频链接还是图片链接
+ *
+ * @param {string[]} urlList 链接列表
+ * @return {{src: string, type: 'video' | 'image' }[]}  转换后的链接列表
+ */
+export function formatGoodsSwiper(urlList) {
+  return (
+    urlList
+      ?.filter((url) => url)
+      .map((url, key) => {
+        const isVideo = VIDEO_SUFFIX_LIST.some((suffix) => url.includes(suffix));
+        const type = isVideo ? 'video' : 'image';
+        const src = $url.cdn(url);
+        return {
+          type,
+          src,
+        };
+      }) || []
+  );
+}
+
+/**
+ * 格式化订单状态的颜色
+ *
+ * @param order 订单
+ * @return {string} 颜色的 class 名称
+ */
+export function formatOrderColor(order) {
+  if (order.status === 0) {
+    return 'info-color';
+  }
+  if (order.status === 10 || order.status === 20 || (order.status === 30 && !order.commentStatus)) {
+    return 'warning-color';
+  }
+  if (order.status === 30 && order.commentStatus) {
+    return 'success-color';
+  }
+  return 'danger-color';
+}
+
+/**
+ * 格式化订单状态
+ *
+ * @param order 订单
+ */
+export function formatOrderStatus(order) {
+  if (order.status === 0) {
+    return '待付款';
+  }
+  if (order.status === 10 && order.deliveryType === 1) {
+    return '待发货';
+  }
+  if (order.status === 10 && order.deliveryType === 2) {
+    return '待核销';
+  }
+  if (order.status === 20) {
+    return '待收货';
+  }
+  if (order.status === 30 && !order.commentStatus) {
+    return '待评价';
+  }
+  if (order.status === 30 && order.commentStatus) {
+    return '已完成';
+  }
+  return '已关闭';
+}
+
+/**
+ * 格式化订单状态的描述
+ *
+ * @param order 订单
+ */
+export function formatOrderStatusDescription(order) {
+  if (order.status === 0) {
+    return `请在 ${formatDate(order.payExpireTime)} 前完成支付`;
+  }
+  if (order.status === 10) {
+    return '商家未发货,请耐心等待';
+  }
+  if (order.status === 20) {
+    return '商家已发货,请耐心等待';
+  }
+  if (order.status === 30 && !order.commentStatus) {
+    return '已收货,快去评价一下吧';
+  }
+  if (order.status === 30 && order.commentStatus) {
+    return '交易完成,感谢您的支持';
+  }
+  return '交易关闭';
+}
+
+/**
+ * 处理订单的 button 操作按钮数组
+ *
+ * @param order 订单
+ */
+export function handleOrderButtons(order) {
+  order.buttons = [];
+  if (order.type === 3) {
+    // 查看拼团
+    order.buttons.push('combination');
+  }
+  if (order.status === 20) {
+    // 确认收货
+    order.buttons.push('confirm');
+  }
+  if (order.logisticsId > 0) {
+    // 查看物流
+    order.buttons.push('express');
+  }
+  if (order.status === 0) {
+    // 取消订单 / 发起支付
+    order.buttons.push('cancel');
+    order.buttons.push('pay');
+  }
+  if (order.status === 30 && !order.commentStatus) {
+    // 发起评价
+    order.buttons.push('comment');
+  }
+  if (order.status === 40) {
+    // 删除订单
+    order.buttons.push('delete');
+  }
+}
+
+/**
+ * 格式化售后状态
+ *
+ * @param afterSale 售后
+ */
+export function formatAfterSaleStatus(afterSale) {
+  if (afterSale.status === 10) {
+    return '申请售后';
+  }
+  if (afterSale.status === 20) {
+    return '商品待退货';
+  }
+  if (afterSale.status === 30) {
+    return '商家待收货';
+  }
+  if (afterSale.status === 40) {
+    return '等待退款';
+  }
+  if (afterSale.status === 50) {
+    return '退款成功';
+  }
+  if (afterSale.status === 61) {
+    return '买家取消';
+  }
+  if (afterSale.status === 62) {
+    return '商家拒绝';
+  }
+  if (afterSale.status === 63) {
+    return '商家拒收货';
+  }
+  return '未知状态';
+}
+
+/**
+ * 格式化售后状态的描述
+ *
+ * @param afterSale 售后
+ */
+export function formatAfterSaleStatusDescription(afterSale) {
+  if (afterSale.status === 10) {
+    return '退款申请待商家处理';
+  }
+  if (afterSale.status === 20) {
+    return '请退货并填写物流信息';
+  }
+  if (afterSale.status === 30) {
+    return '退货退款申请待商家处理';
+  }
+  if (afterSale.status === 40) {
+    return '等待退款';
+  }
+  if (afterSale.status === 50) {
+    return '退款成功';
+  }
+  if (afterSale.status === 61) {
+    return '退款关闭';
+  }
+  if (afterSale.status === 62) {
+    return `商家不同意退款申请,拒绝原因:${afterSale.auditReason}`;
+  }
+  if (afterSale.status === 63) {
+    return `商家拒绝收货,不同意退款,拒绝原因:${afterSale.auditReason}`;
+  }
+  return '未知状态';
+}
+
+/**
+ * 处理售后的 button 操作按钮数组
+ *
+ * @param afterSale 售后
+ */
+export function handleAfterSaleButtons(afterSale) {
+  afterSale.buttons = [];
+  if ([10, 20, 30].includes(afterSale.status)) {
+    // 取消订单
+    afterSale.buttons.push('cancel');
+  }
+  if (afterSale.status === 20) {
+    // 退货信息
+    afterSale.buttons.push('delivery');
+  }
+}
+
+/**
+ * 倒计时
+ * @param toTime   截止时间
+ * @param fromTime 起始时间,默认当前时间
+ * @return {{s: string, ms: number, h: string, m: string}} 持续时间
+ */
+export function useDurationTime(toTime, fromTime = '') {
+  toTime = getDayjsTime(toTime);
+  if (fromTime === '') {
+    fromTime = dayjs();
+  }
+  let duration = ref(toTime - fromTime);
+  if (duration.value > 0) {
+    setTimeout(() => {
+      if (duration.value > 0) {
+        duration.value -= 1000;
+      }
+    }, 1000);
+  }
+
+  let durationTime = dayjs.duration(duration.value);
+  return {
+    h: (durationTime.months() * 30 * 24 + durationTime.days() * 24 + durationTime.hours())
+      .toString()
+      .padStart(2, '0'),
+    m: durationTime.minutes().toString().padStart(2, '0'),
+    s: durationTime.seconds().toString().padStart(2, '0'),
+    ms: durationTime.$ms,
+  };
+}
+
+/**
+ * 转换为 Dayjs
+ * @param {any} time 时间
+ * @return {dayjs.Dayjs}
+ */
+function getDayjsTime(time) {
+  time = time.toString();
+  if (time.indexOf('-') > 0) {
+    // 'date'
+    return dayjs(time);
+  }
+  if (time.length > 10) {
+    // 'timestamp'
+    return dayjs(parseInt(time));
+  }
+  if (time.length === 10) {
+    // 'unixTime'
+    return dayjs.unix(parseInt(time));
+  }
+}
+
+/**
+ * 将分转成元
+ *
+ * @param price 分,例如说 100 分
+ * @returns {string} 元,例如说 1.00 元
+ */
+export function fen2yuan(price) {
+  return (price / 100.0).toFixed(2);
+}
+
+/**
+ * 将分转成元
+ *
+ * 如果没有小数点,则不展示小数点部分
+ *
+ * @param price 分,例如说 100 分
+ * @returns {string} 元,例如说 1 元
+ */
+export function fen2yuanSimple(price) {
+  return fen2yuan(price).replace(/\.?0+$/, '');
+}
+
+/**
+ * 将折扣百分比转化为“打x者”的 x 部分
+ *
+ * @param discountPercent
+ */
+export function formatDiscountPercent(discountPercent) {
+  return (discountPercent / 10.0).toFixed(1).replace(/\.?0+$/, '');
+}
+
+/**
+ * 从商品 SKU 数组中,转换出商品属性的数组
+ *
+ * 类似结构:[{
+ *    id: // 属性的编号
+ *    name: // 属性的名字
+ *    values: [{
+ *      id: // 属性值的编号
+ *      name: // 属性值的名字
+ *    }]
+ * }]
+ *
+ * @param skus 商品 SKU 数组
+ */
+export function convertProductPropertyList(skus) {
+  let result = [];
+  for (const sku of skus) {
+    if (!sku.properties) {
+      continue;
+    }
+    for (const property of sku.properties) {
+      // ① 先处理属性
+      let resultProperty = result.find((item) => item.id === property.propertyId);
+      if (!resultProperty) {
+        resultProperty = {
+          id: property.propertyId,
+          name: property.propertyName,
+          values: [],
+        };
+        result.push(resultProperty);
+      }
+      // ② 再处理属性值
+      let resultValue = resultProperty.values.find((item) => item.id === property.valueId);
+      if (!resultValue) {
+        resultProperty.values.push({
+          id: property.valueId,
+          name: property.valueName,
+        });
+      }
+    }
+  }
+  return result;
+}
+
+export function appendSettlementProduct(spus, settlementInfos) {
+  if (!settlementInfos || settlementInfos.length === 0) {
+    return;
+  }
+  for (const spu of spus) {
+    const settlementInfo = settlementInfos.find((info) => info.spuId === spu.id);
+    if (!settlementInfo) {
+      return;
+    }
+    // 选择价格最小的 SKU 设置到 SPU 上
+    const settlementSku = settlementInfo.skus
+      .filter((sku) => sku.promotionPrice > 0)
+      .reduce((prev, curr) => (prev.promotionPrice < curr.promotionPrice ? prev : curr), []);
+    if (settlementSku) {
+      spu.promotionType = settlementSku.promotionType;
+      spu.promotionPrice = settlementSku.promotionPrice;
+    }
+    // 设置【满减送】活动
+    if (settlementInfo.rewardActivity) {
+      spu.rewardActivity = settlementInfo.rewardActivity;
+    }
+  }
+}
+
+// 获得满减送活动的规则描述(group)
+export function getRewardActivityRuleGroupDescriptions(activity) {
+  if (!activity || !activity.rules || activity.rules.length === 0) {
+    return [];
+  }
+  const result = [
+    { name: '满减', values: [] },
+    { name: '赠品', values: [] },
+    { name: '包邮', values: [] },
+  ];
+  activity.rules.forEach((rule) => {
+    const conditionTypeStr =
+      activity.conditionType === 10 ? `满 ${fen2yuanSimple(rule.limit)} 元` : `满 ${rule.limit} 件`;
+    // 满减
+    if (rule.limit) {
+      result[0].values.push(`${conditionTypeStr} 减 ${fen2yuanSimple(rule.discountPrice)} 元`);
+    }
+    // 赠品
+    if (rule.point || (rule.giveCouponTemplateCounts && rule.giveCouponTemplateCounts.length > 0)) {
+      let tips = [];
+      if (rule.point) {
+        tips.push(`送 ${rule.point} 积分`);
+      }
+      if (rule.giveCouponTemplateCounts && rule.giveCouponTemplateCounts.length > 0) {
+        tips.push(`送 ${rule.giveCouponTemplateCounts.length} 张优惠券`);
+      }
+      result[1].values.push(`${conditionTypeStr} ${tips.join('、')}`);
+    }
+    // 包邮
+    if (rule.freeDelivery) {
+      result[2].values.push(`${conditionTypeStr} 包邮`);
+    }
+  });
+  // 移除 values 为空的元素
+  result.forEach((item) => {
+    if (item.values.length === 0) {
+      result.splice(result.indexOf(item), 1);
+    }
+  });
+  return result;
+}
+
+// 获得满减送活动的规则描述(item)
+export function getRewardActivityRuleItemDescriptions(activity) {
+  if (!activity || !activity.rules || activity.rules.length === 0) {
+    return [];
+  }
+  const result = [];
+  activity.rules.forEach((rule) => {
+    const conditionTypeStr =
+      activity.conditionType === 10 ? `满${fen2yuanSimple(rule.limit)}元` : `满${rule.limit}件`;
+    // 满减
+    if (rule.limit) {
+      result.push(`${conditionTypeStr}减${fen2yuanSimple(rule.discountPrice)}元`);
+    }
+    // 赠品
+    if (rule.point) {
+      result.push(`${conditionTypeStr}送${rule.point}积分`);
+    }
+    if (rule.giveCouponTemplateCounts && rule.giveCouponTemplateCounts.length > 0) {
+      result.push(`${conditionTypeStr}送${rule.giveCouponTemplateCounts.length}张优惠券`);
+    }
+    // 包邮
+    if (rule.freeDelivery) {
+      result.push(`${conditionTypeStr}包邮`);
+    }
+  });
+  return result;
+}

+ 132 - 0
common/hooks/useModal.js

@@ -0,0 +1,132 @@
+import $store from '@/common/store';
+import $helper from '@/common/helper';
+import dayjs from 'dayjs';
+import { ref } from 'vue';
+import test from '@/common/helper/test.js';
+import AuthUtil from '@/common/api/member/auth';
+import sheep from '@/common';
+// 打开登录
+export function showLoginPage(type = 'accountLogin') {
+  const modal = $store('modal')
+  modal.$patch((state) => {
+    state.loginType = type
+  })
+  uni.navigateTo({
+    url:'/pages/index/login',
+  })
+}
+export function closeLoginPage(){
+	sheep.$router.go('/pages/index/index')
+}
+// 打开授权弹框
+export function showAuthModal(type = 'accountLogin') {
+  const modal = $store('modal')
+  // #ifdef H5
+  closeAuthModal()
+  setTimeout(() => {
+    modal.$patch((state) => {
+      state.auth = type
+    })
+  }, 200)
+  // #endif
+
+  // #ifndef H5
+  modal.$patch((state) => {
+    state.auth = type;
+  });
+  // #endif
+}
+
+// 关闭授权弹框
+export function closeAuthModal() {
+  $store('modal').$patch((state) => {
+    state.auth = ''
+  })
+}
+
+
+// 打开快捷菜单
+export function showMenuTools() {
+  $store('modal').$patch((state) => {
+    state.menu = true
+  })
+}
+
+// 关闭快捷菜单
+export function closeMenuTools() {
+  $store('modal').$patch((state) => {
+    state.menu = false
+  })
+}
+
+// 发送短信验证码  60秒
+export function getSmsCode(event, mobile) {
+  const modalStore = $store('modal');
+  const lastSendTimer = modalStore.lastTimer[event];
+  if (typeof lastSendTimer === 'undefined') {
+    $helper.toast('短信发送事件错误');
+    return;
+  }
+
+  const duration = dayjs().unix() - lastSendTimer;
+  const canSend = duration >= 60;
+  if (!canSend) {
+    $helper.toast('请稍后再试');
+    return;
+  }
+  // 只有 mobile 非空时才校验。因为部分场景(修改密码),不需要输入手机
+  if (mobile && !test.mobile(mobile)) {
+    $helper.toast('手机号码格式不正确');
+    return;
+  }
+
+  // 发送验证码 + 更新上次发送验证码时间
+  let scene = -1;
+  switch (event) {
+    case 'resetPassword':
+      scene = 4;
+      break;
+    case 'changePassword':
+      scene = 3;
+      break;
+    case 'changeMobile':
+      scene = 2;
+      break;
+    case 'smsLogin':
+      scene = 1;
+      break;
+  }
+  AuthUtil.sendSmsCode(mobile, scene).then((res) => {
+    if (res.code === 0) {
+      modalStore.$patch((state) => {
+        state.lastTimer[event] = dayjs().unix();
+      });
+    }
+  });
+}
+
+// 获取短信验证码倒计时 -- 60秒
+export function getSmsTimer(event, mobile = '') {
+  const modalStore = $store('modal');
+  const lastSendTimer = modalStore.lastTimer[event];
+
+  if (typeof lastSendTimer === 'undefined') {
+    $helper.toast('短信发送事件错误');
+    return;
+  }
+
+  const duration = ref(dayjs().unix() - lastSendTimer - 60);
+  const canSend = duration.value >= 0;
+
+  if (canSend) {
+    return '获取验证码';
+  }
+
+  if (!canSend) {
+    setTimeout(() => {
+      duration.value++;
+    }, 1000);
+    return -duration.value.toString() + ' 秒';
+  }
+}
+

+ 40 - 0
common/index.js

@@ -0,0 +1,40 @@
+import $api from '@/common/api';
+import $url from '@/common/url';
+import $router from '@/common/router';
+import $platform from '@/common/platform';
+import $helper from '@/common/helper';
+import zIndex from '@/common/config/zIndex.js';
+import $store from '@/common/store';
+import dayjs from 'dayjs';
+import relativeTime from 'dayjs/plugin/relativeTime';
+import duration from 'dayjs/plugin/duration';
+import 'dayjs/locale/zh-cn';
+
+dayjs.locale('zh-cn');
+dayjs.extend(relativeTime);
+dayjs.extend(duration);
+
+const sheep = {
+  $api,
+  $store,
+  $url,
+  $router,
+  $platform,
+  $helper,
+  $zIndex: zIndex,
+};
+
+
+// 开发模式
+function SoundChainDebug() {
+  // 开发环境引入vconsole调试
+  // #ifdef H5
+  // import("vconsole").then(vconsole => {
+  // 	new vconsole.default();
+  // });
+  // #endif
+  // 同步前端页面到后端
+  // console.log(ROUTES)
+}
+
+export default sheep;

+ 32 - 0
common/libs/mplive-manifest-plugin.js

@@ -0,0 +1,32 @@
+const fs = require('fs');
+
+const manifestPath = process.env.UNI_INPUT_DIR + '/manifest.json';
+
+let Manifest = fs.readFileSync(manifestPath, {
+	encoding: 'utf-8'
+});
+
+function mpliveMainfestPlugin(isOpen) {
+	if (process.env.UNI_PLATFORM !== 'mp-weixin') return;
+
+	const manifestData = JSON.parse(Manifest)
+
+	if (isOpen === '0') {
+		delete manifestData['mp-weixin'].plugins['live-player-plugin'];
+	}
+
+	if (isOpen === '1') {
+		manifestData['mp-weixin'].plugins['live-player-plugin'] = {
+			"version": "1.3.5",
+			"provider": "wx2b03c6e691cd7370"
+		}
+	}
+
+	Manifest = JSON.stringify(manifestData, null, 2)
+
+	fs.writeFileSync(manifestPath, Manifest, {
+		"flag": "w"
+	})
+}
+
+export default mpliveMainfestPlugin

+ 244 - 0
common/libs/permission.js

@@ -0,0 +1,244 @@
+/// null = 未请求,1 = 已允许,0 = 拒绝|受限, 2 = 系统未开启
+
+var isIOS;
+
+function album() {
+  var result = 0;
+  var PHPhotoLibrary = plus.ios.import('PHPhotoLibrary');
+  var authStatus = PHPhotoLibrary.authorizationStatus();
+  if (authStatus === 0) {
+    result = null;
+  } else if (authStatus == 3) {
+    result = 1;
+  } else {
+    result = 0;
+  }
+  plus.ios.deleteObject(PHPhotoLibrary);
+  return result;
+}
+
+function camera() {
+  var result = 0;
+  var AVCaptureDevice = plus.ios.import('AVCaptureDevice');
+  var authStatus = AVCaptureDevice.authorizationStatusForMediaType('vide');
+  if (authStatus === 0) {
+    result = null;
+  } else if (authStatus == 3) {
+    result = 1;
+  } else {
+    result = 0;
+  }
+  plus.ios.deleteObject(AVCaptureDevice);
+  return result;
+}
+
+function location() {
+  var result = 0;
+  var cllocationManger = plus.ios.import('CLLocationManager');
+  var enable = cllocationManger.locationServicesEnabled();
+  var status = cllocationManger.authorizationStatus();
+  if (!enable) {
+    result = 2;
+  } else if (status === 0) {
+    result = null;
+  } else if (status === 3 || status === 4) {
+    result = 1;
+  } else {
+    result = 0;
+  }
+  plus.ios.deleteObject(cllocationManger);
+  return result;
+}
+
+function push() {
+  var result = 0;
+  var UIApplication = plus.ios.import('UIApplication');
+  var app = UIApplication.sharedApplication();
+  var enabledTypes = 0;
+  if (app.currentUserNotificationSettings) {
+    var settings = app.currentUserNotificationSettings();
+    enabledTypes = settings.plusGetAttribute('types');
+    if (enabledTypes == 0) {
+      result = 0;
+      console.log('推送权限没有开启');
+    } else {
+      result = 1;
+      console.log('已经开启推送功能!');
+    }
+    plus.ios.deleteObject(settings);
+  } else {
+    enabledTypes = app.enabledRemoteNotificationTypes();
+    if (enabledTypes == 0) {
+      result = 3;
+      console.log('推送权限没有开启!');
+    } else {
+      result = 4;
+      console.log('已经开启推送功能!');
+    }
+  }
+  plus.ios.deleteObject(app);
+  plus.ios.deleteObject(UIApplication);
+  return result;
+}
+
+function contact() {
+  var result = 0;
+  var CNContactStore = plus.ios.import('CNContactStore');
+  var cnAuthStatus = CNContactStore.authorizationStatusForEntityType(0);
+  if (cnAuthStatus === 0) {
+    result = null;
+  } else if (cnAuthStatus == 3) {
+    result = 1;
+  } else {
+    result = 0;
+  }
+  plus.ios.deleteObject(CNContactStore);
+  return result;
+}
+
+function record() {
+  var result = null;
+  var avaudiosession = plus.ios.import('AVAudioSession');
+  var avaudio = avaudiosession.sharedInstance();
+  var status = avaudio.recordPermission();
+  console.log('permissionStatus:' + status);
+  if (status === 1970168948) {
+    result = null;
+  } else if (status === 1735552628) {
+    result = 1;
+  } else {
+    result = 0;
+  }
+  plus.ios.deleteObject(avaudiosession);
+  return result;
+}
+
+function calendar() {
+  var result = null;
+  var EKEventStore = plus.ios.import('EKEventStore');
+  var ekAuthStatus = EKEventStore.authorizationStatusForEntityType(0);
+  if (ekAuthStatus == 3) {
+    result = 1;
+    console.log('日历权限已经开启');
+  } else {
+    console.log('日历权限没有开启');
+  }
+  plus.ios.deleteObject(EKEventStore);
+  return result;
+}
+
+function memo() {
+  var result = null;
+  var EKEventStore = plus.ios.import('EKEventStore');
+  var ekAuthStatus = EKEventStore.authorizationStatusForEntityType(1);
+  if (ekAuthStatus == 3) {
+    result = 1;
+    console.log('备忘录权限已经开启');
+  } else {
+    console.log('备忘录权限没有开启');
+  }
+  plus.ios.deleteObject(EKEventStore);
+  return result;
+}
+
+function requestIOS(permissionID) {
+  return new Promise((resolve, reject) => {
+    switch (permissionID) {
+      case 'push':
+        resolve(push());
+        break;
+      case 'location':
+        resolve(location());
+        break;
+      case 'record':
+        resolve(record());
+        break;
+      case 'camera':
+        resolve(camera());
+        break;
+      case 'album':
+        resolve(album());
+        break;
+      case 'contact':
+        resolve(contact());
+        break;
+      case 'calendar':
+        resolve(calendar());
+        break;
+      case 'memo':
+        resolve(memo());
+        break;
+      default:
+        resolve(0);
+        break;
+    }
+  });
+}
+
+function requestAndroid(permissionID) {
+  return new Promise((resolve, reject) => {
+    plus.android.requestPermissions(
+      [permissionID],
+      function (resultObj) {
+        var result = 0;
+        for (var i = 0; i < resultObj.granted.length; i++) {
+          var grantedPermission = resultObj.granted[i];
+          console.log('已获取的权限:' + grantedPermission);
+          result = 1;
+        }
+        for (var i = 0; i < resultObj.deniedPresent.length; i++) {
+          var deniedPresentPermission = resultObj.deniedPresent[i];
+          console.log('拒绝本次申请的权限:' + deniedPresentPermission);
+          result = 0;
+        }
+        for (var i = 0; i < resultObj.deniedAlways.length; i++) {
+          var deniedAlwaysPermission = resultObj.deniedAlways[i];
+          console.log('永久拒绝申请的权限:' + deniedAlwaysPermission);
+          result = -1;
+        }
+        resolve(result);
+      },
+      function (error) {
+        console.log('result error: ' + error.message);
+        resolve({
+          code: error.code,
+          message: error.message,
+        });
+      },
+    );
+  });
+}
+
+function gotoAppPermissionSetting() {
+  if (permission.isIOS) {
+    var UIApplication = plus.ios.import('UIApplication');
+    var application2 = UIApplication.sharedApplication();
+    var NSURL2 = plus.ios.import('NSURL');
+    var setting2 = NSURL2.URLWithString('app-settings:');
+    application2.openURL(setting2);
+    plus.ios.deleteObject(setting2);
+    plus.ios.deleteObject(NSURL2);
+    plus.ios.deleteObject(application2);
+  } else {
+    var Intent = plus.android.importClass('android.content.Intent');
+    var Settings = plus.android.importClass('android.provider.Settings');
+    var Uri = plus.android.importClass('android.net.Uri');
+    var mainActivity = plus.android.runtimeMainActivity();
+    var intent = new Intent();
+    intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
+    var uri = Uri.fromParts('package', mainActivity.getPackageName(), null);
+    intent.setData(uri);
+    mainActivity.startActivity(intent);
+  }
+}
+
+const permission = {
+  get isIOS() {
+    return typeof isIOS === 'boolean' ? isIOS : (isIOS = uni.getDeviceInfo().platform === 'ios');
+  },
+  requestIOS: requestIOS,
+  requestAndroid: requestAndroid,
+  gotoAppSetting: gotoAppPermissionSetting,
+};
+
+export default permission;

+ 193 - 0
common/libs/sdk-h5-weixin.js

@@ -0,0 +1,193 @@
+/**
+ * 本模块封装微信浏览器下的一些方法。
+ * 更多微信网页开发sdk方法,详见:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html
+ * 有 the permission value is offline verifying 报错请参考 @see https://segmentfault.com/a/1190000042289419 解决
+ */
+
+import jweixin from 'weixin-js-sdk';
+import $helper from '@/common/helper';
+import AuthUtil from '@/common/api/member/auth';
+
+let configSuccess = false;
+
+export default {
+  // 判断是否在微信中
+  isWechat() {
+    const ua = window.navigator.userAgent.toLowerCase();
+    // noinspection EqualityComparisonWithCoercionJS
+    return ua.match(/micromessenger/i) == 'micromessenger';
+  },
+
+  isReady(api) {
+    jweixin.ready(api);
+  },
+
+  // 初始化 JSSDK
+  async init(callback) {
+    if (!this.isWechat()) {
+      $helper.toast('请使用微信网页浏览器打开');
+      return;
+    }
+
+    // 调用后端接口,获得 JSSDK 初始化所需的签名
+    const url = location.origin;
+    const { code, data } = await AuthUtil.createWeixinMpJsapiSignature(url);
+    if (code === 0) {
+      jweixin.config({
+        debug: false,
+        appId: data.appId,
+        timestamp: data.timestamp,
+        nonceStr: data.nonceStr,
+        signature: data.signature,
+        jsApiList: [
+          'chooseWXPay',
+          'openLocation',
+          'getLocation',
+          'updateAppMessageShareData',
+          'updateTimelineShareData',
+          'scanQRCode',
+        ], // TODO 芋艿:后续可以设置更多权限;
+        openTagList: data.openTagList,
+      });
+    } else {
+      console.log('请求 JSSDK 配置失败,错误码:', code);
+    }
+
+    // 监听结果
+    configSuccess = true;
+    jweixin.error((err) => {
+      configSuccess = false;
+      console.error('微信 JSSDK 初始化失败', err);
+      $helper.toast('微信JSSDK:' + err.errMsg);
+    });
+    jweixin.ready(() => {
+      if (configSuccess) {
+        console.log('微信 JSSDK 初始化成功');
+      }
+    });
+
+    // 回调
+    if (callback) {
+      callback(data);
+    }
+  },
+
+  //在需要定位页面调用 TODO 芋艿:未测试
+  getLocation(callback) {
+    this.isReady(() => {
+      jweixin.getLocation({
+        type: 'gcj02', // 默认为wgs84的gps坐标,如果要返回直接给openLocation用的火星坐标,可传入'gcj02'
+        success: function (res) {
+          callback(res);
+        },
+        fail: function (res) {
+          console.log('%c微信H5sdk,getLocation失败:', 'color:green;background:yellow');
+        },
+      });
+    });
+  },
+
+  // 获取微信收货地址
+  openAddress(callback) {
+    this.isReady(() => {
+      jweixin.openAddress({
+        success: function (res) {
+          callback.success && callback.success(res);
+        },
+        fail: function (err) {
+          callback.error && callback.error(err);
+          console.log('%c微信H5sdk,openAddress失败:', 'color:green;background:yellow');
+        },
+        complete: function (res) {},
+      });
+    });
+  },
+
+  // 微信扫码 TODO 芋艿:未测试
+  scanQRCode(callback) {
+    this.isReady(() => {
+      jweixin.scanQRCode({
+        needResult: 1, // 默认为0,扫描结果由微信处理,1则直接返回扫描结果,
+        scanType: ['qrCode', 'barCode'], // 可以指定扫二维码还是一维码,默认二者都有
+        success: function (res) {
+          callback(res);
+        },
+        fail: function (res) {
+          console.log('%c微信H5sdk,scanQRCode失败:', 'color:green;background:yellow');
+        },
+      });
+    });
+  },
+
+  // 更新微信分享信息
+  updateShareInfo(data, callback = null) {
+    this.isReady(() => {
+      const shareData = {
+        title: data.title,
+        desc: data.desc,
+        link: data.link,
+        imgUrl: data.image,
+        success: function (res) {
+          if (callback) {
+            callback(res);
+          }
+          // 分享后的一些操作,比如分享统计等等
+        },
+        cancel: function (res) {},
+      };
+
+      // 新版 分享聊天api
+      jweixin.updateAppMessageShareData(shareData);
+      // 新版 分享到朋友圈api
+      jweixin.updateTimelineShareData(shareData);
+    });
+  },
+
+  // 打开坐标位置 TODO 芋艿:未测试
+  openLocation(data, callback) {
+    this.isReady(() => {
+      jweixin.openLocation({
+        ...data,
+        success: function (res) {
+          console.log(res);
+        },
+      });
+    });
+  },
+
+  // 选择图片 TODO 芋艿:未测试
+  chooseImage(callback) {
+    this.isReady(() => {
+      jweixin.chooseImage({
+        count: 1,
+        sizeType: ['compressed'],
+        sourceType: ['album'],
+        success: function (rs) {
+          callback(rs);
+        },
+      });
+    });
+  },
+
+  // 微信支付
+  wxpay(data, callback) {
+    this.isReady(() => {
+      jweixin.chooseWXPay({
+        timestamp: data.timeStamp, // 支付签名时间戳,注意微信jssdk中的所有使用timestamp字段均为小写。但最新版的支付后台生成签名使用的timeStamp字段名需大写其中的S字符
+        nonceStr: data.nonceStr, // 支付签名随机串,不长于 32 位
+        package: data.packageValue, // 统一支付接口返回的prepay_id参数值,提交格式如:prepay_id=\*\*\*)
+        signType: data.signType, // 签名方式,默认为'SHA1',使用新版支付需传入'MD5'
+        paySign: data.paySign, // 支付签名
+        success: function (res) {
+          callback.success && callback.success(res);
+        },
+        fail: function (err) {
+          callback.fail && callback.fail(err);
+        },
+        cancel: function (err) {
+          callback.cancel && callback.cancel(err);
+        },
+      });
+    });
+  },
+};

+ 121 - 0
common/platform/index.js

@@ -0,0 +1,121 @@
+/**
+ * SoundChain 第三方平台功能聚合
+ * @version 1.0.3
+ * @author lidongtony
+ * @param {String} name - 厂商+平台名称
+ * @param {String} provider - 厂商
+ * @param {String} platform - 平台名称
+ * @param {String} os - 系统型号
+ * @param {Object} device - 设备信息
+ */
+
+import { isEmpty } from 'lodash-es';
+// #ifdef H5
+import { isWxBrowser } from '@/common/helper/utils';
+// #endif
+
+const device = uni.getWindowInfo();
+
+const os = uni.getDeviceInfo().platform;
+
+let name = '';
+let provider = '';
+let platform = '';
+let isWechatInstalled = true;
+
+// #ifdef H5
+if (isWxBrowser()) {
+  name = 'WechatOfficialAccount';
+  provider = 'wechat';
+  platform = 'officialAccount';
+} else {
+  name = 'H5';
+  platform = 'h5';
+}
+// #endif
+
+// #ifdef APP-PLUS
+name = 'App';
+platform = 'openPlatform';
+// 检查微信客户端是否安装,否则AppleStore会因此拒绝上架
+if (os === 'ios') {
+  isWechatInstalled = plus.ios.import('WXApi').isWXAppInstalled();
+}
+// #endif
+
+// #ifdef MP-WEIXIN
+name = 'WechatMiniProgram';
+platform = 'miniProgram';
+provider = 'wechat';
+// #endif
+
+if (isEmpty(name)) {
+  uni.showToast({
+    title: '暂不支持该平台',
+    icon: 'none',
+  });
+}
+
+
+/**
+ * 检查网络
+ * @param {Boolean} silence - 静默检查
+ */
+async function checkNetwork() {
+  const networkStatus = await uni.getNetworkType();
+  if (networkStatus.networkType == 'none') {
+    return Promise.resolve(false);
+  }
+  return Promise.resolve(true);
+}
+
+// 获取小程序胶囊信息
+const getCapsule = () => {
+  // #ifdef MP
+  let capsule = uni.getMenuButtonBoundingClientRect();
+  if (!capsule) {
+    capsule = {
+      bottom: 56,
+      height: 32,
+      left: 278,
+      right: 365,
+      top: 24,
+      width: 87,
+    };
+  }
+  return capsule;
+  // #endif
+
+  // #ifndef MP
+  return {
+    bottom: 56,
+    height: 32,
+    left: 278,
+    right: 365,
+    top: 24,
+    width: 87,
+  };
+  // #endif
+};
+
+const capsule = getCapsule();
+
+// 标题栏高度
+const getNavBar = () => {
+  return device.statusBarHeight + 44;
+};
+const navbar = getNavBar();
+
+const _platform = {
+  name,
+  device,
+  os,
+  provider,
+  platform,
+  checkNetwork,
+  capsule,
+  navbar,
+  isWechatInstalled,
+};
+
+export default _platform;

+ 302 - 0
common/request/index.js

@@ -0,0 +1,302 @@
+/**
+ * SoundChain-request
+ * @description api模块管理,loading配置,请求拦截,错误处理
+ */
+
+import Request from 'luch-request';
+import { apiPath, baseUrl } from '@/common/config';
+import $store from '@/common/store';
+import $platform from '@/common/platform';
+import { showAuthModal, showLoginPage } from '@/common/hooks/useModal';
+import AuthUtil from '@/common/api/member/auth';
+
+const options = {
+  // 显示操作成功消息 默认不显示
+  showSuccess: false,
+  // 成功提醒 默认使用后端返回值
+  successMsg: '',
+  // 显示失败消息 默认显示
+  showError: true,
+  // 失败提醒 默认使用后端返回信息
+  errorMsg: '',
+  // 显示请求时loading模态框 默认显示
+  showLoading: true,
+  // loading提醒文字
+  loadingMsg: '加载中',
+  // 需要授权才能请求 默认放开
+  auth: false,
+  // ...
+};
+
+// Loading全局实例
+let LoadingInstance = {
+  target: null,
+  count: 0,
+};
+
+/**
+ * 关闭loading
+ */
+function closeLoading() {
+  if (LoadingInstance.count > 0) LoadingInstance.count--;
+  if (LoadingInstance.count === 0) uni.hideLoading();
+}
+
+/**
+ * @description 请求基础配置 可直接使用访问自定义请求
+ */
+const http = new Request({
+  baseURL: baseUrl + apiPath,
+  timeout: 60*1000,
+  method: 'GET',
+  header: {
+    Accept: 'text/json',
+    'Content-Type': 'application/json;charset=UTF-8',
+    platform: $platform.name,
+	clientType: 1
+  },
+  // #ifdef APP-PLUS
+  sslVerify: false,
+  // #endif
+  // #ifdef H5
+  // 跨域请求时是否携带凭证(cookies)仅H5支持(HBuilderX 2.6.15+)
+  withCredentials: false,
+  // #endif
+  custom: options,
+});
+
+/**
+ * @description 请求拦截器
+ */
+http.interceptors.request.use(
+  (config) => {
+    // 自定义处理【auth 授权】:必须登录的接口,则跳出 AuthModal 登录弹窗
+    if (config.custom.auth && !$store('user').isLogin) {
+      //showAuthModal();
+	  showLoginPage();
+      return Promise.reject();
+    }
+
+    // 自定义处理【loading 加载中】:如果需要显示 loading,则显示 loading
+    if (config.custom.showLoading) {
+      LoadingInstance.count++;
+      LoadingInstance.count === 1 &&
+        uni.showLoading({
+          title: config.custom.loadingMsg,
+          mask: true,
+          fail: () => {
+            uni.hideLoading();
+          },
+        });
+    }
+
+    // 增加 token 令牌
+    const token = getAccessToken();
+    if (token) {
+      config.header['token'] = token;
+    }
+	config.header['Accept'] = '*/*';
+    return config;
+  },
+  (error) => {
+    return Promise.reject(error);
+  },
+);
+
+/**
+ * @description 响应拦截器
+ */
+http.interceptors.response.use(
+  (response) => {
+    // 约定:如果是 login URL 地址,并且返回了 token 说明是登录相关的接口,则自动设置登陆令牌
+    if (response.config.url.indexOf('phonePswLogin') >= 0 && response.header?.token) {
+      $store('user').setToken(response.header.token, response.header.token)
+    }
+
+    // 自定处理【loading 加载中】:如果需要显示 loading,则关闭 loading
+    response.config.custom.showLoading && closeLoading();
+
+    // 自定义处理【error 错误提示】:如果需要显示错误提示,则显示错误提示
+    if (response.data.code !== 1) {
+      // 特殊:如果 401 错误码,则跳转到登录页 or 刷新令牌
+      if (response.data.code === 401||response.data.code===10001||response.data.code===20103) {
+        //return refreshToken(response.config);
+		return handleAuthorized()
+      }
+      if (response.config.custom.showError) {
+        // 错误提示
+        uni.showToast({
+          title: response.data.msg || '服务器开小差啦,请稍后再试~',
+          icon: 'none',
+          mask: true,
+        });
+      }
+    }
+
+    // 自定义处理【showSuccess 成功提示】:如果需要显示成功提示,则显示成功提示
+    if (
+      response.config.custom.showSuccess &&
+      response.config.custom.successMsg !== '' &&
+      response.data.code === 1
+    ) {
+      uni.showToast({
+        title: response.config.custom.successMsg,
+        icon: 'none',
+      });
+    }
+
+    // 返回结果:包括 code + data + msg
+    return Promise.resolve(response.data);
+  },
+  (error) => {
+    const userStore = $store('user');
+    const isLogin = userStore.isLogin;
+    let errorMessage = '网络请求出错';
+    if (error !== undefined) {
+      switch (error.statusCode) {
+        case 400:
+          errorMessage = '请求错误';
+          break;
+        case 401||10001||20103:
+          errorMessage = isLogin ? '您的登陆已过期' : '请先登录';
+          // 正常情况下,后端不会返回 401 错误,所以这里不处理 handleAuthorized
+		  handleAuthorized()
+          break;
+        case 403:
+          errorMessage = '拒绝访问';
+          break;
+        case 404:
+          errorMessage = '请求出错';
+          break;
+        case 408:
+          errorMessage = '请求超时';
+          break;
+        case 429:
+          errorMessage = '请求频繁, 请稍后再访问';
+          break;
+        case 500:
+          errorMessage = '服务器开小差啦,请稍后再试~';
+          break;
+        case 501:
+          errorMessage = '服务未实现';
+          break;
+        case 502:
+          errorMessage = '网络错误';
+          break;
+        case 503:
+          errorMessage = '服务不可用';
+          break;
+        case 504:
+          errorMessage = '网络超时';
+          break;
+        case 505:
+          errorMessage = 'HTTP 版本不受支持';
+          break;
+      }
+      if (error.errMsg.includes('timeout')) errorMessage = '请求超时';
+      // #ifdef H5
+      if (error.errMsg.includes('Network'))
+        errorMessage = window.navigator.onLine ? '服务器异常' : '请检查您的网络连接';
+      // #endif
+    }
+
+    if (error && error.config) {
+      if (error.config.custom.showError) {
+        uni.showToast({
+          title: error.data?.msg || errorMessage,
+          icon: 'none',
+          mask: true,
+        });
+      }
+      error.config.custom.showLoading && closeLoading();
+    }
+
+    return false;
+  },
+);
+
+// Axios 无感知刷新令牌,参考 https://www.dashingdog.cn/article/11 与 https://segmentfault.com/a/1190000020210980 实现
+let requestList = []; // 请求队列
+let isRefreshToken = false; // 是否正在刷新中
+const refreshToken = async (config) => {
+  // 如果当前已经是 refresh-token 的 URL 地址,并且还是 401 错误,说明是刷新令牌失败了,直接返回 Promise.reject(error)
+  if (config.url.indexOf('/token/update') >= 0) {
+    return Promise.reject('error');
+  }
+
+  // 如果未认证,并且未进行刷新令牌,说明可能是访问令牌过期了
+  if (!isRefreshToken) {
+    isRefreshToken = true;
+    // 1. 如果获取不到刷新令牌,则只能执行登出操作
+    const refreshToken = getRefreshToken();
+    if (!refreshToken) {
+      return handleAuthorized();
+    }
+    // 2. 进行刷新访问令牌
+    try {
+      const refreshTokenResult = await AuthUtil.refreshToken(refreshToken);
+      if (refreshTokenResult.code !== 1) {
+        // 如果刷新不成功,直接抛出 e 触发 2.2 的逻辑
+        // noinspection ExceptionCaughtLocallyJS
+        throw new Error('刷新令牌失败');
+      }
+      // 2.1 刷新成功,则回放队列的请求 + 当前请求
+      config.header.Authorization = 'Bearer ' + getAccessToken();
+      requestList.forEach((cb) => {
+        cb();
+      });
+      requestList = [];
+      return request(config);
+    } catch (e) {
+      // 为什么需要 catch 异常呢?刷新失败时,请求因为 Promise.reject 触发异常。
+      // 2.2 刷新失败,只回放队列的请求
+      requestList.forEach((cb) => {
+        cb();
+      });
+      // 提示是否要登出。即不回放当前请求!不然会形成递归
+      return handleAuthorized();
+    } finally {
+      requestList = [];
+      isRefreshToken = false;
+    }
+  } else {
+    // 添加到队列,等待刷新获取到新的令牌
+    return new Promise((resolve) => {
+      requestList.push(() => {
+        config.header.Authorization = 'Bearer ' + getAccessToken(); // 让每个请求携带自定义token 请根据实际情况自行修改
+        resolve(request(config));
+      });
+    });
+  }
+};
+
+/**
+ * 处理 401 未登录的错误
+ */
+const handleAuthorized = () => {
+  const userStore = $store('user');
+  userStore.logout(true);
+  //showAuthModal();
+  showLoginPage();
+  // 登录超时
+  return Promise.reject({
+    code: 401,
+    msg: userStore.isLogin ? '您的登陆已过期' : '请先登录',
+  });
+};
+
+/** 获得访问令牌 */
+export const getAccessToken = () => {
+  return uni.getStorageSync('token');
+};
+
+/** 获得刷新令牌 */
+export const getRefreshToken = () => {
+  return uni.getStorageSync('refresh-token');
+};
+
+const request = (config) => {
+  return http.middleware(config);
+};
+
+export default request;

+ 172 - 0
common/router/index.js

@@ -0,0 +1,172 @@
+import $store from '@/common/store';
+import { showAuthModal, showLoginPage } from '@/common/hooks/useModal';
+import { isNumber, isString, isEmpty, startsWith, isObject, isNil, clone } from 'lodash-es';
+import throttle from '@/common/helper/throttle';
+
+const _go = (
+  path,
+  params = {},
+  options = {
+    redirect: false,
+  },
+) => {
+  let page = ''; // 跳转页面
+  let query = ''; // 页面参数
+  let url = ''; // 跳转页面完整路径
+
+  if (isString(path)) {
+    // 判断跳转类型是 path | 还是http
+    if (startsWith(path, 'http')) {
+      // #ifdef H5
+      window.location = path;
+      return;
+      // #endif
+      // #ifndef H5
+      page = `/pages/public/webview`;
+      query = `url=${encodeURIComponent(path)}`;
+      // #endif
+    } else {
+      [page, query] = path.split('?');
+    }
+    if (!isEmpty(params)) {
+      let query2 = paramsToQuery(params);
+      if (isEmpty(query)) {
+        query = query2;
+      } else {
+        query += '&' + query2;
+      }
+    }
+  }
+
+  if (isObject(path)) {
+    page = path.url;
+    if (!isNil(path.params)) {
+      query = paramsToQuery(path.params);
+    }
+  }
+  const nextRoute = ROUTES_MAP[page];
+
+  // 未找到指定跳转页面
+  // mark: 跳转404页
+  if (!nextRoute) {
+    console.log(`%c跳转路径参数错误<${page || 'EMPTY'}>`, 'color:red;background:yellow');
+    return;
+  }
+  // 页面登录拦截
+  if (nextRoute.meta?.auth && !$store('user').isLogin) {
+    //showAuthModal();
+	showLoginPage()
+    return;
+  }
+
+  url = page;
+  if (!isEmpty(query)) {
+    url += `?${query}`;
+  }
+
+  // 跳转底部导航
+  if (TABBAR.includes(page)) {
+    uni.switchTab({
+      url,
+    });
+    return;
+  }
+
+  // 使用redirect跳转
+  if (options.redirect) {
+    uni.redirectTo({
+      url,
+    });
+    return;
+  }
+
+  uni.navigateTo({
+    url,
+  });
+};
+
+// 限流 防止重复点击跳转
+function go(...args) {
+  throttle(() => {
+    _go(...args);
+  });
+}
+
+function paramsToQuery(params) {
+  if (isEmpty(params)) {
+    return '';
+  }
+  // return new URLSearchParams(Object.entries(params)).toString();
+  let query = [];
+  for (let key in params) {
+    query.push(key + '=' + params[key]);
+  }
+
+  return query.join('&');
+}
+
+function back() {
+  // #ifdef H5
+  history.back();
+  // #endif
+
+  // #ifndef H5
+  uni.navigateBack();
+  // #endif
+}
+
+function redirect(path, params = {}) {
+  go(path, params, {
+    redirect: true,
+  });
+}
+
+// 检测是否有浏览器历史
+function hasHistory() {
+  // #ifndef H5
+  const pages = getCurrentPages();
+  if (pages.length > 1) {
+    return true;
+  }
+  return false;
+  // #endif
+
+  // #ifdef H5
+  return !!history.state.back;
+  // #endif
+}
+
+function getCurrentRoute(field = '') {
+  let currentPage = getCurrentPage();
+  // #ifdef MP
+  currentPage.$page['route'] = currentPage.route;
+  currentPage.$page['options'] = currentPage.options;
+  // #endif
+  if (field !== '') {
+    return currentPage.$page[field];
+  } else {
+    return currentPage.$page;
+  }
+}
+
+function getCurrentPage() {
+  let pages = getCurrentPages();
+  return pages[pages.length - 1];
+}
+
+function error(errCode, errMsg = '') {
+  redirect('/pages/public/error', {
+    errCode,
+    errMsg,
+  });
+}
+
+export default {
+  go,
+  back,
+  hasHistory,
+  redirect,
+  getCurrentPage,
+  getCurrentRoute,
+  error,
+};

+ 79 - 0
common/router/utils/strip-json-comments.js

@@ -0,0 +1,79 @@
+const singleComment = Symbol('singleComment');
+const multiComment = Symbol('multiComment');
+
+const stripWithoutWhitespace = () => '';
+const stripWithWhitespace = (string, start, end) => string.slice(start, end).replace(/\S/g, ' ');
+
+const isEscaped = (jsonString, quotePosition) => {
+  let index = quotePosition - 1;
+  let backslashCount = 0;
+
+  while (jsonString[index] === '\\') {
+    index -= 1;
+    backslashCount += 1;
+  }
+
+  return Boolean(backslashCount % 2);
+};
+
+export default function stripJsonComments(jsonString, { whitespace = true } = {}) {
+  if (typeof jsonString !== 'string') {
+    throw new TypeError(
+      `Expected argument \`jsonString\` to be a \`string\`, got \`${typeof jsonString}\``,
+    );
+  }
+
+  const strip = whitespace ? stripWithWhitespace : stripWithoutWhitespace;
+
+  let isInsideString = false;
+  let isInsideComment = false;
+  let offset = 0;
+  let result = '';
+
+  for (let index = 0; index < jsonString.length; index++) {
+    const currentCharacter = jsonString[index];
+    const nextCharacter = jsonString[index + 1];
+
+    if (!isInsideComment && currentCharacter === '"') {
+      const escaped = isEscaped(jsonString, index);
+      if (!escaped) {
+        isInsideString = !isInsideString;
+      }
+    }
+
+    if (isInsideString) {
+      continue;
+    }
+
+    if (!isInsideComment && currentCharacter + nextCharacter === '//') {
+      result += jsonString.slice(offset, index);
+      offset = index;
+      isInsideComment = singleComment;
+      index++;
+    } else if (isInsideComment === singleComment && currentCharacter + nextCharacter === '\r\n') {
+      index++;
+      isInsideComment = false;
+      result += strip(jsonString, offset, index);
+      offset = index;
+      continue;
+    } else if (isInsideComment === singleComment && currentCharacter === '\n') {
+      isInsideComment = false;
+      result += strip(jsonString, offset, index);
+      offset = index;
+    } else if (!isInsideComment && currentCharacter + nextCharacter === '/*') {
+      result += jsonString.slice(offset, index);
+      offset = index;
+      isInsideComment = multiComment;
+      index++;
+      continue;
+    } else if (isInsideComment === multiComment && currentCharacter + nextCharacter === '*/') {
+      index++;
+      isInsideComment = false;
+      result += strip(jsonString, offset, index + 1);
+      offset = index + 1;
+      continue;
+    }
+  }
+
+  return result + (isInsideComment ? strip(jsonString.slice(offset)) : jsonString.slice(offset));
+}

+ 103 - 0
common/router/utils/uni-read-pages-v3.js

@@ -0,0 +1,103 @@
+'use strict';
+Object.defineProperty(exports, '__esModule', {
+  value: true,
+});
+const fs = require('fs');
+import stripJsonComments from './strip-json-comments';
+import { isArray, isEmpty } from 'lodash';
+
+class TransformPages {
+  constructor({ includes, pagesJsonDir }) {
+    this.includes = includes;
+    this.uniPagesJSON = JSON.parse(stripJsonComments(fs.readFileSync(pagesJsonDir, 'utf-8')));
+    this.routes = this.getPagesRoutes().concat(this.getSubPackagesRoutes());
+    this.tabbar = this.getTabbarRoutes();
+    this.routesMap = this.transformPathToKey(this.routes);
+  }
+  /**
+   * 通过读取pages.json文件 生成直接可用的routes
+   */
+  getPagesRoutes(pages = this.uniPagesJSON.pages, rootPath = null) {
+    let routes = [];
+    for (let i = 0; i < pages.length; i++) {
+      const item = pages[i];
+      let route = {};
+      for (let j = 0; j < this.includes.length; j++) {
+        const key = this.includes[j];
+        let value = item[key];
+        if (key === 'path') {
+          value = rootPath ? `/${rootPath}/${value}` : `/${value}`;
+        }
+        if (key === 'aliasPath' && i == 0 && rootPath == null) {
+          route[key] = route[key] || '/';
+        } else if (value !== undefined) {
+          route[key] = value;
+        }
+      }
+      routes.push(route);
+    }
+    return routes;
+  }
+  /**
+   * 解析小程序分包路径
+   */
+  getSubPackagesRoutes() {
+    if (!(this.uniPagesJSON && this.uniPagesJSON.subPackages)) {
+      return [];
+    }
+    const subPackages = this.uniPagesJSON.subPackages;
+    let routes = [];
+    for (let i = 0; i < subPackages.length; i++) {
+      const subPages = subPackages[i].pages;
+      const root = subPackages[i].root;
+      const subRoutes = this.getPagesRoutes(subPages, root);
+      routes = routes.concat(subRoutes);
+    }
+    return routes;
+  }
+
+  getTabbarRoutes() {
+    if (!(this.uniPagesJSON && this.uniPagesJSON.tabBar && this.uniPagesJSON.tabBar.list)) {
+      return [];
+    }
+    const tabbar = this.uniPagesJSON.tabBar.list;
+    let tabbarMap = [];
+    tabbar.forEach((bar) => {
+      tabbarMap.push('/' + bar.pagePath);
+    });
+    return tabbarMap;
+  }
+
+  transformPathToKey(list) {
+    if (!isArray(list) || isEmpty(list)) {
+      return [];
+    }
+    let map = {};
+    list.forEach((i) => {
+      map[i.path] = i;
+    });
+    return map;
+  }
+}
+
+function uniReadPagesV3Plugin({ pagesJsonDir, includes }) {
+  let defaultIncludes = ['path', 'aliasPath', 'name'];
+  includes = [...defaultIncludes, ...includes];
+  let pages = new TransformPages({
+    pagesJsonDir,
+    includes,
+  });
+  return {
+    name: 'uni-read-pages-v3',
+    config(config) {
+      return {
+        define: {
+          ROUTES: pages.routes,
+          ROUTES_MAP: pages.routesMap,
+          TABBAR: pages.tabbar,
+        },
+      };
+    },
+  };
+}
+exports.default = uniReadPagesV3Plugin;

+ 354 - 0
common/scss/_main.scss

@@ -0,0 +1,354 @@
+body {
+  color: var(--text-a);
+  background-color: var(--ui-BG-1) !important;
+  font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans',
+    sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+}
+
+/* ==================
+         初始化
+ ==================== */
+.ui-link {
+  cursor: pointer;
+}
+navigator {
+  display: inline-flex;
+}
+navigator.navigator-hover {
+  background-color: inherit;
+  transform: translate(1rpx, 1rpx);
+  // opacity: 1;
+}
+
+/* ==================
+         辅助类
+ ==================== */
+.none {
+  display: none !important;
+}
+.inline {
+  display: inline !important;
+}
+.inline-block {
+  display: inline-block !important;
+}
+.block {
+  display: block !important;
+}
+.touch-none {
+  pointer-events: none;
+}
+.touch-all {
+  pointer-events: all;
+}
+.flex {
+  display: flex !important;
+}
+.inline-flex {
+  display: inline-flex !important;
+}
+.w-100 {
+  width: 100%;
+}
+/* -- 浮动 -- */
+.cf::after,
+.cf::before {
+  content: '';
+  display: table;
+}
+.cf::after {
+  clear: both;
+}
+.fl {
+  float: left;
+}
+.fr {
+  float: right;
+}
+.position-center {
+  @include position-center;
+}
+.position-relative {
+  position: relative;
+}
+/* -- 工具类 -- */
+@function negativify-map($map) {
+  $result: ();
+  @each $key, $value in $map {
+    @if $key != 0 {
+      $result: map-merge($result, ('n' + $key: (-$value)));
+    }
+  }
+  @return $result;
+}
+
+$utilities: () !default;
+$utilities: map-merge(
+  (
+    'margin': (
+      responsive: true,
+      property: margin,
+      class: m,
+      values:
+        map-merge(
+          $spacers,
+          (
+            auto: auto,
+          )
+        ),
+    ),
+    'margin-x': (
+      property: margin-right margin-left,
+      class: mx,
+      values:
+        map-merge(
+          $spacers,
+          (
+            auto: auto,
+          )
+        ),
+    ),
+    'margin-y': (
+      property: margin-top margin-bottom,
+      class: my,
+      values:
+        map-merge(
+          $spacers,
+          (
+            auto: auto,
+          )
+        ),
+    ),
+    'margin-top': (
+      property: margin-top,
+      class: mt,
+      values:
+        map-merge(
+          $spacers,
+          (
+            auto: auto,
+          )
+        ),
+    ),
+    'margin-right': (
+      property: margin-right,
+      class: mr,
+      values:
+        map-merge(
+          $spacers,
+          (
+            auto: auto,
+          )
+        ),
+    ),
+    'margin-bottom': (
+      property: margin-bottom,
+      class: mb,
+      values:
+        map-merge(
+          $spacers,
+          (
+            auto: auto,
+          )
+        ),
+    ),
+    'margin-left': (
+      property: margin-left,
+      class: ml,
+      values:
+        map-merge(
+          $spacers,
+          (
+            auto: auto,
+          )
+        ),
+    ),
+    'padding': (
+      responsive: true,
+      property: padding,
+      class: p,
+      values: $spacers,
+    ),
+    'padding-x': (
+      property: padding-right padding-left,
+      class: px,
+      values: $spacers,
+    ),
+    'padding-y': (
+      property: padding-top padding-bottom,
+      class: py,
+      values: $spacers,
+    ),
+    'padding-top': (
+      property: padding-top,
+      class: pt,
+      values: $spacers,
+    ),
+    'padding-right': (
+      property: padding-right,
+      class: pr,
+      values: $spacers,
+    ),
+    'padding-bottom': (
+      property: padding-bottom,
+      class: pb,
+      values: $spacers,
+    ),
+    'padding-left': (
+      property: padding-left,
+      class: pl,
+      values: $spacers,
+    ),
+    'font-weight': (
+      property: font-weight,
+      class: text,
+      values: (
+        light: $font-weight-light,
+        lighter: $font-weight-lighter,
+        normal: $font-weight-normal,
+        bold: $font-weight-bold,
+        bolder: $font-weight-bolder,
+      ),
+    ),
+    'text-align': (
+      property: text-align,
+      class: text,
+      values: left right center,
+    ),
+    'font-color': (
+      property: color,
+      class: text,
+      values:
+        map-merge(
+          $colors,
+          map-merge(
+            $grays,
+            map-merge(
+              $darks,
+              (
+                'reset': inherit,
+              )
+            )
+          )
+        ),
+    ),
+    'line-height': (
+      property: line-height,
+      class: lh,
+      values: (
+        1: 1,
+        sm: $line-height-sm,
+        base: $line-height-base,
+        lg: $line-height-lg,
+      ),
+    ),
+    'white-space': (
+      property: white-space,
+      class: text,
+      values: (
+        nowrap: nowrap,
+      ),
+    ),
+    'radius': (
+      property: border-radius,
+      class: radius,
+      values: (
+        null: $radius,
+        sm: $radius-sm,
+        lg: $radius-lg,
+        0: 0,
+      ),
+    ),
+    'round': (
+      property: border-radius,
+      class: round,
+      values: (
+        null: $round-pill,
+        circle: 50%,
+      ),
+    ),
+    'radius-top': (
+      property: border-top-left-radius border-top-right-radius,
+      class: radius-top,
+      values: (
+        null: $radius,
+      ),
+    ),
+    'radius-right': (
+      property: border-top-right-radius border-bottom-right-radius,
+      class: radius-right,
+      values: (
+        null: $radius,
+      ),
+    ),
+    'radius-bottom': (
+      property: border-bottom-right-radius border-bottom-left-radius,
+      class: radius-bottom,
+      values: (
+        null: $radius,
+      ),
+    ),
+    'radius-left': (
+      property: border-bottom-left-radius border-top-left-radius,
+      class: radius-left,
+      values: (
+        null: $radius,
+      ),
+    ),
+    'radius-lr': (
+      property: border-top-left-radius border-bottom-right-radius,
+      class: radius-lr,
+      values: (
+        null: $radius,
+      ),
+    ),
+    'radius-lrs': (
+      property: border-top-right-radius border-bottom-left-radius,
+      class: radius-lr,
+      values: (
+        null: 0,
+      ),
+    ),
+    'radius-rl': (
+      property: border-top-right-radius border-bottom-left-radius,
+      class: radius-rl,
+      values: (
+        null: $radius,
+      ),
+    ),
+    'radius-rls': (
+      property: border-top-left-radius border-bottom-right-radius,
+      class: radius-rl,
+      values: (
+        null: 0,
+      ),
+    ),
+  ),
+  $utilities
+);
+@each $key, $utility in $utilities {
+  @if type-of($utility) == 'map' {
+    $values: map-get($utility, values);
+    @if type-of($values) == 'string' or type-of(nth($values, 1)) != 'list' {
+      $values: zip($values, $values);
+    }
+    @each $key, $value in $values {
+      $properties: map-get($utility, property);
+      @if type-of($properties) == 'string' {
+        $properties: append((), $properties);
+      }
+      $property-class: if(
+        map-has-key($utility, class),
+        map-get($utility, class),
+        nth($properties, 1)
+      );
+      $property-class: if($property-class == null, '', $property-class);
+      $property-class-modifier: if($key, if($property-class == '', '', '-') + $key, '');
+      .#{$property-class + $property-class-modifier} {
+        @each $property in $properties {
+          #{$property}: $value !important;
+        }
+      }
+    }
+  }
+}

+ 61 - 0
common/scss/_mixins.scss

@@ -0,0 +1,61 @@
+@mixin bg-square {
+  background: {
+    color: #fff;
+    image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%),
+      linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%);
+    size: 40rpx 40rpx;
+    position: 0 0, 20rpx 20rpx;
+  }
+}
+
+@mixin flex($direction: row) {
+  /* #ifndef APP-NVUE */
+  display: flex;
+  /* #endif */
+  flex-direction: $direction;
+}
+@mixin flex-bar {
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+@mixin flex-center {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+@mixin arrow {
+  content: '';
+  height: 0;
+  width: 0;
+  position: absolute;
+}
+@mixin arrow-top {
+  @include arrow;
+  // border-color: transparent transparent $ui-BG;
+  border-style: none solid solid;
+  border-width: 0 20rpx 20rpx;
+}
+
+@mixin arrow-right {
+  @include arrow;
+  // border-color: transparent  $ui-BG transparent;
+  border-style: solid solid solid none;
+  border-width: 20rpx 20rpx 20rpx 0;
+}
+@mixin position-center {
+  position: absolute !important;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  margin: auto;
+}
+
+@mixin blur {
+  -webkit-backdrop-filter: blur(20px);
+  backdrop-filter: blur(20px);
+  color: var(--ui-TC);
+}

+ 286 - 0
common/scss/_tools.scss

@@ -0,0 +1,286 @@
+/* ==================
+          常用工具
+ ==================== */
+
+.ss-bg-opactity-block {
+  background-color: rgba(#000, 0.2);
+  color: #fff;
+}
+
+/* ==================
+          flex布局
+ ==================== */
+
+.ss-flex {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.ss-flex-1 {
+  flex: 1;
+}
+
+.ss-flex-col {
+  display: flex;
+  flex-direction: column;
+}
+
+.ss-flex-wrap {
+  flex-wrap: wrap;
+}
+
+.ss-flex-nowrap {
+  flex-wrap: nowrap;
+}
+
+.ss-col-center {
+  align-items: center;
+}
+
+.ss-col-top {
+  align-items: flex-start;
+}
+
+.ss-col-bottom {
+  align-items: flex-end;
+}
+
+.ss-col-stretch {
+  align-items: stretch;
+}
+
+.ss-row-center {
+  justify-content: center;
+}
+
+.ss-row-left {
+  justify-content: flex-start;
+}
+
+.ss-row-right {
+  justify-content: flex-end;
+}
+
+.ss-row-between {
+  justify-content: space-between;
+}
+
+.ss-row-around {
+  justify-content: space-around;
+}
+
+.ss-self-start {
+  align-self: flex-start;
+}
+
+.ss-self-end {
+  align-self: flex-end;
+}
+
+.ss-self-center {
+  align-self: center;
+}
+.ss-h-100 {
+  height: 100%;
+}
+.ss-w-100 {
+  width: 100%;
+}
+
+/* ==================
+
+    margin padding: 内外边距
+	
+ ==================== */
+@for $i from 0 through 100 {
+  // 只要双数和能被5除尽的数
+  @if $i % 2==0 or $i % 5==0 {
+    // 得出:u-margin-30或者u-m-30
+    .ss-margin-#{$i},
+    .ss-m-#{$i} {
+      margin: $i + rpx;
+    }
+    .ss-m-x-#{$i} {
+      margin-left: $i + rpx;
+      margin-right: $i + rpx;
+    }
+    .ss-m-y-#{$i} {
+      margin-top: $i + rpx;
+      margin-bottom: $i + rpx;
+    }
+
+    // 得出:u-padding-30或者u-p-30
+    .ss-padding-#{$i},
+    .ss-p-#{$i} {
+      padding: $i + rpx;
+    }
+    .ss-p-x-#{$i} {
+      padding-left: $i + rpx;
+      padding-right: $i + rpx;
+    }
+    .ss-p-y-#{$i} {
+      padding-top: $i + rpx;
+      padding-bottom: $i + rpx;
+    }
+
+    @each $short, $long in l left, t top, r right, b bottom {
+      // 缩写版,结果如: u-m-l-30
+      // 定义外边距
+      .ss-m-#{$short}-#{$i} {
+        margin-#{$long}: $i + rpx;
+      }
+
+      // 定义内边距
+      .ss-p-#{$short}-#{$i} {
+        padding-#{$long}: $i + rpx;
+      }
+
+      // 完整版,结果如:u-margin-left-30
+      // 定义外边距
+      .ss-margin-#{$long}-#{$i} {
+        margin-#{$long}: $i + rpx;
+      }
+
+      // 定义内边距
+      .ss-padding-#{$long}-#{$i} {
+        padding-#{$long}: $i + rpx;
+      }
+    }
+  }
+}
+
+/* ==================
+
+    radius
+	
+ ==================== */
+@for $i from 0 through 100 {
+  // 只要双数和能被5除尽的数
+  @if $i % 2==0 or $i % 5==0 {
+    .ss-radius-#{$i},
+    .ss-r-#{$i} {
+      border-radius: $i + rpx;
+    }
+
+    .ss-r-t-#{$i} {
+      border-top-left-radius: $i + rpx;
+      border-top-right-radius: $i + rpx;
+    }
+
+    .ss-r-b-#{$i} {
+      border-bottom-left-radius: $i + rpx;
+      border-bottom-right-radius: $i + rpx;
+    }
+
+    @each $short, $long in tl 'top-left', tr 'top-right', bl 'bottom-right', br 'bottom-right' {
+      // 定义外边距
+      .ss-r-#{$short}-#{$i} {
+        border-#{$long}-radius: $i + rpx;
+      }
+
+      // 定义内边距
+      .ss-radius-#{$long}-#{$i} {
+        border-#{$long}-radius: $i + rpx;
+      }
+    }
+  }
+}
+
+/* ==================
+
+    溢出省略号
+    @param {Number} 行数
+	
+ ==================== */
+@mixin ellipsis($rowCount: 1) {
+  // @if $rowCount <=1 {
+  //   overflow: hidden;
+  //   text-overflow: ellipsis;
+  //   white-space: nowrap;
+  // } @else {
+  //   min-width: 0;
+  //   overflow: hidden;
+  //   text-overflow: ellipsis;
+  //   display: -webkit-box;
+  //   -webkit-line-clamp: $rowCount;
+  //   -webkit-box-orient: vertical;
+  // }
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: $rowCount;
+  -webkit-box-orient: vertical;
+}
+
+@for $i from 1 through 6 {
+  .ss-line-#{$i} {
+    @include ellipsis($i);
+  }
+}
+
+/* ==================
+    hover
+ ==================== */
+.ss-hover-class {
+  background-color: $gray-c;
+  opacity: 0.6;
+}
+.ss-hover-btn {
+  transform: translate(1px, 1px);
+}
+
+/* ==================
+    底部安全区域
+ ==================== */
+
+.ss-safe-bottom {
+  padding-bottom: 0;
+  padding-bottom: calc(constant(safe-area-inset-bottom) / 5 * 3);
+  padding-bottom: calc(env(safe-area-inset-bottom) / 5 * 3);
+}
+
+/* ==================
+
+    字体大小
+	
+ ==================== */
+
+@for $i from 20 through 50 {
+  .ss-font-#{$i} {
+    font-size: $i + rpx;
+  }
+}
+
+/* ==================
+    按钮
+ ==================== */
+.ss-reset-button {
+  padding: 0;
+  margin: 0;
+  font-size: inherit;
+  background-color: transparent;
+  color: inherit;
+  position: relative;
+  border: 0rpx;
+  /* #ifndef APP-NVUE */
+  display: flex;
+  /* #endif */
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+  text-align: center;
+  text-decoration: none;
+  white-space: nowrap;
+  vertical-align: baseline;
+  transform: translate(0, 0);
+}
+.ss-reset-button.button-hover {
+  transform: translate(1px, 1px);
+  background: none;
+}
+
+.ss-reset-button::after {
+  border: none;
+}

+ 163 - 0
common/scss/_var.scss

@@ -0,0 +1,163 @@
+@import './mixins';
+
+//颜色 ,渐变背景60%
+$red: #d10019;      // 中国红
+$orange: #f37b1d;   // 桔橙
+$gold: #fbbd08;     // 明黄
+$green: #8dc63f;    // 橄榄绿
+$cyan: #1cbbb4;     // 天青
+$blue: #0081ff;     // 海蓝
+$purple: #6739b6;   // 姹紫
+$brightRed: #e54d42;  // 嫣红
+$forestGreen: #39b54a; // 森绿
+$mauve: #9c26b0;    // 木槿
+$pink: #e03997;     // 桃粉
+$brown: #a5673f;    // 棕褐
+$grey: #8799a3;     // 玄灰
+$gray: #aaaaaa;     // 草灰
+$black: #333333;    // 墨黑
+
+$colors: ();
+$colors: map-merge(
+  (
+    'red':$red,
+    'orange':$orange,
+    'gold':$gold,
+    'green':$green,
+    'cyan':$cyan,
+    'blue':$blue,
+    'purple':$purple,
+    'brightRed':$brightRed,
+    'forestGreen':$forestGreen,
+    'mauve':$mauve,
+    'pink':$pink,
+    'brown':$brown,
+    'grey':$grey,
+    'gray':$gray,
+    'black':$black,
+  ),
+  $colors
+);
+
+//灰度
+$bg-page: #f6f6f6;
+$white: #ffffff;
+$gray-f: #f8f9fa;
+$gray-e: #eeeeee;
+$gray-d: #dddddd;
+$gray-c: #cccccc;
+$gray-b: #bbbbbb;
+$gray-a: #aaaaaa;
+$dark-9: #999999;
+$dark-8: #888888;
+$dark-7: #777777;
+$dark-6: #666666;
+$dark-5: #555555;
+$dark-4: #484848; //ss-黑
+$dark-3: #333333;
+$dark-2: #222222;
+$dark-1: #111111;
+$black: #000000;
+
+$grays: ();
+$grays: map-merge(
+  (
+    'white': $white,
+    'gray-f': $gray-f,
+    'gray-e': $gray-e,
+    'gray-d': $gray-d,
+    'gray-c': $gray-c,
+    'gray-b': $gray-b,
+    'gray-a': $gray-a,
+    'gray': $gray-a,
+  ),
+  $grays
+);
+
+$darks: ();
+$darks: map-merge(
+  (
+    'dark-9': $dark-9,
+    'dark-8': $dark-8,
+    'dark-7': $dark-7,
+    'dark-6': $dark-6,
+    'dark-5': $dark-5,
+    'dark-4': $dark-4,
+    'dark-3': $dark-3,
+    'dark-2': $dark-2,
+    'dark-1': $dark-1,
+    'black': $black,
+  ),
+  $darks
+);
+
+// 边框
+$border-width: 1rpx !default; // 边框大小
+$border-color: $gray-d !default; // 边框颜色
+
+// 圆角
+$radius: 10rpx !default; // 默认圆角大小
+$radius-lg: 40rpx !default; // 大圆角
+$radius-sm: 6rpx !default; // 小圆角
+$round-pill: 1000rpx !default; // 半圆
+
+// 动画过渡
+$transition-base: all 0.2s ease-in-out !default; // 默认过渡
+$transition-base-out: all 0.04s ease-in-out !default; // 进场过渡
+$transition-fade: opacity 0.15s linear !default; // 透明过渡
+$transition-collapse: height 0.35s ease !default; // 收缩过渡
+
+// 间距
+$spacer: 20rpx !default;
+$spacers: () !default;
+$spacers: map-merge(
+  (
+    0: 0,
+    1: $spacer * 0.25,
+    2: $spacer * 0.5,
+    3: $spacer,
+    4: $spacer * 1.5,
+    5: $spacer * 3,
+    6: $spacer * 5,
+  ),
+  $spacers
+);
+// 字形
+$font-weight-lighter: lighter !default;
+$font-weight-light: 300 !default;
+$font-weight-normal: 400 !default;
+$font-weight-bold: 700 !default;
+$font-weight-bolder: 900 !default;
+$fontsize: () !default;
+$fontsize: map-merge(
+  (
+    xs: 20,
+    sm: 24,
+    df: 28,
+    lg: 32,
+    xl: 36,
+    xxl: 44,
+    sl: 80,
+    xsl: 120,
+  ),
+  $fontsize
+);
+// 段落
+$line-height-base: 1.5 !default;
+$line-height-lg: 2 !default;
+$line-height-sm: 1.25 !default;
+// 图标
+$iconsize: () !default;
+$iconsize: map-merge(
+  (
+    xs: 0.5,
+    sm: 0.75,
+    df: 1,
+    lg: 1.25,
+    xl: 1.5,
+    xxl: 2,
+    sl: 6,
+    xsl: 10,
+  ),
+  $iconsize
+);

BIN
common/scss/font/OPPOSANS-M-subfont.ttf


Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott