Browse Source

Merge remote-tracking branch 'yudao/master'

dhb52 1 year ago
parent
commit
c2038b95bf
100 changed files with 5919 additions and 99 deletions
  1. 3 2
      .eslintrc.js
  2. 6 6
      README.md
  3. 1 8
      build/vite/index.ts
  4. 28 28
      package.json
  5. 65 0
      src/api/crm/contact/index.ts
  6. 16 8
      src/api/crm/customer/index.ts
  7. 35 0
      src/api/crm/customerLimitConfig/index.ts
  8. 19 0
      src/api/crm/customerPoolConf/index.ts
  9. 48 0
      src/api/crm/permission/index.ts
  10. 43 0
      src/api/crm/product/index.ts
  11. 33 0
      src/api/crm/productCategory/index.ts
  12. 1 1
      src/api/crm/receivablePlan/index.ts
  13. 5 5
      src/api/infra/codegen/index.ts
  14. 40 0
      src/api/infra/demo/demo01/index.ts
  15. 37 0
      src/api/infra/demo/demo02/index.ts
  16. 91 0
      src/api/infra/demo/demo03/erp/index.ts
  17. 53 0
      src/api/infra/demo/demo03/inner/index.ts
  18. 53 0
      src/api/infra/demo/demo03/normal/index.ts
  19. 5 0
      src/api/login/index.ts
  20. 12 0
      src/api/mall/product/favorite.ts
  21. 1 1
      src/api/mall/promotion/combination/combinationActivity.ts
  22. 45 0
      src/api/mall/promotion/diy/page.ts
  23. 58 0
      src/api/mall/promotion/diy/template.ts
  24. 25 0
      src/api/pay/demo/transfer/index.ts
  25. 27 0
      src/api/pay/transfer/index.ts
  26. 0 2
      src/api/system/sms/smsLog/index.ts
  27. 5 0
      src/api/system/user/index.ts
  28. BIN
      src/assets/imgs/diy/statusBar.png
  29. 0 0
      src/assets/map/json/china.json
  30. 34 0
      src/components/ColorInput/index.vue
  31. 235 0
      src/components/DiyEditor/components/ComponentContainer.vue
  32. 164 0
      src/components/DiyEditor/components/ComponentContainerProperty.vue
  33. 205 0
      src/components/DiyEditor/components/ComponentLibrary.vue
  34. 50 0
      src/components/DiyEditor/components/mobile/Carousel/config.ts
  35. 43 0
      src/components/DiyEditor/components/mobile/Carousel/index.vue
  36. 142 0
      src/components/DiyEditor/components/mobile/Carousel/property.vue
  37. 29 0
      src/components/DiyEditor/components/mobile/Divider/config.ts
  38. 29 0
      src/components/DiyEditor/components/mobile/Divider/index.vue
  39. 80 0
      src/components/DiyEditor/components/mobile/Divider/property.vue
  40. 27 0
      src/components/DiyEditor/components/mobile/ImageBar/config.ts
  41. 24 0
      src/components/DiyEditor/components/mobile/ImageBar/index.vue
  42. 34 0
      src/components/DiyEditor/components/mobile/ImageBar/property.vue
  43. 48 0
      src/components/DiyEditor/components/mobile/MagicCube/config.ts
  44. 73 0
      src/components/DiyEditor/components/mobile/MagicCube/index.vue
  45. 76 0
      src/components/DiyEditor/components/mobile/MagicCube/property.vue
  46. 78 0
      src/components/DiyEditor/components/mobile/MenuGrid/config.ts
  47. 35 0
      src/components/DiyEditor/components/mobile/MenuGrid/index.vue
  48. 96 0
      src/components/DiyEditor/components/mobile/MenuGrid/property.vue
  49. 47 0
      src/components/DiyEditor/components/mobile/MenuList/config.ts
  50. 31 0
      src/components/DiyEditor/components/mobile/MenuList/index.vue
  51. 75 0
      src/components/DiyEditor/components/mobile/MenuList/property.vue
  52. 66 0
      src/components/DiyEditor/components/mobile/MenuSwiper/config.ts
  53. 119 0
      src/components/DiyEditor/components/mobile/MenuSwiper/index.vue
  54. 106 0
      src/components/DiyEditor/components/mobile/MenuSwiper/property.vue
  55. 38 0
      src/components/DiyEditor/components/mobile/NavigationBar/config.ts
  56. 62 0
      src/components/DiyEditor/components/mobile/NavigationBar/index.vue
  57. 63 0
      src/components/DiyEditor/components/mobile/NavigationBar/property.vue
  58. 39 0
      src/components/DiyEditor/components/mobile/NoticeBar/config.ts
  59. 26 0
      src/components/DiyEditor/components/mobile/NoticeBar/index.vue
  60. 77 0
      src/components/DiyEditor/components/mobile/NoticeBar/property.vue
  61. 23 0
      src/components/DiyEditor/components/mobile/PageConfig/config.ts
  62. 34 0
      src/components/DiyEditor/components/mobile/PageConfig/property.vue
  63. 97 0
      src/components/DiyEditor/components/mobile/ProductCard/config.ts
  64. 165 0
      src/components/DiyEditor/components/mobile/ProductCard/index.vue
  65. 149 0
      src/components/DiyEditor/components/mobile/ProductCard/property.vue
  66. 43 0
      src/components/DiyEditor/components/mobile/SearchBar/config.ts
  67. 75 0
      src/components/DiyEditor/components/mobile/SearchBar/index.vue
  68. 99 0
      src/components/DiyEditor/components/mobile/SearchBar/property.vue
  69. 97 0
      src/components/DiyEditor/components/mobile/TabBar/config.ts
  70. 59 0
      src/components/DiyEditor/components/mobile/TabBar/index.vue
  71. 145 0
      src/components/DiyEditor/components/mobile/TabBar/property.vue
  72. 65 0
      src/components/DiyEditor/components/mobile/TitleBar/config.ts
  73. 80 0
      src/components/DiyEditor/components/mobile/TitleBar/index.vue
  74. 115 0
      src/components/DiyEditor/components/mobile/TitleBar/property.vue
  75. 37 0
      src/components/DiyEditor/components/mobile/VideoPlayer/config.ts
  76. 30 0
      src/components/DiyEditor/components/mobile/VideoPlayer/index.vue
  77. 55 0
      src/components/DiyEditor/components/mobile/VideoPlayer/property.vue
  78. 61 0
      src/components/DiyEditor/components/mobile/index.ts
  79. 470 0
      src/components/DiyEditor/index.vue
  80. 117 0
      src/components/DiyEditor/util.ts
  81. 59 0
      src/components/InputWithColor/index.vue
  82. 270 0
      src/components/MagicCubeEditor/index.vue
  83. 72 0
      src/components/MagicCubeEditor/util.ts
  84. 41 16
      src/components/UploadFile/src/UploadFile.vue
  85. 9 5
      src/components/UploadFile/src/UploadImg.vue
  86. 1 1
      src/components/Verifition/src/Verify/VerifySlide.vue
  87. 44 0
      src/components/VerticalButtonGroup/index.vue
  88. 4 0
      src/config/axios/service.ts
  89. 1 1
      src/layout/components/Footer/src/Footer.vue
  90. 1 1
      src/layout/components/Message/src/Message.vue
  91. 56 0
      src/router/modules/remaining.ts
  92. 21 0
      src/utils/color.ts
  93. 4 1
      src/utils/dict.ts
  94. 23 8
      src/views/Login/components/LoginForm.vue
  95. 0 1
      src/views/Profile/components/UserSocial.vue
  96. 2 3
      src/views/crm/clue/ClueForm.vue
  97. 1 1
      src/views/crm/clue/index.vue
  98. 115 0
      src/views/crm/components/CrmPermissionForm.vue
  99. 156 0
      src/views/crm/components/CrmTeamList.vue
  100. 17 0
      src/views/crm/components/index.ts

+ 3 - 2
.eslintrc.js

@@ -21,7 +21,7 @@ module.exports = defineConfig({
     'plugin:vue/vue3-recommended',
     'plugin:@typescript-eslint/recommended',
     'prettier',
-    'plugin:prettier/recommended', 
+    'plugin:prettier/recommended',
     '@unocss'
   ],
   rules: {
@@ -67,6 +67,7 @@ module.exports = defineConfig({
       }
     ],
     'vue/multi-word-component-names': 'off',
-    'vue/no-v-html': 'off'
+    'vue/no-v-html': 'off',
+    'prettier/prettier': 'off' // 芋艿:默认关闭 prettier 的 ESLint 校验,因为我们使用的是 IDE 的 Prettier 插件
   }
 })

+ 6 - 6
README.md

@@ -38,15 +38,15 @@
 
 | 框架                                                                   | 说明               | 版本     |
 |----------------------------------------------------------------------|------------------|--------|
-| [Vue](https://staging-cn.vuejs.org/)                                 | Vue 框架           | 3.3.4 |
-| [Vite](https://cn.vitejs.dev//)                                      | 开发与构建工具          | 4.4.11  |
-| [Element Plus](https://element-plus.org/zh-CN/)                      | Element Plus     | 2.4.0 |
+| [Vue](https://staging-cn.vuejs.org/)                                 | Vue 框架           | 3.3.8 |
+| [Vite](https://cn.vitejs.dev//)                                      | 开发与构建工具          | 4.5.0  |
+| [Element Plus](https://element-plus.org/zh-CN/)                      | Element Plus     | 2.4.2 |
 | [TypeScript](https://www.typescriptlang.org/docs/)                   | JavaScript 的超集   | 5.2.2  |
 | [pinia](https://pinia.vuejs.org/)                                    | Vue 存储库 替代 vuex5 | 2.1.7 |
-| [vueuse](https://vueuse.org/)                                        | 常用工具集            | 10.5.0 |
-| [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化              | 9.5.0  |
+| [vueuse](https://vueuse.org/)                                        | 常用工具集            | 10.6.1 |
+| [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化              | 9.6.5  |
 | [vue-router](https://router.vuejs.org/)                              | Vue 路由           | 4.2.5  |
-| [unocss](https://uno.antfu.me/)                                      | 原子 css          | 0.56.5  |
+| [unocss](https://uno.antfu.me/)                                      | 原子 css          | 0.57.4  |
 | [iconify](https://icon-sets.iconify.design/)                         | 在线图标库            | 3.1.1  |
 | [wangeditor](https://www.wangeditor.com/)                            | 富文本编辑器           | 5.1.23 |
 

+ 1 - 8
build/vite/index.ts

@@ -60,18 +60,11 @@ export function createVitePlugins() {
       }
     }),
     Components({
-      // 要搜索组件的目录的相对路径
-      dirs: ['src/components'],
-      // 组件的有效文件扩展名
-      extensions: ['vue', 'md'],
-      // 搜索子目录
-      deep: true,
-      include: [/\.vue$/, /\.vue\?vue/],
       // 生成自定义 `auto-components.d.ts` 全局声明
       dts: 'src/types/auto-components.d.ts',
       // 自定义组件的解析器
       resolvers: [ElementPlusResolver()],
-      exclude: [/[\\/]node_modules[\\/]/]
+      globs: ["src/components/**/**.{vue, md}", '!src/components/DiyEditor/components/mobile/**']
     }),
     EslintPlugin({
       cache: false,

+ 28 - 28
package.json

@@ -31,23 +31,23 @@
     "@form-create/element-ui": "^3.1.24",
     "@iconify/iconify": "^3.1.1",
     "@videojs-player/vue": "^1.0.0",
-    "@vueuse/core": "^10.5.0",
+    "@vueuse/core": "^10.6.1",
     "@wangeditor/editor": "^5.1.23",
     "@wangeditor/editor-for-vue": "^5.1.10",
     "@zxcvbn-ts/core": "^3.0.4",
     "animate.css": "^4.1.1",
-    "axios": "^1.6.0",
+    "axios": "^1.6.1",
     "benz-amr-recorder": "^1.1.5",
     "bpmn-js-token-simulation": "^0.10.0",
     "camunda-bpmn-moddle": "^7.0.1",
     "cropperjs": "^1.6.1",
     "crypto-js": "^4.2.0",
     "dayjs": "^1.11.10",
-    "diagram-js": "^12.6.0",
-    "driver.js": "^1.3.0",
+    "diagram-js": "^12.8.0",
+    "driver.js": "^1.3.1",
     "echarts": "^5.4.3",
     "echarts-wordcloud": "^2.1.0",
-    "element-plus": "2.4.1",
+    "element-plus": "2.4.2",
     "fast-xml-parser": "^4.3.2",
     "highlight.js": "^11.9.0",
     "jsencrypt": "^3.3.2",
@@ -62,9 +62,9 @@
     "steady-xml": "^0.1.0",
     "url": "^0.11.3",
     "video.js": "^7.21.5",
-    "vue": "^3.3.7",
+    "vue": "^3.3.8",
     "vue-dompurify-html": "^4.1.4",
-    "vue-i18n": "^9.6.2",
+    "vue-i18n": "^9.6.5",
     "vue-router": "^4.2.5",
     "vue-types": "^5.1.1",
     "vuedraggable": "^4.1.0",
@@ -72,49 +72,49 @@
     "xml-js": "^1.6.11"
   },
   "devDependencies": {
-    "@commitlint/cli": "^18.2.0",
-    "@commitlint/config-conventional": "^18.1.0",
-    "@iconify/json": "^2.2.135",
-    "@intlify/unplugin-vue-i18n": "^1.4.0",
+    "@commitlint/cli": "^18.4.1",
+    "@commitlint/config-conventional": "^18.4.0",
+    "@iconify/json": "^2.2.142",
+    "@intlify/unplugin-vue-i18n": "^1.5.0",
     "@purge-icons/generated": "^0.9.0",
-    "@types/lodash-es": "^4.17.10",
-    "@types/node": "^20.8.9",
-    "@types/nprogress": "^0.2.2",
-    "@types/qrcode": "^1.5.4",
-    "@types/qs": "^6.9.9",
-    "@types/sortablejs": "^1.15.4",
-    "@typescript-eslint/eslint-plugin": "^6.9.1",
-    "@typescript-eslint/parser": "^6.9.1",
-    "@unocss/transformer-variant-group": "^0.57.1",
-    "@unocss/eslint-config": "^0.57.1",
+    "@types/lodash-es": "^4.17.11",
+    "@types/node": "^20.9.0",
+    "@types/nprogress": "^0.2.3",
+    "@types/qrcode": "^1.5.5",
+    "@types/qs": "^6.9.10",
+    "@types/sortablejs": "^1.15.5",
+    "@typescript-eslint/eslint-plugin": "^6.11.0",
+    "@typescript-eslint/parser": "^6.11.0",
+    "@unocss/transformer-variant-group": "^0.57.4",
+    "@unocss/eslint-config": "^0.57.4",
     "@vitejs/plugin-legacy": "^4.1.1",
-    "@vitejs/plugin-vue": "^4.4.0",
+    "@vitejs/plugin-vue": "^4.4.1",
     "@vitejs/plugin-vue-jsx": "^3.0.2",
     "autoprefixer": "^10.4.16",
     "bpmn-js": "8.9.0",
     "bpmn-js-properties-panel": "0.46.0",
     "consola": "^3.2.3",
-    "eslint": "^8.52.0",
+    "eslint": "^8.53.0",
     "eslint-config-prettier": "^9.0.0",
     "eslint-define-config": "^1.24.1",
     "eslint-plugin-prettier": "^5.0.1",
     "eslint-plugin-vue": "^9.18.1",
-    "lint-staged": "^15.0.2",
+    "lint-staged": "^15.1.0",
     "postcss": "^8.4.31",
     "postcss-html": "^1.5.0",
     "postcss-scss": "^4.0.9",
-    "prettier": "^3.0.3",
+    "prettier": "^3.1.0",
     "rimraf": "^5.0.5",
-    "rollup": "^4.1.5",
+    "rollup": "^4.4.1",
     "sass": "^1.69.5",
     "stylelint": "^15.11.0",
     "stylelint-config-html": "^1.1.0",
     "stylelint-config-recommended": "^13.0.0",
     "stylelint-config-standard": "^34.0.0",
     "stylelint-order": "^6.0.3",
-    "terser": "^5.23.0",
+    "terser": "^5.24.0",
     "typescript": "5.2.2",
-    "unocss": "^0.57.1",
+    "unocss": "^0.57.4",
     "unplugin-auto-import": "^0.16.7",
     "unplugin-element-plus": "^0.8.0",
     "unplugin-vue-components": "^0.25.2",

+ 65 - 0
src/api/crm/contact/index.ts

@@ -0,0 +1,65 @@
+/*
+ * @Author: zyna
+ * @Date: 2023-11-05 13:34:41
+ * @LastEditTime: 2023-11-11 16:20:19
+ * @FilePath: \yudao-ui-admin-vue3\src\api\crm\contact\index.ts
+ * @Description:
+ */
+import request from '@/config/axios'
+
+export interface ContactVO {
+  name: string
+  nextTime: Date
+  mobile: string
+  telephone: string
+  email: string
+  post: string
+  customerId: number
+  address: string
+  remark: string
+  ownerUserId: string
+  lastTime: Date
+  id: number
+  parentId: number
+  qq: number
+  webchat: string
+  sex: number
+  policyMakers: boolean
+  creatorName: string
+  updateTime?: Date
+  createTime?: Date
+  customerName: string
+}
+
+// 查询crm联系人列表
+export const getContactPage = async (params) => {
+  return await request.get({ url: `/crm/contact/page`, params })
+}
+
+// 查询crm联系人详情
+export const getContact = async (id: number) => {
+  return await request.get({ url: `/crm/contact/get?id=` + id })
+}
+
+// 新增crm联系人
+export const createContact = async (data: ContactVO) => {
+  return await request.post({ url: `/crm/contact/create`, data })
+}
+
+// 修改crm联系人
+export const updateContact = async (data: ContactVO) => {
+  return await request.put({ url: `/crm/contact/update`, data })
+}
+
+// 删除crm联系人
+export const deleteContact = async (id: number) => {
+  return await request.delete({ url: `/crm/contact/delete?id=` + id })
+}
+
+// 导出crm联系人 Excel
+export const exportContact = async (params) => {
+  return await request.download({ url: `/crm/contact/export-excel`, params })
+}
+export const simpleAlllist = async () => {
+  return await request.get({ url: `/crm/contact/simpleAlllist` })
+}

+ 16 - 8
src/api/crm/customer/index.ts

@@ -1,13 +1,14 @@
 import request from '@/config/axios'
 
 export interface CustomerVO {
-  id: number
+  id?: number
   name: string
   industryId: number
   level: number
   source: number
-  followUpStatus: boolean
-  lockStatus: boolean
+  followUpStatus?: boolean
+  lockStatus?: boolean
+  dealStatus?: boolean
   mobile: string
   telephone: string
   website: string
@@ -16,13 +17,20 @@ export interface CustomerVO {
   email: string
   description: string
   remark: string
-  ownerUserId: number
-  roUserIds: string
-  rwUserIds: string
-  areaId: number
+  ownerUserId?: number
+  ownerUserName?: string
+  ownerUserDept?: string
+  roUserIds?: string
+  rwUserIds?: string
+  areaId?: number
+  areaName?: string
   detailAddress: string
-  contactLastTime: Date
+  contactLastTime?: Date
   contactNextTime: Date
+  createTime?: Date
+  updateTime?: Date
+  creator?: string
+  creatorName?: string
 }
 
 // 查询客户列表

+ 35 - 0
src/api/crm/customerLimitConfig/index.ts

@@ -0,0 +1,35 @@
+import request from '@/config/axios'
+
+export interface CustomerLimitConfigVO {
+  id?: number
+  type?: number
+  userIds?: string
+  deptIds?: string
+  maxCount?: number
+  dealCountEnabled?: boolean
+}
+
+// 查询客户限制配置列表
+export const getCustomerLimitConfigPage = async (params) => {
+  return await request.get({ url: `/crm/customer-limit-config/page`, params })
+}
+
+// 查询客户限制配置详情
+export const getCustomerLimitConfig = async (id: number) => {
+  return await request.get({ url: `/crm/customer-limit-config/get?id=` + id })
+}
+
+// 新增客户限制配置
+export const createCustomerLimitConfig = async (data: CustomerLimitConfigVO) => {
+  return await request.post({ url: `/crm/customer-limit-config/create`, data })
+}
+
+// 修改客户限制配置
+export const updateCustomerLimitConfig = async (data: CustomerLimitConfigVO) => {
+  return await request.put({ url: `/crm/customer-limit-config/update`, data })
+}
+
+// 删除客户限制配置
+export const deleteCustomerLimitConfig = async (id: number) => {
+  return await request.delete({ url: `/crm/customer-limit-config/delete?id=` + id })
+}

+ 19 - 0
src/api/crm/customerPoolConf/index.ts

@@ -0,0 +1,19 @@
+import request from '@/config/axios'
+
+export interface CustomerPoolConfigVO {
+  enabled?: boolean
+  contactExpireDays?: number
+  dealExpireDays?: number
+  notifyEnabled?: boolean
+  notifyDays: number
+}
+
+// 获取客户公海规则设置
+export const getCustomerPoolConfig = async () => {
+  return await request.get({ url: `/crm/customer-pool-config/get` })
+}
+
+// 更新客户公海规则设置
+export const updateCustomerPoolConfig = async (data: ConfigVO) => {
+  return await request.put({ url: `/crm/customer-pool-config/update`, data })
+}

+ 48 - 0
src/api/crm/permission/index.ts

@@ -0,0 +1,48 @@
+import request from '@/config/axios'
+
+export interface PermissionVO {
+  id?: number // 数据权限编号
+  userId: number | undefined // 用户编号
+  bizType: number | undefined // Crm 类型
+  bizId: number | undefined // Crm 类型数据编号
+  level: number | undefined // 权限级别
+  deptName?: string // 部门名称 // 岗位名称数组 TODO @puhui999:数组?
+  nickname?: string // 用户昵称
+  postNames?: string // 岗位名称数组 TODO @puhui999:数组?
+  createTime?: Date
+}
+
+// 查询团队成员列表
+export const getPermissionList = async (params) => {
+  return await request.get({ url: `/crm/permission/list`, params })
+}
+
+// 新增团队成员
+export const createPermission = async (data: PermissionVO) => {
+  return await request.post({ url: `/crm/permission/add`, data })
+}
+
+// 修改团队成员权限级别
+export const updatePermission = async (data) => {
+  return await request.put({ url: `/crm/permission/update`, data })
+}
+
+// 删除团队成员
+export const deletePermission = async (params) => {
+  return await request.delete({ url: '/crm/permission/delete', params })
+}
+
+// 退出团队
+export const quitTeam = async (id) => {
+  return await request.delete({ url: '/crm/permission/quit-team?id=' + id })
+}
+
+// 领取公海数据
+export const receive = async (data: { bizType: number; bizId: number }) => {
+  return await request.put({ url: `/crm/permission/receive`, data })
+}
+
+// 数据放入公海
+export const putPool = async (data: { bizType: number; bizId: number }) => {
+  return await request.put({ url: `/crm/permission/put-pool`, data })
+}

+ 43 - 0
src/api/crm/product/index.ts

@@ -0,0 +1,43 @@
+import request from '@/config/axios'
+
+export interface ProductVO {
+  id: number
+  name: string
+  no: string
+  unit: string
+  price: number
+  status: number
+  categoryId: number
+  description: string
+  ownerUserId: number
+}
+
+// 查询产品列表
+export const getProductPage = async (params) => {
+  return await request.get({ url: `/crm/product/page`, params })
+}
+
+// 查询产品详情
+export const getProduct = async (id: number) => {
+  return await request.get({ url: `/crm/product/get?id=` + id })
+}
+
+// 新增产品
+export const createProduct = async (data: ProductVO) => {
+  return await request.post({ url: `/crm/product/create`, data })
+}
+
+// 修改产品
+export const updateProduct = async (data: ProductVO) => {
+  return await request.put({ url: `/crm/product/update`, data })
+}
+
+// 删除产品
+export const deleteProduct = async (id: number) => {
+  return await request.delete({ url: `/crm/product/delete?id=` + id })
+}
+
+// 导出产品 Excel
+export const exportProduct = async (params) => {
+  return await request.download({ url: `/crm/product/export-excel`, params })
+}

+ 33 - 0
src/api/crm/productCategory/index.ts

@@ -0,0 +1,33 @@
+import request from '@/config/axios'
+
+// TODO @zange:挪到 product 下,建个 category 包,挪进去哈;
+export interface ProductCategoryVO {
+  id: number
+  name: string
+  parentId: number
+}
+
+// 查询产品分类详情
+export const getProductCategory = async (id: number) => {
+  return await request.get({ url: `/crm/product-category/get?id=` + id })
+}
+
+// 新增产品分类
+export const createProductCategory = async (data: ProductCategoryVO) => {
+  return await request.post({ url: `/crm/product-category/create`, data })
+}
+
+// 修改产品分类
+export const updateProductCategory = async (data: ProductCategoryVO) => {
+  return await request.put({ url: `/crm/product-category/update`, data })
+}
+
+// 删除产品分类
+export const deleteProductCategory = async (id: number) => {
+  return await request.delete({ url: `/crm/product-category/delete?id=` + id })
+}
+
+// 产品分类列表
+export const getProductCategoryList = async (params) => {
+  return await request.get({ url: `/crm/product-category/list`, params })
+}

+ 1 - 1
src/api/crm/receivablePlan/index.ts

@@ -2,7 +2,7 @@ import request from '@/config/axios'
 
 export interface ReceivablePlanVO {
   id: number
-  indexNo: number
+  period: number
   receivableId: number
   status: number
   checkStatus: string

+ 5 - 5
src/api/infra/codegen/index.ts

@@ -67,6 +67,11 @@ export type CodegenCreateListReqVO = {
   tableNames: string[]
 }
 
+// 查询列表代码生成表定义
+export const getCodegenTableList = (dataSourceConfigId: number) => {
+  return request.get({ url: '/infra/codegen/table/list?dataSourceConfigId=' + dataSourceConfigId })
+}
+
 // 查询列表代码生成表定义
 export const getCodegenTablePage = (params: PageParam) => {
   return request.get({ url: '/infra/codegen/table/page', params })
@@ -92,11 +97,6 @@ export const syncCodegenFromDB = (id: number) => {
   return request.put({ url: '/infra/codegen/sync-from-db?tableId=' + id })
 }
 
-// 基于 SQL 建表语句,同步数据库的表和字段定义
-export const syncCodegenFromSQL = (id: number, sql: string) => {
-  return request.put({ url: '/infra/codegen/sync-from-sql?tableId=' + id + '&sql=' + sql })
-}
-
 // 预览生成代码
 export const previewCodegen = (id: number) => {
   return request.get({ url: '/infra/codegen/preview?tableId=' + id })

+ 40 - 0
src/api/infra/demo/demo01/index.ts

@@ -0,0 +1,40 @@
+import request from '@/config/axios'
+
+export interface Demo01ContactVO {
+  id: number
+  name: string
+  sex: number
+  birthday: Date
+  description: string
+  avatar: string
+}
+
+// 查询示例联系人分页
+export const getDemo01ContactPage = async (params) => {
+  return await request.get({ url: `/infra/demo01-contact/page`, params })
+}
+
+// 查询示例联系人详情
+export const getDemo01Contact = async (id: number) => {
+  return await request.get({ url: `/infra/demo01-contact/get?id=` + id })
+}
+
+// 新增示例联系人
+export const createDemo01Contact = async (data: Demo01ContactVO) => {
+  return await request.post({ url: `/infra/demo01-contact/create`, data })
+}
+
+// 修改示例联系人
+export const updateDemo01Contact = async (data: Demo01ContactVO) => {
+  return await request.put({ url: `/infra/demo01-contact/update`, data })
+}
+
+// 删除示例联系人
+export const deleteDemo01Contact = async (id: number) => {
+  return await request.delete({ url: `/infra/demo01-contact/delete?id=` + id })
+}
+
+// 导出示例联系人 Excel
+export const exportDemo01Contact = async (params) => {
+  return await request.download({ url: `/infra/demo01-contact/export-excel`, params })
+}

+ 37 - 0
src/api/infra/demo/demo02/index.ts

@@ -0,0 +1,37 @@
+import request from '@/config/axios'
+
+export interface Demo02CategoryVO {
+  id: number
+  name: string
+  parentId: number
+}
+
+// 查询示例分类列表
+export const getDemo02CategoryList = async (params) => {
+  return await request.get({ url: `/infra/demo02-category/list`, params })
+}
+
+// 查询示例分类详情
+export const getDemo02Category = async (id: number) => {
+  return await request.get({ url: `/infra/demo02-category/get?id=` + id })
+}
+
+// 新增示例分类
+export const createDemo02Category = async (data: Demo02CategoryVO) => {
+  return await request.post({ url: `/infra/demo02-category/create`, data })
+}
+
+// 修改示例分类
+export const updateDemo02Category = async (data: Demo02CategoryVO) => {
+  return await request.put({ url: `/infra/demo02-category/update`, data })
+}
+
+// 删除示例分类
+export const deleteDemo02Category = async (id: number) => {
+  return await request.delete({ url: `/infra/demo02-category/delete?id=` + id })
+}
+
+// 导出示例分类 Excel
+export const exportDemo02Category = async (params) => {
+  return await request.download({ url: `/infra/demo02-category/export-excel`, params })
+}

+ 91 - 0
src/api/infra/demo/demo03/erp/index.ts

@@ -0,0 +1,91 @@
+import request from '@/config/axios'
+
+export interface Demo03StudentVO {
+  id: number
+  name: string
+  sex: number
+  birthday: Date
+  description: string
+}
+
+// 查询学生分页
+export const getDemo03StudentPage = async (params) => {
+  return await request.get({ url: `/infra/demo03-student/page`, params })
+}
+
+// 查询学生详情
+export const getDemo03Student = async (id: number) => {
+  return await request.get({ url: `/infra/demo03-student/get?id=` + id })
+}
+
+// 新增学生
+export const createDemo03Student = async (data: Demo03StudentVO) => {
+  return await request.post({ url: `/infra/demo03-student/create`, data })
+}
+
+// 修改学生
+export const updateDemo03Student = async (data: Demo03StudentVO) => {
+  return await request.put({ url: `/infra/demo03-student/update`, data })
+}
+
+// 删除学生
+export const deleteDemo03Student = async (id: number) => {
+  return await request.delete({ url: `/infra/demo03-student/delete?id=` + id })
+}
+
+// 导出学生 Excel
+export const exportDemo03Student = async (params) => {
+  return await request.download({ url: `/infra/demo03-student/export-excel`, params })
+}
+
+// ==================== 子表(学生课程) ====================
+
+// 获得学生课程分页
+export const getDemo03CoursePage = async (params) => {
+  return await request.get({ url: `/infra/demo03-student/demo03-course/page`, params })
+}
+// 新增学生课程
+export const createDemo03Course = async (data) => {
+  return await request.post({ url: `/infra/demo03-student/demo03-course/create`, data })
+}
+
+// 修改学生课程
+export const updateDemo03Course = async (data) => {
+  return await request.put({ url: `/infra/demo03-student/demo03-course/update`, data })
+}
+
+// 删除学生课程
+export const deleteDemo03Course = async (id: number) => {
+  return await request.delete({ url: `/infra/demo03-student/demo03-course/delete?id=` + id })
+}
+
+// 获得学生课程
+export const getDemo03Course = async (id: number) => {
+  return await request.get({ url: `/infra/demo03-student/demo03-course/get?id=` + id })
+}
+
+// ==================== 子表(学生班级) ====================
+
+// 获得学生班级分页
+export const getDemo03GradePage = async (params) => {
+  return await request.get({ url: `/infra/demo03-student/demo03-grade/page`, params })
+}
+// 新增学生班级
+export const createDemo03Grade = async (data) => {
+  return await request.post({ url: `/infra/demo03-student/demo03-grade/create`, data })
+}
+
+// 修改学生班级
+export const updateDemo03Grade = async (data) => {
+  return await request.put({ url: `/infra/demo03-student/demo03-grade/update`, data })
+}
+
+// 删除学生班级
+export const deleteDemo03Grade = async (id: number) => {
+  return await request.delete({ url: `/infra/demo03-student/demo03-grade/delete?id=` + id })
+}
+
+// 获得学生班级
+export const getDemo03Grade = async (id: number) => {
+  return await request.get({ url: `/infra/demo03-student/demo03-grade/get?id=` + id })
+}

+ 53 - 0
src/api/infra/demo/demo03/inner/index.ts

@@ -0,0 +1,53 @@
+import request from '@/config/axios'
+
+export interface Demo03StudentVO {
+  id: number
+  name: string
+  sex: number
+  birthday: Date
+  description: string
+}
+
+// 查询学生分页
+export const getDemo03StudentPage = async (params) => {
+  return await request.get({ url: `/infra/demo03-student/page`, params })
+}
+
+// 查询学生详情
+export const getDemo03Student = async (id: number) => {
+  return await request.get({ url: `/infra/demo03-student/get?id=` + id })
+}
+
+// 新增学生
+export const createDemo03Student = async (data: Demo03StudentVO) => {
+  return await request.post({ url: `/infra/demo03-student/create`, data })
+}
+
+// 修改学生
+export const updateDemo03Student = async (data: Demo03StudentVO) => {
+  return await request.put({ url: `/infra/demo03-student/update`, data })
+}
+
+// 删除学生
+export const deleteDemo03Student = async (id: number) => {
+  return await request.delete({ url: `/infra/demo03-student/delete?id=` + id })
+}
+
+// 导出学生 Excel
+export const exportDemo03Student = async (params) => {
+  return await request.download({ url: `/infra/demo03-student/export-excel`, params })
+}
+
+// ==================== 子表(学生课程) ====================
+
+// 获得学生课程列表
+export const getDemo03CourseListByStudentId = async (studentId) => {
+  return await request.get({ url: `/infra/demo03-student/demo03-course/list-by-student-id?studentId=` + studentId })
+}
+
+// ==================== 子表(学生班级) ====================
+
+// 获得学生班级
+export const getDemo03GradeByStudentId = async (studentId) => {
+  return await request.get({ url: `/infra/demo03-student/demo03-grade/get-by-student-id?studentId=` + studentId })
+}

+ 53 - 0
src/api/infra/demo/demo03/normal/index.ts

@@ -0,0 +1,53 @@
+import request from '@/config/axios'
+
+export interface Demo03StudentVO {
+  id: number
+  name: string
+  sex: number
+  birthday: Date
+  description: string
+}
+
+// 查询学生分页
+export const getDemo03StudentPage = async (params) => {
+  return await request.get({ url: `/infra/demo03-student/page`, params })
+}
+
+// 查询学生详情
+export const getDemo03Student = async (id: number) => {
+  return await request.get({ url: `/infra/demo03-student/get?id=` + id })
+}
+
+// 新增学生
+export const createDemo03Student = async (data: Demo03StudentVO) => {
+  return await request.post({ url: `/infra/demo03-student/create`, data })
+}
+
+// 修改学生
+export const updateDemo03Student = async (data: Demo03StudentVO) => {
+  return await request.put({ url: `/infra/demo03-student/update`, data })
+}
+
+// 删除学生
+export const deleteDemo03Student = async (id: number) => {
+  return await request.delete({ url: `/infra/demo03-student/delete?id=` + id })
+}
+
+// 导出学生 Excel
+export const exportDemo03Student = async (params) => {
+  return await request.download({ url: `/infra/demo03-student/export-excel`, params })
+}
+
+// ==================== 子表(学生课程) ====================
+
+// 获得学生课程列表
+export const getDemo03CourseListByStudentId = async (studentId) => {
+  return await request.get({ url: `/infra/demo03-student/demo03-course/list-by-student-id?studentId=` + studentId })
+}
+
+// ==================== 子表(学生班级) ====================
+
+// 获得学生班级
+export const getDemo03GradeByStudentId = async (studentId) => {
+  return await request.get({ url: `/infra/demo03-student/demo03-grade/get-by-student-id?studentId=` + studentId })
+}

+ 5 - 0
src/api/login/index.ts

@@ -27,6 +27,11 @@ export const getTenantIdByName = (name: string) => {
   return request.get({ url: '/system/tenant/get-id-by-name?name=' + name })
 }
 
+// 使用租户域名,获得租户信息
+export const getTenantByWebsite = (website: string) => {
+  return request.get({ url: '/system/tenant/get-by-website?website=' + website })
+}
+
 // 登出
 export const loginOut = () => {
   return request.post({ url: '/system/auth/logout' })

+ 12 - 0
src/api/mall/product/favorite.ts

@@ -0,0 +1,12 @@
+import request from '@/config/axios'
+
+export interface Favorite {
+  id?: number
+  userId?: string // 用户编号
+  spuId?: number | null // 商品 SPU 编号
+}
+
+// 获得 ProductFavorite 列表
+export const getFavoritePage = (params: PageParam) => {
+  return request.get({ url: '/product/favorite/page', params })
+}

+ 1 - 1
src/api/mall/promotion/combination/combinationActivity.ts

@@ -57,7 +57,7 @@ export const updateCombinationActivity = async (data: CombinationActivityVO) =>
 
 // 关闭拼团活动
 export const closeCombinationActivity = async (id: number) => {
-  return await request.put({ url: '/promotion/bargain-combination/close?id=' + id })
+  return await request.put({ url: '/promotion/combination-activity/close?id=' + id })
 }
 
 // 删除拼团活动

+ 45 - 0
src/api/mall/promotion/diy/page.ts

@@ -0,0 +1,45 @@
+import request from '@/config/axios'
+
+export interface DiyPageVO {
+  id?: number
+  templateId?: number
+  name: string
+  remark: string
+  previewImageUrls: string[]
+  property: string
+}
+
+// 查询装修页面列表
+export const getDiyPagePage = async (params: any) => {
+  return await request.get({ url: `/promotion/diy-page/page`, params })
+}
+
+// 查询装修页面详情
+export const getDiyPage = async (id: number) => {
+  return await request.get({ url: `/promotion/diy-page/get?id=` + id })
+}
+
+// 新增装修页面
+export const createDiyPage = async (data: DiyPageVO) => {
+  return await request.post({ url: `/promotion/diy-page/create`, data })
+}
+
+// 修改装修页面
+export const updateDiyPage = async (data: DiyPageVO) => {
+  return await request.put({ url: `/promotion/diy-page/update`, data })
+}
+
+// 删除装修页面
+export const deleteDiyPage = async (id: number) => {
+  return await request.delete({ url: `/promotion/diy-page/delete?id=` + id })
+}
+
+// 获得装修页面属性
+export const getDiyPageProperty = async (id: number) => {
+  return await request.get({ url: `/promotion/diy-page/get-property?id=` + id })
+}
+
+// 更新装修页面属性
+export const updateDiyPageProperty = async (data: DiyPageVO) => {
+  return await request.put({ url: `/promotion/diy-page/update-property`, data })
+}

+ 58 - 0
src/api/mall/promotion/diy/template.ts

@@ -0,0 +1,58 @@
+import request from '@/config/axios'
+import { DiyPageVO } from '@/api/mall/promotion/diy/page'
+
+export interface DiyTemplateVO {
+  id?: number
+  name: string
+  used: boolean
+  usedTime?: Date
+  remark: string
+  previewImageUrls: string[]
+  property: string
+}
+
+export interface DiyTemplatePropertyVO extends DiyTemplateVO {
+  pages: DiyPageVO[]
+}
+
+// 查询装修模板列表
+export const getDiyTemplatePage = async (params: any) => {
+  return await request.get({ url: `/promotion/diy-template/page`, params })
+}
+
+// 查询装修模板详情
+export const getDiyTemplate = async (id: number) => {
+  return await request.get({ url: `/promotion/diy-template/get?id=` + id })
+}
+
+// 新增装修模板
+export const createDiyTemplate = async (data: DiyTemplateVO) => {
+  return await request.post({ url: `/promotion/diy-template/create`, data })
+}
+
+// 修改装修模板
+export const updateDiyTemplate = async (data: DiyTemplateVO) => {
+  return await request.put({ url: `/promotion/diy-template/update`, data })
+}
+
+// 删除装修模板
+export const deleteDiyTemplate = async (id: number) => {
+  return await request.delete({ url: `/promotion/diy-template/delete?id=` + id })
+}
+
+// 使用装修模板
+export const useDiyTemplate = async (id: number) => {
+  return await request.put({ url: `/promotion/diy-template/use?id=` + id })
+}
+
+// 获得装修模板属性
+export const getDiyTemplateProperty = async (id: number) => {
+  return await request.get<DiyTemplatePropertyVO>({
+    url: `/promotion/diy-template/get-property?id=` + id
+  })
+}
+
+// 更新装修模板属性
+export const updateDiyTemplateProperty = async (data: DiyTemplateVO) => {
+  return await request.put({ url: `/promotion/diy-template/update-property`, data })
+}

+ 25 - 0
src/api/pay/demo/transfer/index.ts

@@ -0,0 +1,25 @@
+import request from '@/config/axios'
+
+export interface DemoTransferVO {
+  price: number
+  type: number
+  userName: string
+  alipayLogonId: string
+  openid: string
+}
+
+// 创建示例转账单
+export function createDemoTransfer(data: DemoTransferVO) {
+  return request.post({
+    url: '/pay/demo-transfer/create',
+    data: data
+  })
+}
+
+// 获得示例订单分页
+export function getDemoTransferPage(query: PageParam) {
+  return request.get({
+    url: '/pay/demo-transfer/page',
+    params: query
+  })
+}

+ 27 - 0
src/api/pay/transfer/index.ts

@@ -0,0 +1,27 @@
+import request from '@/config/axios'
+
+export interface TransferVO {
+  appId: number
+  channelCode: string
+  merchantTransferId: string
+  type: number
+  price: number
+  subject: string
+  userName: string
+  alipayLogonId: string
+  openid: string
+}
+
+// 新增转账单
+export const createTransfer = async (data: TransferVO) => {
+  return await request.post({ url: `/pay/transfer/create`, data })
+}
+
+// 查询转账单列表
+export const getTransferPage = async (params) => {
+  return await request.get({ url: `/pay/transfer/page`, params })
+}
+
+export const getTransfer = async (id: number) => {
+  return await request.get({ url: '/pay/transfer/get?id=' + id })
+}

+ 0 - 2
src/api/system/sms/smsLog/index.ts

@@ -15,8 +15,6 @@ export interface SmsLogVO {
   userType: number | null
   sendStatus: number | null
   sendTime: Date | null
-  sendCode: number | null
-  sendMsg: string
   apiSendCode: string
   apiSendMsg: string
   apiRequestId: string

+ 5 - 0
src/api/system/user/index.ts

@@ -22,6 +22,11 @@ export const getUserPage = (params: PageParam) => {
   return request.get({ url: '/system/user/page', params })
 }
 
+// 查询所有用户列表
+export const getAllUser = () => {
+  return request.get({ url: '/system/user/all' })
+}
+
 // 查询用户详情
 export const getUser = (id: number) => {
   return request.get({ url: '/system/user/get?id=' + id })

BIN
src/assets/imgs/diy/statusBar.png


File diff suppressed because it is too large
+ 0 - 0
src/assets/map/json/china.json


+ 34 - 0
src/components/ColorInput/index.vue

@@ -0,0 +1,34 @@
+<template>
+  <el-input v-model="color">
+    <template #prepend>
+      <el-color-picker v-model="color" :predefine="PREDEFINE_COLORS" />
+    </template>
+  </el-input>
+</template>
+
+<script setup lang="ts">
+import { propTypes } from '@/utils/propTypes'
+import { PREDEFINE_COLORS } from '@/utils/color'
+
+// 颜色输入框
+defineOptions({ name: 'ColorInput' })
+
+const props = defineProps({
+  modelValue: propTypes.string.def('')
+})
+const emit = defineEmits(['update:modelValue'])
+const color = computed({
+  get: () => {
+    return props.modelValue
+  },
+  set: (val: string) => {
+    emit('update:modelValue', val)
+  }
+})
+</script>
+
+<style scoped lang="scss">
+:deep(.el-input-group__prepend) {
+  padding: 0;
+}
+</style>

+ 235 - 0
src/components/DiyEditor/components/ComponentContainer.vue

@@ -0,0 +1,235 @@
+<template>
+  <div :class="['component', { active: active }]">
+    <div
+      :style="{
+        ...style
+      }"
+    >
+      <component :is="component.id" :property="component.property" />
+    </div>
+    <div class="component-wrap">
+      <!-- 左侧组件名 -->
+      <div class="component-name" v-if="component.name">
+        {{ component.name }}
+      </div>
+      <!-- 左侧:组件操作工具栏 -->
+      <div class="component-toolbar" v-if="showToolbar && component.name && active">
+        <VerticalButtonGroup type="primary">
+          <el-tooltip content="上移" placement="right">
+            <el-button :disabled="!canMoveUp" @click.stop="handleMoveComponent(-1)">
+              <Icon icon="ep:arrow-up" />
+            </el-button>
+          </el-tooltip>
+          <el-tooltip content="下移" placement="right">
+            <el-button :disabled="!canMoveDown" @click.stop="handleMoveComponent(1)">
+              <Icon icon="ep:arrow-down" />
+            </el-button>
+          </el-tooltip>
+          <el-tooltip content="复制" placement="right">
+            <el-button @click.stop="handleCopyComponent()">
+              <Icon icon="ep:copy-document" />
+            </el-button>
+          </el-tooltip>
+          <el-tooltip content="删除" placement="right">
+            <el-button @click.stop="handleDeleteComponent()">
+              <Icon icon="ep:delete" />
+            </el-button>
+          </el-tooltip>
+        </VerticalButtonGroup>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+// 注册所有的组件
+import { components } from '../components/mobile/index'
+export default {
+  components: { ...components }
+}
+</script>
+<script setup lang="ts">
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+import { propTypes } from '@/utils/propTypes'
+import { object } from 'vue-types'
+
+/**
+ * 组件容器
+ * 用于包裹组件,为组件提供 背景、外边距、内边距、边框等样式
+ */
+defineOptions({ name: 'ComponentContainer' })
+
+type DiyComponentWithStyle = DiyComponent<any> & { property: { style?: ComponentStyle } }
+const props = defineProps({
+  component: object<DiyComponentWithStyle>().isRequired,
+  active: propTypes.bool.def(false),
+  canMoveUp: propTypes.bool.def(false),
+  canMoveDown: propTypes.bool.def(false),
+  showToolbar: propTypes.bool.def(true)
+})
+
+/**
+ * 组件样式
+ */
+const style = computed(() => {
+  let componentStyle = props.component.property.style
+  if (!componentStyle) {
+    return {}
+  }
+  return {
+    marginTop: `${componentStyle.marginTop || 0}px`,
+    marginBottom: `${componentStyle.marginBottom || 0}px`,
+    marginLeft: `${componentStyle.marginLeft || 0}px`,
+    marginRight: `${componentStyle.marginRight || 0}px`,
+    paddingTop: `${componentStyle.paddingTop || 0}px`,
+    paddingRight: `${componentStyle.paddingRight || 0}px`,
+    paddingBottom: `${componentStyle.paddingBottom || 0}px`,
+    paddingLeft: `${componentStyle.paddingLeft || 0}px`,
+    borderTopLeftRadius: `${componentStyle.borderTopLeftRadius || 0}px`,
+    borderTopRightRadius: `${componentStyle.borderTopRightRadius || 0}px`,
+    borderBottomRightRadius: `${componentStyle.borderBottomRightRadius || 0}px`,
+    borderBottomLeftRadius: `${componentStyle.borderBottomLeftRadius || 0}px`,
+    overflow: 'hidden',
+    background:
+      componentStyle.bgType === 'color' ? componentStyle.bgColor : `url(${componentStyle.bgImg})`
+  }
+})
+
+const emits = defineEmits<{
+  (e: 'move', direction: number): void
+  (e: 'copy'): void
+  (e: 'delete'): void
+}>()
+/**
+ * 移动组件
+ * @param direction 移动方向
+ */
+const handleMoveComponent = (direction: number) => {
+  emits('move', direction)
+}
+/**
+ * 复制组件
+ */
+const handleCopyComponent = () => {
+  emits('copy')
+}
+/**
+ * 删除组件
+ */
+const handleDeleteComponent = () => {
+  emits('delete')
+}
+</script>
+
+<style scoped lang="scss">
+$active-border-width: 2px;
+$hover-border-width: 1px;
+$name-position: -85px;
+$toolbar-position: -55px;
+
+/* 组件 */
+.component {
+  position: relative;
+  cursor: move;
+
+  .component-wrap {
+    position: absolute;
+    top: 0;
+    left: -$active-border-width;
+    display: block;
+    width: 100%;
+    height: 100%;
+
+    /* 鼠标放到组件上时 */
+    &:hover {
+      border: $hover-border-width dashed var(--el-color-primary);
+      box-shadow: 0 0 5px 0 rgb(24 144 255 / 30%);
+
+      .component-name {
+        top: $hover-border-width;
+
+        /* 防止加了边框之后,位置移动 */
+        left: $name-position - $hover-border-width;
+      }
+    }
+
+    /* 左侧:组件名称 */
+    .component-name {
+      position: absolute;
+      top: $active-border-width;
+      left: $name-position;
+      display: block;
+      width: 80px;
+      height: 25px;
+      font-size: 12px;
+      line-height: 25px;
+      text-align: center;
+      background: #fff;
+      box-shadow:
+        0 0 4px #00000014,
+        0 2px 6px #0000000f,
+        0 4px 8px 2px #0000000a;
+
+      /* 右侧小三角 */
+      &::after {
+        position: absolute;
+        top: 7.5px;
+        right: -10px;
+        width: 0;
+        height: 0;
+        border: 5px solid transparent;
+        border-left-color: #fff;
+        content: ' ';
+      }
+    }
+
+    /* 右侧:组件操作工具栏 */
+    .component-toolbar {
+      position: absolute;
+      top: 0;
+      right: $toolbar-position;
+      display: none;
+
+      /* 左侧小三角 */
+      &::before {
+        position: absolute;
+        top: 10px;
+        left: -10px;
+        width: 0;
+        height: 0;
+        border: 5px solid transparent;
+        border-right-color: #2d8cf0;
+        content: ' ';
+      }
+    }
+  }
+
+  /* 组件选中时 */
+  &.active {
+    margin-bottom: 4px;
+
+    .component-wrap {
+      margin-bottom: $active-border-width + $active-border-width;
+      border: $active-border-width solid var(--el-color-primary) !important;
+      box-shadow: 0 0 10px 0 rgb(24 144 255 / 30%);
+
+      .component-name {
+        top: 0 !important;
+
+        /* 防止加了边框之后,位置移动 */
+        left: $name-position - $active-border-width !important;
+        color: #fff;
+        background: var(--el-color-primary);
+
+        &::after {
+          border-left-color: var(--el-color-primary);
+        }
+      }
+
+      .component-toolbar {
+        display: block;
+      }
+    }
+  }
+}
+</style>

+ 164 - 0
src/components/DiyEditor/components/ComponentContainerProperty.vue

@@ -0,0 +1,164 @@
+<template>
+  <el-tabs stretch>
+    <el-tab-pane label="内容">
+      <slot></slot>
+    </el-tab-pane>
+    <el-tab-pane label="样式" lazy>
+      <el-card header="组件样式" class="property-group">
+        <el-form :model="formData" label-width="80px">
+          <el-form-item label="组件背景" prop="bgType">
+            <el-radio-group v-model="formData.bgType">
+              <el-radio label="color">纯色</el-radio>
+              <el-radio label="img">图片</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item label="选择颜色" prop="bgColor" v-if="formData.bgType === 'color'">
+            <ColorInput v-model="formData.bgColor" />
+          </el-form-item>
+          <el-form-item label="上传图片" prop="bgImg" v-else>
+            <UploadImg v-model="formData.bgImg" :limit="1">
+              <template #tip>建议宽度 750px</template>
+            </UploadImg>
+          </el-form-item>
+          <el-tree :data="treeData" :expand-on-click-node="false">
+            <template #default="{ node, data }">
+              <el-form-item
+                :label="data.label"
+                :prop="data.prop"
+                :label-width="node.level === 1 ? '80px' : '62px'"
+                class="w-full m-b-0!"
+              >
+                <el-slider
+                  v-model="formData[data.prop]"
+                  :max="100"
+                  :min="0"
+                  show-input
+                  input-size="small"
+                  :show-input-controls="false"
+                  @input="handleSliderChange(data.prop)"
+                />
+              </el-form-item>
+            </template>
+          </el-tree>
+          <slot name="style" :formData="formData"></slot>
+        </el-form>
+      </el-card>
+    </el-tab-pane>
+  </el-tabs>
+</template>
+
+<script setup lang="ts">
+import { ComponentStyle, usePropertyForm } from '@/components/DiyEditor/util'
+
+/**
+ * 组件容器属性
+ * 用于包裹组件,为组件提供 背景、外边距、内边距、边框等样式
+ */
+defineOptions({ name: 'ComponentContainer' })
+
+const props = defineProps<{ modelValue: ComponentStyle }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+const treeData = [
+  {
+    label: '外部边距',
+    prop: 'margin',
+    children: [
+      {
+        label: '上',
+        prop: 'marginTop'
+      },
+      {
+        label: '右',
+        prop: 'marginRight'
+      },
+      {
+        label: '下',
+        prop: 'marginBottom'
+      },
+      {
+        label: '左',
+        prop: 'marginLeft'
+      }
+    ]
+  },
+  {
+    label: '内部边距',
+    prop: 'padding',
+    children: [
+      {
+        label: '上',
+        prop: 'paddingTop'
+      },
+      {
+        label: '右',
+        prop: 'paddingRight'
+      },
+      {
+        label: '下',
+        prop: 'paddingBottom'
+      },
+      {
+        label: '左',
+        prop: 'paddingLeft'
+      }
+    ]
+  },
+  {
+    label: '边框圆角',
+    prop: 'borderRadius',
+    children: [
+      {
+        label: '上左',
+        prop: 'borderTopLeftRadius'
+      },
+      {
+        label: '上右',
+        prop: 'borderTopRightRadius'
+      },
+      {
+        label: '下右',
+        prop: 'borderBottomRightRadius'
+      },
+      {
+        label: '下左',
+        prop: 'borderBottomLeftRadius'
+      }
+    ]
+  }
+]
+
+const handleSliderChange = (prop: string) => {
+  switch (prop) {
+    case 'margin':
+      formData.value.marginTop = formData.value.margin
+      formData.value.marginRight = formData.value.margin
+      formData.value.marginBottom = formData.value.margin
+      formData.value.marginLeft = formData.value.margin
+      break
+    case 'padding':
+      formData.value.paddingTop = formData.value.padding
+      formData.value.paddingRight = formData.value.padding
+      formData.value.paddingBottom = formData.value.padding
+      formData.value.paddingLeft = formData.value.padding
+      break
+    case 'borderRadius':
+      formData.value.borderTopLeftRadius = formData.value.borderRadius
+      formData.value.borderTopRightRadius = formData.value.borderRadius
+      formData.value.borderBottomRightRadius = formData.value.borderRadius
+      formData.value.borderBottomLeftRadius = formData.value.borderRadius
+      break
+  }
+}
+</script>
+
+<style scoped lang="scss">
+:deep(.el-slider__runway) {
+  margin-right: 16px;
+}
+
+:deep(.el-input-number) {
+  width: 50px;
+}
+</style>

+ 205 - 0
src/components/DiyEditor/components/ComponentLibrary.vue

@@ -0,0 +1,205 @@
+<template>
+  <el-aside class="editor-left" width="261px">
+    <el-scrollbar>
+      <el-collapse v-model="extendGroups">
+        <el-collapse-item
+          v-for="group in groups"
+          :key="group.name"
+          :name="group.name"
+          :title="group.name"
+        >
+          <draggable
+            class="component-container"
+            ghost-class="draggable-ghost"
+            item-key="index"
+            :list="group.components"
+            :sort="false"
+            :group="{ name: 'component', pull: 'clone', put: false }"
+            :clone="handleCloneComponent"
+            :animation="200"
+            :force-fallback="true"
+          >
+            <template #item="{ element }">
+              <div>
+                <div class="drag-placement">组件放置区域</div>
+                <div class="component">
+                  <Icon :icon="element.icon" :size="32" />
+                  <span class="mt-4px text-12px">{{ element.name }}</span>
+                </div>
+              </div>
+            </template>
+          </draggable>
+        </el-collapse-item>
+      </el-collapse>
+    </el-scrollbar>
+  </el-aside>
+</template>
+
+<script setup lang="ts">
+import draggable from 'vuedraggable'
+import { componentConfigs } from '../components/mobile/index'
+import { cloneDeep } from 'lodash-es'
+import { DiyComponent, DiyComponentLibrary } from '@/components/DiyEditor/util'
+
+/** 组件库 */
+defineOptions({ name: 'ComponentLibrary' })
+
+// 组件列表
+const props = defineProps<{
+  list: DiyComponentLibrary[]
+}>()
+const groups = reactive<any[]>([])
+// 展开的折叠面板
+const extendGroups = reactive<string[]>([])
+watch(
+  () => props.list,
+  () => {
+    // 清除旧数据
+    extendGroups.length = 0
+    groups.length = 0
+    // 重新生成数据
+    props.list.forEach((group) => {
+      // 是否展开分组
+      if (group.extended) {
+        extendGroups.push(group.name)
+      }
+      // 查找组件
+      const components = group.components
+        .map((name) => componentConfigs[name] as DiyComponent<any>)
+        .filter((component) => component)
+      if (components.length > 0) {
+        groups.push({
+          name: group.name,
+          components
+        })
+      }
+    })
+  },
+  {
+    immediate: true
+  }
+)
+
+// 克隆组件
+const handleCloneComponent = (component: DiyComponent<any>) => {
+  return cloneDeep(component)
+}
+</script>
+
+<style scoped lang="scss">
+.editor-left {
+  z-index: 1;
+  flex-shrink: 0;
+  box-shadow: 8px 0 8px -8px rgb(0 0 0 / 12%);
+
+  :deep(.el-collapse) {
+    border-top: none;
+  }
+
+  :deep(.el-collapse-item__wrap) {
+    border-bottom: none;
+  }
+
+  :deep(.el-collapse-item__content) {
+    padding-bottom: 0;
+  }
+
+  :deep(.el-collapse-item__header) {
+    height: 32px;
+    padding: 0 24px;
+    line-height: 32px;
+    background-color: var(--el-bg-color-page);
+    border-bottom: none;
+  }
+
+  .component-container {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+  }
+
+  .component {
+    display: flex;
+    width: 86px;
+    height: 86px;
+    cursor: move;
+    border-right: 1px solid var(--el-border-color-lighter);
+    border-bottom: 1px solid var(--el-border-color-lighter);
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+
+    .el-icon {
+      margin-bottom: 4px;
+      color: gray;
+    }
+  }
+
+  .component.active,
+  .component:hover {
+    color: var(--el-color-white);
+    background: var(--el-color-primary);
+
+    .el-icon {
+      color: var(--el-color-white);
+    }
+  }
+
+  .component:nth-of-type(3n) {
+    border-right: none;
+  }
+}
+
+/* 拖拽占位提示,默认不显示 */
+.drag-placement {
+  display: none;
+  color: #fff;
+}
+
+.drag-area {
+  /* 拖拽到手机区域时的样式 */
+  .draggable-ghost {
+    display: flex;
+    width: 100%;
+    height: 40px;
+
+    /* 条纹背景 */
+    background: linear-gradient(
+      45deg,
+      #91a8d5 0,
+      #91a8d5 10%,
+      #94b4eb 10%,
+      #94b4eb 50%,
+      #91a8d5 50%,
+      #91a8d5 60%,
+      #94b4eb 60%,
+      #94b4eb
+    );
+    background-size: 1rem 1rem;
+    transition: all 0.5s;
+    justify-content: center;
+    align-items: center;
+
+    span {
+      display: inline-block;
+      width: 140px;
+      height: 25px;
+      font-size: 12px;
+      line-height: 25px;
+      color: #fff;
+      text-align: center;
+      background: #5487df;
+    }
+
+    /* 拖拽时隐藏组件 */
+    .component {
+      display: none;
+    }
+
+    /* 拖拽时显示占位提示 */
+    .drag-placement {
+      display: block;
+    }
+  }
+}
+</style>

+ 50 - 0
src/components/DiyEditor/components/mobile/Carousel/config.ts

@@ -0,0 +1,50 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 轮播图属性 */
+export interface CarouselProperty {
+  // 类型:默认 | 卡片
+  type: 'default' | 'card'
+  // 指示器样式:点 | 数字
+  indicator: 'dot' | 'number'
+  // 是否自动播放
+  autoplay: boolean
+  // 播放间隔
+  interval: number
+  // 轮播内容
+  items: CarouselItemProperty[]
+  // 组件样式
+  style: ComponentStyle
+}
+// 轮播内容属性
+export interface CarouselItemProperty {
+  // 类型:图片 | 视频
+  type: 'img' | 'video'
+  // 图片链接
+  imgUrl: string
+  // 视频链接
+  videoUrl: string
+  // 跳转链接
+  url: string
+}
+
+// 定义组件
+export const component = {
+  id: 'Carousel',
+  name: '轮播图',
+  icon: 'system-uicons:carousel',
+  property: {
+    type: 'default',
+    indicator: 'dot',
+    autoplay: false,
+    interval: 3,
+    items: [
+      { type: 'img', imgUrl: 'https://static.iocoder.cn/mall/banner-01.jpg', videoUrl: '' },
+      { type: 'img', imgUrl: 'https://static.iocoder.cn/mall/banner-02.jpg', videoUrl: '' }
+    ] as CarouselItemProperty[],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<CarouselProperty>

+ 43 - 0
src/components/DiyEditor/components/mobile/Carousel/index.vue

@@ -0,0 +1,43 @@
+<template>
+  <!-- 无图片 -->
+  <div
+    class="h-250px flex items-center justify-center bg-gray-3"
+    v-if="property.items.length === 0"
+  >
+    <Icon icon="tdesign:image" class="text-gray-8 text-120px!" />
+  </div>
+  <div v-else class="relative">
+    <el-carousel
+      height="174px"
+      :type="property.type === 'card' ? 'card' : ''"
+      :autoplay="property.autoplay"
+      :interval="property.interval * 1000"
+      :indicator-position="property.indicator === 'number' ? 'none' : undefined"
+      @change="handleIndexChange"
+    >
+      <el-carousel-item v-for="(item, index) in property.items" :key="index">
+        <el-image class="h-full w-full" :src="item.imgUrl" />
+      </el-carousel-item>
+    </el-carousel>
+    <div
+      v-if="property.indicator === 'number'"
+      class="absolute bottom-10px right-10px rounded-xl bg-black p-x-8px p-y-2px text-10px text-white opacity-40"
+      >{{ currentIndex }} / {{ property.items.length }}</div
+    >
+  </div>
+</template>
+<script setup lang="ts">
+import { CarouselProperty } from './config'
+
+/** 轮播图 */
+defineOptions({ name: 'Carousel' })
+
+defineProps<{ property: CarouselProperty }>()
+
+const currentIndex = ref(0)
+const handleIndexChange = (index: number) => {
+  currentIndex.value = index + 1
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 142 - 0
src/components/DiyEditor/components/mobile/Carousel/property.vue

@@ -0,0 +1,142 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <el-form label-width="80px" :model="formData">
+      <el-card header="样式设置" class="property-group" shadow="never">
+        <el-form-item label="样式" prop="type">
+          <el-radio-group v-model="formData.type">
+            <el-tooltip class="item" content="默认" placement="bottom">
+              <el-radio-button label="default">
+                <Icon icon="system-uicons:carousel" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip class="item" content="卡片" placement="bottom">
+              <el-radio-button label="card">
+                <Icon icon="ic:round-view-carousel" />
+              </el-radio-button>
+            </el-tooltip>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="指示器" prop="indicator">
+          <el-radio-group v-model="formData.indicator">
+            <el-radio label="dot">小圆点</el-radio>
+            <el-radio label="number">数字</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="是否轮播" prop="autoplay">
+          <el-switch v-model="formData.autoplay" />
+        </el-form-item>
+        <el-form-item label="播放间隔" prop="interval" v-if="formData.autoplay">
+          <el-slider
+            v-model="formData.interval"
+            :max="10"
+            :min="0.5"
+            :step="0.5"
+            show-input
+            input-size="small"
+            :show-input-controls="false"
+          />
+          <el-text type="info">单位:秒</el-text>
+        </el-form-item>
+      </el-card>
+      <el-card header="内容设置" class="property-group" shadow="never">
+        <el-text type="info" size="small"> 拖动左上角的小圆点可对其排序 </el-text>
+        <template v-if="formData.items[0]">
+          <draggable
+            :list="formData.items"
+            :force-fallback="true"
+            :animation="200"
+            handle=".drag-icon"
+            class="m-t-8px"
+            item-key="index"
+          >
+            <template #item="{ element, index }">
+              <div class="content mb-4px flex flex-col gap-4px rounded bg-gray-50 p-8px">
+                <div
+                  class="m--8px m-b-8px flex flex-row items-center justify-between bg-gray-100 p-8px"
+                >
+                  <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
+                  <Icon
+                    icon="ep:delete"
+                    class="cursor-pointer text-red-5"
+                    @click="handleDeleteImage(index)"
+                    v-if="formData.items.length > 1"
+                  />
+                </div>
+                <el-form-item label="类型" prop="type" class="m-b-8px!" label-width="50px">
+                  <el-radio-group v-model="element.type">
+                    <el-radio label="img">图片</el-radio>
+                    <el-radio label="video">视频</el-radio>
+                  </el-radio-group>
+                </el-form-item>
+                <el-form-item
+                  label="图片"
+                  class="m-b-8px!"
+                  label-width="50px"
+                  v-if="element.type === 'img'"
+                >
+                  <UploadImg
+                    v-model="element.imgUrl"
+                    draggable="false"
+                    height="80px"
+                    width="100%"
+                    class="min-w-80px"
+                  />
+                </el-form-item>
+                <template v-else>
+                  <el-form-item label="封面" class="m-b-8px!" label-width="50px">
+                    <UploadImg
+                      v-model="element.imgUrl"
+                      draggable="false"
+                      height="80px"
+                      width="100%"
+                      class="min-w-80px"
+                    />
+                  </el-form-item>
+                  <el-form-item label="视频" class="m-b-8px!" label-width="50px">
+                    <UploadFile
+                      v-model="element.videoUrl"
+                      :file-type="['mp4']"
+                      :limit="1"
+                      :file-size="100"
+                      class="min-w-80px"
+                    />
+                  </el-form-item>
+                </template>
+                <el-form-item label="链接" class="m-b-8px!" label-width="50px">
+                  <el-input placeholder="链接" v-model="element.url" />
+                </el-form-item>
+              </div>
+            </template>
+          </draggable>
+        </template>
+        <el-button @click="handleAddImage" type="primary" plain class="w-full">
+          添加图片
+        </el-button>
+      </el-card>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import draggable from 'vuedraggable' //拖拽组件
+import { CarouselItemProperty, CarouselProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+
+// 轮播图属性面板
+defineOptions({ name: 'CarouselProperty' })
+
+const props = defineProps<{ modelValue: CarouselProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+// 添加图片
+const handleAddImage = () => {
+  formData.value.items.push({} as CarouselItemProperty)
+}
+// 删除图片
+const handleDeleteImage = (index: number) => {
+  formData.value.items.splice(index, 1)
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 29 - 0
src/components/DiyEditor/components/mobile/Divider/config.ts

@@ -0,0 +1,29 @@
+import { DiyComponent } from '@/components/DiyEditor/util'
+
+/** 分割线属性 */
+export interface DividerProperty {
+  // 高度
+  height: number
+  // 线宽
+  lineWidth: number
+  // 边距类型
+  paddingType: 'none' | 'horizontal'
+  // 颜色
+  lineColor: string
+  // 类型
+  borderType: 'solid' | 'dashed' | 'dotted' | 'none'
+}
+
+// 定义组件
+export const component = {
+  id: 'Divider',
+  name: '分割线',
+  icon: 'tdesign:component-divider-vertical',
+  property: {
+    height: 30,
+    lineWidth: 1,
+    paddingType: 'none',
+    lineColor: '#dcdfe6',
+    borderType: 'solid'
+  }
+} as DiyComponent<DividerProperty>

+ 29 - 0
src/components/DiyEditor/components/mobile/Divider/index.vue

@@ -0,0 +1,29 @@
+<template>
+  <div
+    class="flex items-center"
+    :style="{
+      height: property.height + 'px'
+    }"
+  >
+    <div
+      class="w-full"
+      :style="{
+        borderTopStyle: property.borderType,
+        borderTopColor: property.lineColor,
+        borderTopWidth: `${property.lineWidth}px`,
+        margin: property.paddingType === 'none' ? '0' : '0px 16px'
+      }"
+    ></div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { DividerProperty } from './config'
+
+/** 页面顶部导航栏 */
+defineOptions({ name: 'Divider' })
+
+defineProps<{ property: DividerProperty }>()
+</script>
+
+<style scoped lang="scss"></style>

+ 80 - 0
src/components/DiyEditor/components/mobile/Divider/property.vue

@@ -0,0 +1,80 @@
+<template>
+  <el-form label-width="80px" :model="formData">
+    <el-form-item label="高度" prop="height">
+      <el-slider v-model="formData.height" :min="1" :max="100" show-input input-size="small" />
+    </el-form-item>
+    <el-form-item label="选择样式" prop="borderType">
+      <el-radio-group v-model="formData!.borderType">
+        <el-tooltip
+          placement="top"
+          v-for="(item, index) in BORDER_TYPES"
+          :key="index"
+          :content="item.text"
+        >
+          <el-radio-button :label="item.type">
+            <Icon :icon="item.icon" />
+          </el-radio-button>
+        </el-tooltip>
+      </el-radio-group>
+    </el-form-item>
+    <template v-if="formData.borderType !== 'none'">
+      <el-form-item label="线宽" prop="lineWidth">
+        <el-slider v-model="formData.lineWidth" :min="1" :max="30" show-input input-size="small" />
+      </el-form-item>
+      <el-form-item label="左右边距" prop="paddingType">
+        <el-radio-group v-model="formData!.paddingType">
+          <el-tooltip content="无边距" placement="top">
+            <el-radio-button label="none">
+              <Icon icon="tabler:box-padding" />
+            </el-radio-button>
+          </el-tooltip>
+          <el-tooltip content="左右留边" placement="top">
+            <el-radio-button label="horizontal">
+              <Icon icon="vaadin:padding" />
+            </el-radio-button>
+          </el-tooltip>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="颜色">
+        <!-- 分割线颜色 -->
+        <ColorInput v-model="formData.lineColor" />
+      </el-form-item>
+    </template>
+  </el-form>
+</template>
+
+<script setup lang="ts">
+import { DividerProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+// 导航栏属性面板
+defineOptions({ name: 'DividerProperty' })
+const props = defineProps<{ modelValue: DividerProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+//线类型
+const BORDER_TYPES = [
+  {
+    icon: 'vaadin:line-h',
+    text: '实线',
+    type: 'solid'
+  },
+  {
+    icon: 'tabler:line-dashed',
+    text: '虚线',
+    type: 'dashed'
+  },
+  {
+    icon: 'tabler:line-dotted',
+    text: '点线',
+    type: 'dotted'
+  },
+  {
+    icon: 'entypo:progress-empty',
+    text: '无',
+    type: 'none'
+  }
+]
+</script>
+
+<style scoped lang="scss"></style>

+ 27 - 0
src/components/DiyEditor/components/mobile/ImageBar/config.ts

@@ -0,0 +1,27 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 图片展示属性 */
+export interface ImageBarProperty {
+  // 图片链接
+  imgUrl: string
+  // 跳转链接
+  url: string
+  // 组件样式
+  style: ComponentStyle
+}
+
+// 定义组件
+export const component = {
+  id: 'ImageBar',
+  name: '图片展示',
+  icon: 'ep:picture',
+  property: {
+    imgUrl: '',
+    url: '',
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<ImageBarProperty>

+ 24 - 0
src/components/DiyEditor/components/mobile/ImageBar/index.vue

@@ -0,0 +1,24 @@
+<template>
+  <!-- 无图片 -->
+  <div class="h-50px flex items-center justify-center bg-gray-3" v-if="!property.imgUrl">
+    <Icon icon="ep:picture" class="text-gray-8 text-30px!" />
+  </div>
+  <el-image class="min-h-30px" v-else :src="property.imgUrl" />
+</template>
+<script setup lang="ts">
+import { ImageBarProperty } from './config'
+
+/** 图片展示 */
+defineOptions({ name: 'ImageBar' })
+
+defineProps<{ property: ImageBarProperty }>()
+</script>
+
+<style scoped lang="scss">
+/* 图片 */
+img {
+  display: block;
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 34 - 0
src/components/DiyEditor/components/mobile/ImageBar/property.vue

@@ -0,0 +1,34 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <el-form label-width="80px" :model="formData">
+      <el-form-item label="上传图片" prop="imgUrl">
+        <UploadImg
+          v-model="formData.imgUrl"
+          draggable="false"
+          height="80px"
+          width="100%"
+          class="min-w-80px"
+        >
+          <template #tip> 建议宽度750 </template>
+        </UploadImg>
+      </el-form-item>
+      <el-form-item label="链接" prop="url">
+        <el-input placeholder="链接" v-model="formData.url" />
+      </el-form-item>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import { ImageBarProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+
+// 图片展示属性面板
+defineOptions({ name: 'ImageBarProperty' })
+
+const props = defineProps<{ modelValue: ImageBarProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+</script>
+
+<style scoped lang="scss"></style>

+ 48 - 0
src/components/DiyEditor/components/mobile/MagicCube/config.ts

@@ -0,0 +1,48 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 广告魔方属性 */
+export interface MagicCubeProperty {
+  // 上圆角
+  borderRadiusTop: number
+  // 下圆角
+  borderRadiusBottom: number
+  // 间隔
+  space: number
+  // 导航菜单列表
+  list: MagicCubeItemProperty[]
+  // 组件样式
+  style: ComponentStyle
+}
+/** 广告魔方项目属性 */
+export interface MagicCubeItemProperty {
+  // 图标链接
+  imgUrl: string
+  // 链接
+  url: string
+  // 宽
+  width: number
+  // 高
+  height: number
+  // 上
+  top: number
+  // 左
+  left: number
+}
+
+// 定义组件
+export const component = {
+  id: 'MagicCube',
+  name: '广告魔方',
+  icon: 'bi:columns',
+  property: {
+    borderRadiusTop: 0,
+    borderRadiusBottom: 0,
+    space: 0,
+    list: [],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<MagicCubeProperty>

+ 73 - 0
src/components/DiyEditor/components/mobile/MagicCube/index.vue

@@ -0,0 +1,73 @@
+<template>
+  <div
+    class="relative"
+    :style="{ height: `${rowCount * CUBE_SIZE}px`, width: `${4 * CUBE_SIZE}px` }"
+  >
+    <div
+      v-for="(item, index) in property.list"
+      :key="index"
+      class="absolute"
+      :style="{
+        width: `${item.width * CUBE_SIZE - property.space * 2}px`,
+        height: `${item.height * CUBE_SIZE - property.space * 2}px`,
+        margin: `${property.space}px`,
+        top: `${item.top * CUBE_SIZE}px`,
+        left: `${item.left * CUBE_SIZE}px`
+      }"
+    >
+      <el-image
+        class="h-full w-full"
+        fit="cover"
+        :src="item.imgUrl"
+        :style="{
+          borderTopLeftRadius: `${property.borderRadiusTop}px`,
+          borderTopRightRadius: `${property.borderRadiusTop}px`,
+          borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
+          borderBottomRightRadius: `${property.borderRadiusBottom}px`
+        }"
+      >
+        <template #error>
+          <div class="image-slot">
+            <div
+              class="flex items-center justify-center"
+              :style="{
+                width: `${item.width * CUBE_SIZE}px`,
+                height: `${item.height * CUBE_SIZE}px`
+              }"
+            >
+              <Icon icon="ep-picture" color="gray" :size="CUBE_SIZE" />
+            </div>
+          </div>
+        </template>
+      </el-image>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { MagicCubeProperty } from './config'
+
+/** 广告魔方 */
+defineOptions({ name: 'MagicCube' })
+const props = defineProps<{ property: MagicCubeProperty }>()
+// 一个方块的大小
+const CUBE_SIZE = 93.75
+/**
+ * 计算方块的行数
+ * 行数用于计算魔方的总体高度,存在以下情况:
+ * 1. 没有数据时,默认就只显示一行的高度
+ * 2. 底部的空白不算高度,例如只有第一行有数据,那么就只显示一行的高度
+ * 3. 顶部及中间的空白算高度,例如一共有四行,只有最后一行有数据,那么也显示四行的高度
+ */
+const rowCount = computed(() => {
+  let count = 0
+  if (props.property.list.length > 0) {
+    // 最大行号
+    count = Math.max(...props.property.list.map((item) => item.bottom))
+  }
+  // 行号从 0 开始,所以加 1
+  return count + 1
+})
+</script>
+
+<style scoped lang="scss"></style>

+ 76 - 0
src/components/DiyEditor/components/mobile/MagicCube/property.vue

@@ -0,0 +1,76 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <!-- 表单 -->
+    <el-form label-width="80px" :model="formData" class="m-t-8px">
+      <el-text tag="p"> 魔方设置 </el-text>
+      <el-text type="info" size="small"> 每格尺寸187 * 187 </el-text>
+      <MagicCubeEditor
+        class="m-y-16px"
+        v-model="formData.list"
+        :rows="4"
+        :cols="4"
+        @hot-area-selected="handleHotAreaSelected"
+      />
+      <template v-for="(hotArea, index) in formData.list" :key="index">
+        <template v-if="selectedHotAreaIndex === index">
+          <el-form-item label="上传图片" :prop="`list[${index}].imgUrl`">
+            <UploadImg v-model="hotArea.imgUrl" height="80px" width="80px" />
+          </el-form-item>
+          <el-form-item label="链接" :prop="`list[${index}].url`">
+            <el-input v-model="hotArea.url" placeholder="请输入链接" />
+          </el-form-item>
+        </template>
+      </template>
+      <el-form-item label="上圆角" prop="borderRadiusTop">
+        <el-slider
+          v-model="formData.borderRadiusTop"
+          :max="100"
+          :min="0"
+          show-input
+          input-size="small"
+          :show-input-controls="false"
+        />
+      </el-form-item>
+      <el-form-item label="下圆角" prop="borderRadiusBottom">
+        <el-slider
+          v-model="formData.borderRadiusBottom"
+          :max="100"
+          :min="0"
+          show-input
+          input-size="small"
+          :show-input-controls="false"
+        />
+      </el-form-item>
+      <el-form-item label="间隔" prop="space">
+        <el-slider
+          v-model="formData.space"
+          :max="100"
+          :min="0"
+          show-input
+          input-size="small"
+          :show-input-controls="false"
+        />
+      </el-form-item>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import { MagicCubeProperty } from '@/components/DiyEditor/components/mobile/MagicCube/config'
+
+/** 广告魔方属性面板 */
+defineOptions({ name: 'MagicCubeProperty' })
+
+const props = defineProps<{ modelValue: MagicCubeProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+// 选中的热区
+const selectedHotAreaIndex = ref(-1)
+const handleHotAreaSelected = (_: any, index: number) => {
+  selectedHotAreaIndex.value = index
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 78 - 0
src/components/DiyEditor/components/mobile/MenuGrid/config.ts

@@ -0,0 +1,78 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+import { cloneDeep } from 'lodash-es'
+
+/** 宫格导航属性 */
+export interface MenuGridProperty {
+  // 列数
+  column: number
+  // 导航菜单列表
+  list: MenuGridItemProperty[]
+  // 组件样式
+  style: ComponentStyle
+}
+/** 宫格导航项目属性 */
+export interface MenuGridItemProperty {
+  // 图标链接
+  iconUrl: string
+  // 标题
+  title: string
+  // 标题颜色
+  titleColor: string
+  // 副标题
+  subtitle: string
+  // 副标题颜色
+  subtitleColor: string
+  // 链接
+  url: string
+  // 角标
+  badge: {
+    // 是否显示
+    show: boolean
+    // 角标文字
+    text: string
+    // 角标文字颜色
+    textColor: string
+    // 角标背景颜色
+    bgColor: string
+  }
+}
+
+export const EMPTY_MENU_GRID_ITEM_PROPERTY = {
+  title: '标题',
+  titleColor: '#333',
+  subtitle: '副标题',
+  subtitleColor: '#bbb',
+  badge: {
+    show: false,
+    textColor: '#fff',
+    bgColor: '#FF6000'
+  }
+} as MenuGridItemProperty
+
+// 定义组件
+export const component = {
+  id: 'MenuGrid',
+  name: '宫格导航',
+  icon: 'bi:grid-3x3-gap',
+  property: {
+    column: 3,
+    list: [cloneDeep(EMPTY_MENU_GRID_ITEM_PROPERTY)],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8,
+      marginLeft: 8,
+      marginRight: 8,
+      padding: 8,
+      paddingTop: 8,
+      paddingRight: 8,
+      paddingBottom: 8,
+      paddingLeft: 8,
+      borderRadius: 8,
+      borderTopLeftRadius: 8,
+      borderTopRightRadius: 8,
+      borderBottomRightRadius: 8,
+      borderBottomLeftRadius: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<MenuGridProperty>

+ 35 - 0
src/components/DiyEditor/components/mobile/MenuGrid/index.vue

@@ -0,0 +1,35 @@
+<template>
+  <div class="flex flex-row flex-wrap">
+    <div
+      v-for="(item, index) in property.list"
+      :key="index"
+      class="relative flex flex-col items-center p-b-14px p-t-20px"
+      :style="{ width: `${100 * (1 / property.column)}%` }"
+    >
+      <!-- 右上角角标 -->
+      <span
+        v-if="item.badge?.show"
+        class="absolute left-50% top-10px z-1 h-20px rounded-50% p-x-6px text-center text-12px leading-20px"
+        :style="{ color: item.badge.textColor, backgroundColor: item.badge.bgColor }"
+      >
+        {{ item.badge.text }}
+      </span>
+      <el-image v-if="item.iconUrl" class="h-28px w-28px" :src="item.iconUrl" />
+      <span class="m-t-8px h-16px text-12px leading-16px" :style="{ color: item.titleColor }">
+        {{ item.title }}
+      </span>
+      <span class="m-t-6px h-12px text-10px leading-12px" :style="{ color: item.subtitleColor }">
+        {{ item.subtitle }}
+      </span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { MenuGridProperty } from './config'
+/** 宫格导航 */
+defineOptions({ name: 'MenuGrid' })
+defineProps<{ property: MenuGridProperty }>()
+</script>
+
+<style scoped lang="scss"></style>

+ 96 - 0
src/components/DiyEditor/components/mobile/MenuGrid/property.vue

@@ -0,0 +1,96 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <!-- 表单 -->
+    <el-form label-width="80px" :model="formData" class="m-t-8px">
+      <el-form-item label="每行数量" prop="column">
+        <el-radio-group v-model="formData.column">
+          <el-radio :label="3">3个</el-radio>
+          <el-radio :label="4">4个</el-radio>
+        </el-radio-group>
+      </el-form-item>
+
+      <el-text tag="p"> 菜单设置 </el-text>
+      <el-text type="info" size="small"> 拖动左侧的小圆点可以调整顺序 </el-text>
+      <template v-if="formData.list.length">
+        <VueDraggable
+          class="m-t-8px"
+          :list="formData.list"
+          item-key="index"
+          handle=".drag-icon"
+          :forceFallback="true"
+          :animation="200"
+        >
+          <template #item="{ element, index }">
+            <div class="mb-4px flex flex-col gap-4px rounded bg-gray-100 p-8px">
+              <div class="flex flex-row justify-between">
+                <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
+                <Icon icon="ep:delete" class="text-red-500" @click="handleDeleteMenu(index)" />
+              </div>
+              <el-form-item label="图标" prop="iconUrl">
+                <UploadImg v-model="element.iconUrl" height="80px" width="80px">
+                  <template #tip> 建议尺寸:44 * 44 </template>
+                </UploadImg>
+              </el-form-item>
+              <el-form-item label="标题" prop="title">
+                <InputWithColor v-model="element.title" v-model:color="element.titleColor" />
+              </el-form-item>
+              <el-form-item label="副标题" prop="subtitle">
+                <InputWithColor v-model="element.subtitle" v-model:color="element.subtitleColor" />
+              </el-form-item>
+              <el-form-item label="链接" prop="url">
+                <el-input v-model="element.url" />
+              </el-form-item>
+              <el-form-item label="显示角标" prop="badge.show">
+                <el-switch v-model="element.badge.show" />
+              </el-form-item>
+              <template v-if="element.badge.show">
+                <el-form-item label="角标内容" prop="badge.text">
+                  <InputWithColor
+                    v-model="element.badge.text"
+                    v-model:color="element.badge.textColor"
+                  />
+                </el-form-item>
+                <el-form-item label="背景颜色" prop="badge.bgColor">
+                  <ColorInput v-model="element.badge.bgColor" />
+                </el-form-item>
+              </template>
+            </div>
+          </template>
+        </VueDraggable>
+      </template>
+      <el-form-item label-width="0">
+        <el-button @click="handleAddMenu" type="primary" plain class="m-t-8px w-full">
+          <Icon icon="ep:plus" class="mr-5px" /> 添加菜单
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import VueDraggable from 'vuedraggable'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import {
+  EMPTY_MENU_GRID_ITEM_PROPERTY,
+  MenuGridProperty
+} from '@/components/DiyEditor/components/mobile/MenuGrid/config'
+import { cloneDeep } from 'lodash-es'
+
+/** 宫格导航属性面板 */
+defineOptions({ name: 'MenuGridProperty' })
+
+const props = defineProps<{ modelValue: MenuGridProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+/* 添加菜单 */
+const handleAddMenu = () => {
+  formData.value.list.push(cloneDeep(EMPTY_MENU_GRID_ITEM_PROPERTY))
+}
+/* 删除菜单 */
+const handleDeleteMenu = (index: number) => {
+  formData.value.list.splice(index, 1)
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 47 - 0
src/components/DiyEditor/components/mobile/MenuList/config.ts

@@ -0,0 +1,47 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+import { cloneDeep } from 'lodash-es'
+
+/** 列表导航属性 */
+export interface MenuListProperty {
+  // 导航菜单列表
+  list: MenuListItemProperty[]
+  // 组件样式
+  style: ComponentStyle
+}
+/** 列表导航项目属性 */
+export interface MenuListItemProperty {
+  // 图标链接
+  iconUrl: string
+  // 标题
+  title: string
+  // 标题颜色
+  titleColor: string
+  // 副标题
+  subtitle: string
+  // 副标题颜色
+  subtitleColor: string
+  // 链接
+  url: string
+}
+
+export const EMPTY_MENU_LIST_ITEM_PROPERTY = {
+  title: '标题',
+  titleColor: '#333',
+  subtitle: '副标题',
+  subtitleColor: '#bbb'
+}
+
+// 定义组件
+export const component = {
+  id: 'MenuList',
+  name: '列表导航',
+  icon: 'fa-solid:list',
+  property: {
+    list: [cloneDeep(EMPTY_MENU_LIST_ITEM_PROPERTY)],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<MenuListProperty>

+ 31 - 0
src/components/DiyEditor/components/mobile/MenuList/index.vue

@@ -0,0 +1,31 @@
+<template>
+  <div class="min-h-42px flex flex-col">
+    <div
+      v-for="(item, index) in property.list"
+      :key="index"
+      class="item h-42px flex flex-row items-center justify-between gap-4px p-x-12px"
+    >
+      <div class="flex flex-1 flex-row items-center gap-8px">
+        <el-image v-if="item.iconUrl" class="h-16px w-16px" :src="item.iconUrl" />
+        <span class="text-16px" :style="{ color: item.titleColor }">{{ item.title }}</span>
+      </div>
+      <div class="item-center flex flex-row justify-center gap-4px">
+        <span class="text-12px" :style="{ color: item.subtitleColor }">{{ item.subtitle }}</span>
+        <Icon icon="ep-arrow-right" color="#000" :size="16" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { MenuListProperty } from './config'
+/** 列表导航 */
+defineOptions({ name: 'MenuList' })
+defineProps<{ property: MenuListProperty }>()
+</script>
+
+<style scoped lang="scss">
+.item + .item {
+  border-top: 1px solid #eee;
+}
+</style>

+ 75 - 0
src/components/DiyEditor/components/mobile/MenuList/property.vue

@@ -0,0 +1,75 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <el-text tag="p"> 菜单设置 </el-text>
+    <el-text type="info" size="small"> 拖动左侧的小圆点可以调整顺序 </el-text>
+
+    <!-- 表单 -->
+    <el-form label-width="60px" :model="formData" class="m-t-8px">
+      <div v-if="formData.list.length">
+        <VueDraggable
+          :list="formData.list"
+          item-key="index"
+          handle=".drag-icon"
+          :forceFallback="true"
+          :animation="200"
+        >
+          <template #item="{ element, index }">
+            <div class="mb-4px flex flex-col gap-4px rounded bg-gray-100 p-8px">
+              <div class="flex flex-row justify-between">
+                <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
+                <Icon icon="ep:delete" class="text-red-500" @click="handleDeleteMenu(index)" />
+              </div>
+              <el-form-item label="图标" prop="iconUrl">
+                <UploadImg v-model="element.iconUrl" height="80px" width="80px">
+                  <template #tip> 建议尺寸:44 * 44 </template>
+                </UploadImg>
+              </el-form-item>
+              <el-form-item label="标题" prop="title">
+                <InputWithColor v-model="element.title" v-model:color="element.titleColor" />
+              </el-form-item>
+              <el-form-item label="副标题" prop="subtitle">
+                <InputWithColor v-model="element.subtitle" v-model:color="element.subtitleColor" />
+              </el-form-item>
+              <el-form-item label="链接" prop="url">
+                <el-input v-model="element.url" />
+              </el-form-item>
+            </div>
+          </template>
+        </VueDraggable>
+      </div>
+      <el-form-item label-width="0">
+        <el-button @click="handleAddMenu" type="primary" plain class="m-t-8px w-full">
+          <Icon icon="ep:plus" class="mr-5px" /> 添加菜单
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import VueDraggable from 'vuedraggable'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import {
+  EMPTY_MENU_LIST_ITEM_PROPERTY,
+  MenuListProperty
+} from '@/components/DiyEditor/components/mobile/MenuList/config'
+import { cloneDeep } from 'lodash-es'
+
+/** 列表导航属性面板 */
+defineOptions({ name: 'MenuListProperty' })
+
+const props = defineProps<{ modelValue: MenuListProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+/* 添加菜单 */
+const handleAddMenu = () => {
+  formData.value.list.push(cloneDeep(EMPTY_MENU_LIST_ITEM_PROPERTY))
+}
+/* 删除菜单 */
+const handleDeleteMenu = (index: number) => {
+  formData.value.list.splice(index, 1)
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 66 - 0
src/components/DiyEditor/components/mobile/MenuSwiper/config.ts

@@ -0,0 +1,66 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+import { cloneDeep } from 'lodash-es'
+
+/** 菜单导航属性 */
+export interface MenuSwiperProperty {
+  // 布局: 图标+文字 | 图标
+  layout: 'iconText' | 'icon'
+  // 行数
+  row: number
+  // 列数
+  column: number
+  // 导航菜单列表
+  list: MenuSwiperItemProperty[]
+  // 组件样式
+  style: ComponentStyle
+}
+/** 菜单导航项目属性 */
+export interface MenuSwiperItemProperty {
+  // 图标链接
+  iconUrl: string
+  // 标题
+  title: string
+  // 标题颜色
+  titleColor: string
+  // 链接
+  url: string
+  // 角标
+  badge: {
+    // 是否显示
+    show: boolean
+    // 角标文字
+    text: string
+    // 角标文字颜色
+    textColor: string
+    // 角标背景颜色
+    bgColor: string
+  }
+}
+
+export const EMPTY_MENU_SWIPER_ITEM_PROPERTY = {
+  title: '标题',
+  titleColor: '#333',
+  badge: {
+    show: false,
+    textColor: '#fff',
+    bgColor: '#FF6000'
+  }
+} as MenuSwiperItemProperty
+
+// 定义组件
+export const component = {
+  id: 'MenuSwiper',
+  name: '菜单导航',
+  icon: 'bi:grid-3x2-gap',
+  property: {
+    layout: 'iconText',
+    row: 1,
+    column: 3,
+    list: [cloneDeep(EMPTY_MENU_SWIPER_ITEM_PROPERTY)],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<MenuSwiperProperty>

+ 119 - 0
src/components/DiyEditor/components/mobile/MenuSwiper/index.vue

@@ -0,0 +1,119 @@
+<template>
+  <el-carousel
+    :height="`${carouselHeight}px`"
+    :autoplay="false"
+    arrow="hover"
+    indicator-position="outside"
+  >
+    <el-carousel-item v-for="(page, pageIndex) in pages" :key="pageIndex">
+      <div class="flex flex-row flex-wrap">
+        <div
+          v-for="(item, index) in page"
+          :key="index"
+          class="relative flex flex-col items-center justify-center"
+          :style="{ width: columnWidth, height: `${rowHeight}px` }"
+        >
+          <!-- 图标 + 角标 -->
+          <div class="relative" :class="`h-${ICON_SIZE}px w-${ICON_SIZE}px`">
+            <!-- 右上角角标 -->
+            <span
+              v-if="item.badge?.show"
+              class="absolute right--10px top--10px z-1 h-20px rounded-10px p-x-6px text-center text-12px leading-20px"
+              :style="{ color: item.badge.textColor, backgroundColor: item.badge.bgColor }"
+            >
+              {{ item.badge.text }}
+            </span>
+            <el-image v-if="item.iconUrl" :src="item.iconUrl" class="h-full w-full" />
+          </div>
+          <!-- 标题 -->
+          <span
+            v-if="property.layout === 'iconText'"
+            class="text-14px"
+            :style="{
+              color: item.titleColor,
+              height: `${TITLE_HEIGHT}px`,
+              lineHeight: `${TITLE_HEIGHT}px`
+            }"
+          >
+            {{ item.title }}
+          </span>
+        </div>
+      </div>
+    </el-carousel-item>
+  </el-carousel>
+</template>
+
+<script setup lang="ts">
+import { MenuSwiperProperty, MenuSwiperItemProperty } from './config'
+/** 菜单导航 */
+defineOptions({ name: 'MenuSwiper' })
+const props = defineProps<{ property: MenuSwiperProperty }>()
+// 标题的高度
+const TITLE_HEIGHT = 20
+// 图标的高度
+const ICON_SIZE = 50
+// 垂直间距:一行上下的间距
+const SPACE_Y = 16
+
+// 分页
+const pages = ref<MenuSwiperItemProperty[][]>([])
+// 轮播图高度
+const carouselHeight = ref(0)
+// 行高
+const rowHeight = ref(0)
+// 列宽
+const columnWidth = ref('')
+watch(
+  () => props.property,
+  () => {
+    // 计算列宽:每一列的百分比
+    columnWidth.value = `${100 * (1 / props.property.column)}%`
+    // 计算行高:图标 + 文字(仅显示图片时为0) + 垂直间距 * 2
+    rowHeight.value =
+      (props.property.layout === 'iconText' ? ICON_SIZE + TITLE_HEIGHT : ICON_SIZE) + SPACE_Y * 2
+    // 计算轮播的高度:行数 * 行高
+    carouselHeight.value = props.property.row * rowHeight.value
+
+    // 每页数量:行数 * 列数
+    const pageSize = props.property.row * props.property.column
+    // 清空分页
+    pages.value = []
+    // 每一页的菜单
+    let pageItems: MenuSwiperItemProperty[] = []
+    for (const item of props.property.list) {
+      // 本页满员,新建下一页
+      if (pageItems.length === pageSize) {
+        pageItems = []
+      }
+      // 增加一页
+      if (pageItems.length === 0) {
+        pages.value.push(pageItems)
+      }
+      // 本页增加一个
+      pageItems.push(item)
+    }
+  },
+  { immediate: true, deep: true }
+)
+</script>
+
+<style lang="scss">
+// 重写指示器样式,与 APP 保持一致
+:root {
+  .el-carousel__indicator {
+    padding-top: 0;
+    padding-bottom: 0;
+    .el-carousel__button {
+      --el-carousel-indicator-height: 6px;
+      --el-carousel-indicator-width: 6px;
+      --el-carousel-indicator-out-color: #ff6000;
+      border-radius: 6px;
+    }
+  }
+  .el-carousel__indicator.is-active {
+    .el-carousel__button {
+      --el-carousel-indicator-width: 12px;
+    }
+  }
+}
+</style>

+ 106 - 0
src/components/DiyEditor/components/mobile/MenuSwiper/property.vue

@@ -0,0 +1,106 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <!-- 表单 -->
+    <el-form label-width="80px" :model="formData" class="m-t-8px">
+      <el-form-item label="布局" prop="layout">
+        <el-radio-group v-model="formData.layout">
+          <el-radio label="iconText">图标+文字</el-radio>
+          <el-radio label="icon">仅图标</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="行数" prop="row">
+        <el-radio-group v-model="formData.row">
+          <el-radio :label="1">1行</el-radio>
+          <el-radio :label="2">2行</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="列数" prop="column">
+        <el-radio-group v-model="formData.column">
+          <el-radio :label="3">3列</el-radio>
+          <el-radio :label="4">4列</el-radio>
+          <el-radio :label="5">5列</el-radio>
+        </el-radio-group>
+      </el-form-item>
+
+      <el-text tag="p"> 菜单设置 </el-text>
+      <el-text type="info" size="small"> 拖动左侧的小圆点可以调整顺序 </el-text>
+      <template v-if="formData.list.length">
+        <VueDraggable
+          class="m-t-8px"
+          :list="formData.list"
+          item-key="index"
+          handle=".drag-icon"
+          :forceFallback="true"
+          :animation="200"
+        >
+          <template #item="{ element, index }">
+            <div class="mb-4px flex flex-col gap-4px rounded bg-gray-100 p-8px">
+              <div class="flex flex-row justify-between">
+                <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
+                <Icon icon="ep:delete" class="text-red-500" @click="handleDeleteMenu(index)" />
+              </div>
+              <el-form-item label="图标" prop="iconUrl">
+                <UploadImg v-model="element.iconUrl" height="80px" width="80px">
+                  <template #tip> 建议尺寸:98 * 98 </template>
+                </UploadImg>
+              </el-form-item>
+              <el-form-item label="标题" prop="title">
+                <InputWithColor v-model="element.title" v-model:color="element.titleColor" />
+              </el-form-item>
+              <el-form-item label="链接" prop="url">
+                <el-input v-model="element.url" />
+              </el-form-item>
+              <el-form-item label="显示角标" prop="badge.show">
+                <el-switch v-model="element.badge.show" />
+              </el-form-item>
+              <template v-if="element.badge.show">
+                <el-form-item label="角标内容" prop="badge.text">
+                  <InputWithColor
+                    v-model="element.badge.text"
+                    v-model:color="element.badge.textColor"
+                  />
+                </el-form-item>
+                <el-form-item label="背景颜色" prop="badge.bgColor">
+                  <ColorInput v-model="element.badge.bgColor" />
+                </el-form-item>
+              </template>
+            </div>
+          </template>
+        </VueDraggable>
+      </template>
+      <el-form-item label-width="0">
+        <el-button @click="handleAddMenu" type="primary" plain class="m-t-8px w-full">
+          <Icon icon="ep:plus" class="mr-5px" /> 添加菜单
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import VueDraggable from 'vuedraggable'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import {
+  EMPTY_MENU_SWIPER_ITEM_PROPERTY,
+  MenuSwiperProperty
+} from '@/components/DiyEditor/components/mobile/MenuSwiper/config'
+import { cloneDeep } from 'lodash-es'
+
+/** 菜单导航属性面板 */
+defineOptions({ name: 'MenuSwiperProperty' })
+
+const props = defineProps<{ modelValue: MenuSwiperProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+/* 添加菜单 */
+const handleAddMenu = () => {
+  formData.value.list.push(cloneDeep(EMPTY_MENU_SWIPER_ITEM_PROPERTY))
+}
+/* 删除菜单 */
+const handleDeleteMenu = (index: number) => {
+  formData.value.list.splice(index, 1)
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 38 - 0
src/components/DiyEditor/components/mobile/NavigationBar/config.ts

@@ -0,0 +1,38 @@
+import { DiyComponent } from '@/components/DiyEditor/util'
+
+/** 顶部导航栏属性 */
+export interface NavigationBarProperty {
+  // 页面标题
+  title: string
+  // 页面描述
+  description: string
+  // 顶部导航高度
+  navBarHeight: number
+  // 页面背景颜色
+  backgroundColor: string
+  // 页面背景图片
+  backgroundImage: string
+  // 样式类型:默认 | 沉浸式
+  styleType: 'default' | 'immersion'
+  // 常驻显示
+  alwaysShow: boolean
+  // 是否显示返回按钮
+  showGoBack: boolean
+}
+
+// 定义组件
+export const component = {
+  id: 'NavigationBar',
+  name: '顶部导航栏',
+  icon: 'tabler:layout-navbar',
+  property: {
+    title: '页面标题',
+    description: '',
+    navBarHeight: 35,
+    backgroundColor: '#fff',
+    backgroundImage: '',
+    styleType: 'default',
+    alwaysShow: true,
+    showGoBack: true
+  }
+} as DiyComponent<NavigationBarProperty>

+ 62 - 0
src/components/DiyEditor/components/mobile/NavigationBar/index.vue

@@ -0,0 +1,62 @@
+<template>
+  <div
+    class="navigation-bar"
+    :style="{
+      height: `${property.navBarHeight}px`,
+      backgroundColor: property.backgroundColor,
+      backgroundImage: `url(${property.backgroundImage})`
+    }"
+  >
+    <!-- 左侧 -->
+    <div class="left">
+      <Icon icon="ep:arrow-left" v-show="property.showGoBack" />
+    </div>
+    <!-- 中间 -->
+    <div
+      class="center"
+      :style="{
+        height: `${property.navBarHeight}px`,
+        lineHeight: `${property.navBarHeight}px`
+      }"
+    >
+      {{ property.title }}
+    </div>
+    <!-- 右侧 -->
+    <div class="right"></div>
+  </div>
+</template>
+<script setup lang="ts">
+import { NavigationBarProperty } from './config'
+
+/** 页面顶部导航栏 */
+defineOptions({ name: 'NavigationBar' })
+
+defineProps<{ property: NavigationBarProperty }>()
+</script>
+<style lang="scss" scoped>
+.navigation-bar {
+  display: flex;
+  height: 35px;
+  background: #fff;
+  justify-content: space-between;
+  align-items: center;
+
+  /* 左边 */
+  .left {
+    margin-left: 8px;
+  }
+
+  .center {
+    font-size: 14px;
+    line-height: 35px;
+    color: #333;
+    text-align: center;
+    flex: 1;
+  }
+
+  /* 右边 */
+  .right {
+    margin-right: 8px;
+  }
+}
+</style>

+ 63 - 0
src/components/DiyEditor/components/mobile/NavigationBar/property.vue

@@ -0,0 +1,63 @@
+<template>
+  <el-form label-width="80px" :model="formData" :rules="rules">
+    <el-form-item label="页面标题" prop="title">
+      <el-input v-model="formData!.title" placeholder="页面标题" maxlength="25" show-word-limit />
+    </el-form-item>
+    <el-form-item label="页面描述" prop="description">
+      <el-input
+        type="textarea"
+        v-model="formData!.description"
+        placeholder="用户通过微信分享给朋友时,会自动显示页面描述"
+      />
+    </el-form-item>
+    <el-form-item label="样式" prop="styleType">
+      <el-radio-group v-model="formData!.styleType">
+        <el-radio label="default">默认</el-radio>
+        <el-radio label="immersion">沉浸式</el-radio>
+      </el-radio-group>
+    </el-form-item>
+    <el-form-item label="常驻显示" prop="alwaysShow" v-if="formData.styleType === 'immersion'">
+      <el-radio-group v-model="formData!.alwaysShow">
+        <el-radio :label="false">关闭</el-radio>
+        <el-radio :label="true">开启</el-radio>
+      </el-radio-group>
+    </el-form-item>
+    <el-form-item label="高度" prop="navBarHeight">
+      <el-slider
+        v-model="formData!.navBarHeight"
+        :max="100"
+        :min="35"
+        show-input
+        input-size="small"
+      />
+    </el-form-item>
+    <el-form-item label="返回按钮" prop="showGoBack">
+      <el-switch v-model="formData!.showGoBack" />
+    </el-form-item>
+    <el-form-item label="背景颜色" prop="backgroundColor">
+      <ColorInput v-model="formData!.backgroundColor" />
+    </el-form-item>
+    <el-form-item label="背景图片" prop="backgroundImage">
+      <UploadImg v-model="formData!.backgroundImage" :limit="1">
+        <template #tip>建议宽度 750px</template>
+      </UploadImg>
+    </el-form-item>
+  </el-form>
+</template>
+
+<script setup lang="ts">
+import { NavigationBarProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+// 导航栏属性面板
+defineOptions({ name: 'NavigationBarProperty' })
+// 表单校验
+const rules = {
+  name: [{ required: true, message: '请输入页面名称', trigger: 'blur' }]
+}
+
+const props = defineProps<{ modelValue: NavigationBarProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+</script>
+
+<style scoped lang="scss"></style>

+ 39 - 0
src/components/DiyEditor/components/mobile/NoticeBar/config.ts

@@ -0,0 +1,39 @@
+import { DiyComponent } from '@/components/DiyEditor/util'
+
+/** 公告栏属性 */
+export interface NoticeBarProperty {
+  // 图标地址
+  iconUrl: string
+  // 公告内容列表
+  contents: NoticeContentProperty[]
+  // 背景颜色
+  backgroundColor: string
+  // 文字颜色
+  textColor: string
+}
+
+/** 内容属性 */
+export interface NoticeContentProperty {
+  // 内容文字
+  text: string
+  // 链接地址
+  url: string
+}
+
+// 定义组件
+export const component = {
+  id: 'NoticeBar',
+  name: '公告栏',
+  icon: 'ep:bell',
+  property: {
+    iconUrl: 'http://mall.yudao.iocoder.cn/static/images/xinjian.png',
+    contents: [
+      {
+        text: '',
+        url: ''
+      }
+    ],
+    backgroundColor: '#fff',
+    textColor: '#333'
+  }
+} as DiyComponent<NoticeBarProperty>

+ 26 - 0
src/components/DiyEditor/components/mobile/NoticeBar/index.vue

@@ -0,0 +1,26 @@
+<template>
+  <div
+    class="flex items-center p-y-4px text-12px"
+    :style="{ backgroundColor: property.backgroundColor, color: property.textColor }"
+  >
+    <el-image :src="property.iconUrl" class="h-18px" />
+    <el-divider direction="vertical" />
+    <el-carousel height="24px" direction="vertical" :autoplay="true" class="flex-1 p-r-8px">
+      <el-carousel-item v-for="(item, index) in property.contents" :key="index">
+        <div class="h-24px truncate leading-24px">{{ item.text }}</div>
+      </el-carousel-item>
+    </el-carousel>
+    <Icon icon="ep:arrow-right" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { NoticeBarProperty } from './config'
+
+/** 公告栏 */
+defineOptions({ name: 'NoticeBar' })
+
+defineProps<{ property: NoticeBarProperty }>()
+</script>
+
+<style scoped lang="scss"></style>

+ 77 - 0
src/components/DiyEditor/components/mobile/NoticeBar/property.vue

@@ -0,0 +1,77 @@
+<template>
+  <el-form label-width="80px" :model="formData" :rules="rules">
+    <el-form-item label="公告图标" prop="iconUrl">
+      <UploadImg v-model="formData.iconUrl" height="48px">
+        <template #tip>建议尺寸:24 * 24</template>
+      </UploadImg>
+    </el-form-item>
+    <el-form-item label="背景颜色" prop="backgroundColor">
+      <ColorInput v-model="formData.backgroundColor" />
+    </el-form-item>
+    <el-form-item label="文字颜色" prop="文字颜色">
+      <ColorInput v-model="formData.textColor" />
+    </el-form-item>
+    <el-text tag="p"> 公告内容 </el-text>
+    <el-text type="info" size="small"> 拖动左上角的小圆点可以调整热词顺序 </el-text>
+    <template v-if="formData.contents.length">
+      <VueDraggable
+        :list="formData.contents"
+        item-key="index"
+        handle=".drag-icon"
+        :forceFallback="true"
+        :animation="200"
+        class="m-t-8px"
+      >
+        <template #item="{ element, index }">
+          <div class="mb-4px flex flex-row gap-4px rounded bg-gray-100 p-8px">
+            <div class="flex flex-col items-start justify-between">
+              <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
+              <Icon
+                icon="ep:delete"
+                class="cursor-pointer text-red-5"
+                @click="handleDeleteContent(index)"
+                v-if="formData.contents.length > 1"
+              />
+            </div>
+            <div class="w-full flex flex-col gap-8px">
+              <el-input v-model="element.text" placeholder="请输入公告" />
+              <el-input v-model="element.url" placeholder="请输入链接" />
+            </div>
+          </div>
+        </template>
+      </VueDraggable>
+    </template>
+    <el-form-item label-width="0">
+      <el-button @click="handleAddContent" type="primary" plain class="m-t-8px w-full">
+        添加内容
+      </el-button>
+    </el-form-item>
+  </el-form>
+</template>
+
+<script setup lang="ts">
+import { NoticeBarProperty, NoticeContentProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import VueDraggable from 'vuedraggable'
+// 通知栏属性面板
+defineOptions({ name: 'NoticeBarProperty' })
+// 表单校验
+const rules = {
+  content: [{ required: true, message: '请输入公告', trigger: 'blur' }]
+}
+
+const props = defineProps<{ modelValue: NoticeBarProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+/* 添加公告 */
+const handleAddContent = () => {
+  formData.value.contents.push({} as NoticeContentProperty)
+}
+/* 删除公告 */
+const handleDeleteContent = (index: number) => {
+  formData.value.contents.splice(index, 1)
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 23 - 0
src/components/DiyEditor/components/mobile/PageConfig/config.ts

@@ -0,0 +1,23 @@
+import { DiyComponent } from '@/components/DiyEditor/util'
+
+/** 页面设置属性 */
+export interface PageConfigProperty {
+  // 页面描述
+  description: string
+  // 页面背景颜色
+  backgroundColor: string
+  // 页面背景图片
+  backgroundImage: string
+}
+
+// 定义页面组件
+export const component = {
+  id: 'PageConfig',
+  name: '页面设置',
+  icon: 'ep:document',
+  property: {
+    description: '',
+    backgroundColor: '#f5f5f5',
+    backgroundImage: ''
+  }
+} as DiyComponent<PageConfigProperty>

+ 34 - 0
src/components/DiyEditor/components/mobile/PageConfig/property.vue

@@ -0,0 +1,34 @@
+<template>
+  <el-form label-width="80px" :model="formData" :rules="rules">
+    <el-form-item label="页面描述" prop="description">
+      <el-input
+        type="textarea"
+        v-model="formData!.description"
+        placeholder="用户通过微信分享给朋友时,会自动显示页面描述"
+      />
+    </el-form-item>
+    <el-form-item label="背景颜色" prop="backgroundColor">
+      <ColorInput v-model="formData!.backgroundColor" />
+    </el-form-item>
+    <el-form-item label="背景图片" prop="backgroundImage">
+      <UploadImg v-model="formData!.backgroundImage" :limit="1">
+        <template #tip>建议宽度 750px</template>
+      </UploadImg>
+    </el-form-item>
+  </el-form>
+</template>
+
+<script setup lang="ts">
+import { PageConfigProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+// 导航栏属性面板
+defineOptions({ name: 'PageConfigProperty' })
+// 表单校验
+const rules = {}
+
+const props = defineProps<{ modelValue: PageConfigProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+</script>
+
+<style scoped lang="scss"></style>

+ 97 - 0
src/components/DiyEditor/components/mobile/ProductCard/config.ts

@@ -0,0 +1,97 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 商品卡片属性 */
+export interface ProductCardProperty {
+  // 布局类型:单列大图 | 单列小图 | 双列
+  layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol'
+  // 商品字段
+  fields: {
+    // 商品名称
+    name: ProductCardFieldProperty
+    // 商品简介
+    introduction: ProductCardFieldProperty
+    // 商品价格
+    price: ProductCardFieldProperty
+    // 商品市场价
+    marketPrice: ProductCardFieldProperty
+    // 商品销量
+    salesCount: ProductCardFieldProperty
+    // 商品库存
+    stock: ProductCardFieldProperty
+  }
+  // 角标
+  badge: {
+    // 是否显示
+    show: boolean
+    // 角标图片
+    imgUrl: string
+  }
+  // 按钮
+  btnBuy: {
+    // 类型:文字 | 图片
+    type: 'text' | 'img'
+    // 文字
+    text: string
+    // 文字按钮:背景渐变起始颜色
+    bgBeginColor: string
+    // 文字按钮:背景渐变结束颜色
+    bgEndColor: string
+    // 图片按钮:图片地址
+    imgUrl: string
+  }
+  // 上圆角
+  borderRadiusTop: number
+  // 下圆角
+  borderRadiusBottom: number
+  // 间距
+  space: number
+  // 商品编号列表
+  spuIds: number[]
+  // 组件样式
+  style: ComponentStyle
+}
+// 商品字段
+export interface ProductCardFieldProperty {
+  // 是否显示
+  show: boolean
+  // 颜色
+  color: string
+}
+
+// 定义组件
+export const component = {
+  id: 'ProductCard',
+  name: '商品卡片',
+  icon: 'system-uicons:carousel',
+  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: false, color: '#c4c4c4' }
+    },
+    badge: { show: false, imgUrl: '' },
+    btnBuy: {
+      type: 'text',
+      text: '立即购买',
+      // todo: @owen 根据主题色配置
+      bgBeginColor: '#FF6000',
+      bgEndColor: '#FE832A',
+      imgUrl: ''
+    },
+    borderRadiusTop: 8,
+    borderRadiusBottom: 8,
+    space: 8,
+    spuIds: [],
+    style: {
+      bgType: 'color',
+      bgColor: '',
+      marginLeft: 8,
+      marginRight: 8,
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<ProductCardProperty>

+ 165 - 0
src/components/DiyEditor/components/mobile/ProductCard/index.vue

@@ -0,0 +1,165 @@
+<template>
+  <div :class="`box-content min-h-30px w-full flex flex-row flex-wrap`" ref="containerRef">
+    <div
+      class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
+      :style="{
+        ...calculateSpace(index),
+        ...calculateWidth(),
+        borderTopLeftRadius: `${property.borderRadiusTop}px`,
+        borderTopRightRadius: `${property.borderRadiusTop}px`,
+        borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
+        borderBottomRightRadius: `${property.borderRadiusBottom}px`
+      }"
+      v-for="(spu, index) in spuList"
+      :key="index"
+    >
+      <!-- 角标 -->
+      <div v-if="property.badge.show" class="absolute left-0 top-0 z-1 items-center justify-center">
+        <el-image fit="cover" :src="property.badge.imgUrl" class="h-26px w-38px" />
+      </div>
+      <!-- 商品封面图 -->
+      <div
+        :class="[
+          'h-140px',
+          {
+            'w-full': property.layoutType !== 'oneColSmallImg',
+            'w-140px': property.layoutType === 'oneColSmallImg'
+          }
+        ]"
+      >
+        <el-image fit="cover" class="h-full w-full" :src="spu.picUrl" />
+      </div>
+      <div
+        :class="[
+          ' flex flex-col gap-8px p-8px box-border',
+          {
+            'w-full': property.layoutType !== 'oneColSmallImg',
+            'w-[calc(100%-140px-16px)]': property.layoutType === 'oneColSmallImg'
+          }
+        ]"
+      >
+        <!-- 商品名称 -->
+        <div
+          v-if="property.fields.name.show"
+          :class="[
+            'text-14px ',
+            {
+              truncate: property.layoutType !== 'oneColSmallImg',
+              'overflow-ellipsis line-clamp-2': property.layoutType === 'oneColSmallImg'
+            }
+          ]"
+          :style="{ color: property.fields.name.color }"
+        >
+          {{ spu.name }}
+        </div>
+        <!-- 商品简介 -->
+        <div
+          v-if="property.fields.introduction.show"
+          class="truncate text-12px"
+          :style="{ color: property.fields.introduction.color }"
+        >
+          {{ spu.introduction }}
+        </div>
+        <div>
+          <!-- 价格 -->
+          <span
+            v-if="property.fields.price.show"
+            class="text-16px"
+            :style="{ color: property.fields.price.color }"
+          >
+            ¥{{ spu.price }}
+          </span>
+          <!-- 市场价 -->
+          <span
+            v-if="property.fields.marketPrice.show && spu.marketPrice"
+            class="ml-4px text-10px line-through"
+            :style="{ color: property.fields.marketPrice.color }"
+            >¥{{ spu.marketPrice }}</span
+          >
+        </div>
+        <div class="text-12px">
+          <!-- 销量 -->
+          <span
+            v-if="property.fields.salesCount.show"
+            :style="{ color: property.fields.salesCount.color }"
+          >
+            已售{{ (spu.salesCount || 0) + (spu.virtualSalesCount || 0) }}件
+          </span>
+          <!-- 库存 -->
+          <span v-if="property.fields.stock.show" :style="{ color: property.fields.stock.color }">
+            库存{{ spu.stock || 0 }}
+          </span>
+        </div>
+      </div>
+      <!-- 购买按钮 -->
+      <div class="absolute bottom-8px right-8px">
+        <!-- 文字按钮 -->
+        <span
+          v-if="property.btnBuy.type === 'text'"
+          class="rounded-full p-x-12px p-y-4px text-12px text-white"
+          :style="{
+            background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`
+          }"
+        >
+          {{ property.btnBuy.text }}
+        </span>
+        <!-- 图片按钮 -->
+        <el-image
+          v-else
+          class="h-28px w-28px rounded-full"
+          fit="cover"
+          :src="property.btnBuy.imgUrl"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+<script setup lang="ts">
+import { ProductCardProperty } from './config'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+
+/** 商品卡片 */
+defineOptions({ name: 'ProductCard' })
+// 定义属性
+const props = defineProps<{ property: ProductCardProperty }>()
+// 商品列表
+const spuList = ref<ProductSpuApi.Spu[]>([])
+watch(
+  () => props.property.spuIds,
+  async () => {
+    spuList.value = await ProductSpuApi.getSpuDetailList(props.property.spuIds)
+  },
+  {
+    immediate: true,
+    deep: true
+  }
+)
+/**
+ * 计算商品的间距
+ * @param index 商品索引
+ */
+const calculateSpace = (index: number) => {
+  // 商品的列数
+  const columns = props.property.layoutType === 'twoCol' ? 2 : 1
+  // 第一列没有左边距
+  const marginLeft = index % columns === 0 ? '0' : props.property.space + 'px'
+  // 第一行没有上边距
+  const marginTop = index < columns ? '0' : props.property.space + 'px'
+
+  return { marginLeft, marginTop }
+}
+
+// 容器
+const containerRef = ref()
+// 计算商品的宽度
+const calculateWidth = () => {
+  let width = '100%'
+  // 双列时每列的宽度为:(总宽度 - 间距)/ 2
+  if (props.property.layoutType === 'twoCol') {
+    width = `${(containerRef.value.offsetWidth - props.property.space) / 2}px`
+  }
+  return { width }
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 149 - 0
src/components/DiyEditor/components/mobile/ProductCard/property.vue

@@ -0,0 +1,149 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <el-form label-width="80px" :model="formData">
+      <el-card header="商品列表" class="property-group" shadow="never">
+        <SpuShowcase v-model="formData.spuIds" />
+      </el-card>
+      <el-card header="商品样式" class="property-group" shadow="never">
+        <el-form-item label="布局" prop="type">
+          <el-radio-group v-model="formData.layoutType">
+            <el-tooltip class="item" content="单列大图" placement="bottom">
+              <el-radio-button label="oneColBigImg">
+                <Icon icon="fluent:text-column-one-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip class="item" content="单列小图" placement="bottom">
+              <el-radio-button label="oneColSmallImg">
+                <Icon icon="fluent:text-column-two-left-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip class="item" content="双列" placement="bottom">
+              <el-radio-button label="twoCol">
+                <Icon icon="fluent:text-column-two-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="商品名称" prop="fields.name.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.name.color" />
+            <el-checkbox v-model="formData.fields.name.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品简介" prop="fields.introduction.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.introduction.color" />
+            <el-checkbox v-model="formData.fields.introduction.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品价格" prop="fields.price.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.price.color" />
+            <el-checkbox v-model="formData.fields.price.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="市场价" prop="fields.marketPrice.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.marketPrice.color" />
+            <el-checkbox v-model="formData.fields.marketPrice.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品销量" prop="fields.salesCount.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.salesCount.color" />
+            <el-checkbox v-model="formData.fields.salesCount.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品库存" prop="fields.stock.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.stock.color" />
+            <el-checkbox v-model="formData.fields.stock.show" />
+          </div>
+        </el-form-item>
+      </el-card>
+      <el-card header="角标" class="property-group" shadow="never">
+        <el-form-item label="角标" prop="badge.show">
+          <el-switch v-model="formData.badge.show" />
+        </el-form-item>
+        <el-form-item label="角标" prop="badge.imgUrl" v-if="formData.badge.show">
+          <UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px">
+            <template #tip> 建议尺寸:36 * 22 </template>
+          </UploadImg>
+        </el-form-item>
+      </el-card>
+      <el-card header="按钮" class="property-group" shadow="never">
+        <el-form-item label="按钮类型" prop="btnBuy.type">
+          <el-radio-group v-model="formData.btnBuy.type">
+            <el-radio-button label="text">文字</el-radio-button>
+            <el-radio-button label="img">图片</el-radio-button>
+          </el-radio-group>
+        </el-form-item>
+        <template v-if="formData.btnBuy.type === 'text'">
+          <el-form-item label="按钮文字" prop="btnBuy.text">
+            <el-input v-model="formData.btnBuy.text" />
+          </el-form-item>
+          <el-form-item label="左侧背景" prop="btnBuy.bgBeginColor">
+            <ColorInput v-model="formData.btnBuy.bgBeginColor" />
+          </el-form-item>
+          <el-form-item label="右侧背景" prop="btnBuy.bgEndColor">
+            <ColorInput v-model="formData.btnBuy.bgEndColor" />
+          </el-form-item>
+        </template>
+        <template v-else>
+          <el-form-item label="图片" prop="btnBuy.imgUrl">
+            <UploadImg v-model="formData.btnBuy.imgUrl" height="56px" width="56px">
+              <template #tip> 建议尺寸:56 * 56 </template>
+            </UploadImg>
+          </el-form-item>
+        </template>
+      </el-card>
+      <el-card header="商品样式" class="property-group" shadow="never">
+        <el-form-item label="上圆角" prop="borderRadiusTop">
+          <el-slider
+            v-model="formData.borderRadiusTop"
+            :max="100"
+            :min="0"
+            show-input
+            input-size="small"
+            :show-input-controls="false"
+          />
+        </el-form-item>
+        <el-form-item label="下圆角" prop="borderRadiusBottom">
+          <el-slider
+            v-model="formData.borderRadiusBottom"
+            :max="100"
+            :min="0"
+            show-input
+            input-size="small"
+            :show-input-controls="false"
+          />
+        </el-form-item>
+        <el-form-item label="间隔" prop="space">
+          <el-slider
+            v-model="formData.space"
+            :max="100"
+            :min="0"
+            show-input
+            input-size="small"
+            :show-input-controls="false"
+          />
+        </el-form-item>
+      </el-card>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import { ProductCardProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import SpuShowcase from '@/views/mall/product/spu/components/SpuShowcase.vue'
+
+// 商品卡片属性面板
+defineOptions({ name: 'ProductCardProperty' })
+
+const props = defineProps<{ modelValue: ProductCardProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+</script>
+
+<style scoped lang="scss"></style>

+ 43 - 0
src/components/DiyEditor/components/mobile/SearchBar/config.ts

@@ -0,0 +1,43 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 搜索框属性 */
+export interface SearchProperty {
+  height: number // 搜索栏高度
+  showScan: boolean // 显示扫一扫
+  borderRadius: number // 框体样式
+  placeholder: string // 占位文字
+  placeholderPosition: PlaceholderPosition // 占位文字位置
+  backgroundColor: string // 框体颜色
+  textColor: string // 字体颜色
+  hotKeywords: string[] // 热词
+  style: ComponentStyle
+}
+
+// 文字位置
+export type PlaceholderPosition = 'left' | 'center'
+
+// 定义组件
+export const component = {
+  id: 'SearchBar',
+  name: '搜索框',
+  icon: 'ep:search',
+  property: {
+    height: 28,
+    showScan: false,
+    borderRadius: 0,
+    placeholder: '搜索商品',
+    placeholderPosition: 'left',
+    backgroundColor: 'rgb(238, 238, 238)',
+    textColor: 'rgb(150, 151, 153)',
+    hotKeywords: [],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8,
+      paddingTop: 8,
+      paddingRight: 8,
+      paddingBottom: 8,
+      paddingLeft: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<SearchProperty>

+ 75 - 0
src/components/DiyEditor/components/mobile/SearchBar/index.vue

@@ -0,0 +1,75 @@
+<template>
+  <div
+    class="search-bar"
+    :style="{
+      color: property.textColor
+    }"
+  >
+    <!-- 搜索框 -->
+    <div
+      class="inner"
+      :style="{
+        height: `${property.height}px`,
+        background: property.backgroundColor,
+        borderRadius: `${property.borderRadius}px`
+      }"
+    >
+      <div
+        class="placeholder"
+        :style="{
+          justifyContent: property.placeholderPosition
+        }"
+      >
+        <Icon icon="ep:search" />
+        <span>{{ property.placeholder || '搜索商品' }}</span>
+      </div>
+      <div class="right">
+        <!-- 搜索热词 -->
+        <span v-for="(keyword, index) in property.hotKeywords" :key="index">{{ keyword }}</span>
+        <!-- 扫一扫 -->
+        <Icon icon="ant-design:scan-outlined" v-show="property.showScan" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { SearchProperty } from './config'
+/** 搜索框 */
+defineOptions({ name: 'SearchBar' })
+defineProps<{ property: SearchProperty }>()
+</script>
+
+<style scoped lang="scss">
+.search-bar {
+  /* 搜索框 */
+  .inner {
+    position: relative;
+    display: flex;
+    min-height: 28px;
+    font-size: 14px;
+    align-items: center;
+
+    .placeholder {
+      display: flex;
+      width: 100%;
+      padding: 0 8px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      word-break: break-all;
+      white-space: nowrap;
+      align-items: center;
+      gap: 2px;
+    }
+
+    .right {
+      position: absolute;
+      right: 8px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      gap: 8px;
+    }
+  }
+}
+</style>

+ 99 - 0
src/components/DiyEditor/components/mobile/SearchBar/property.vue

@@ -0,0 +1,99 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <el-text tag="p"> 搜索热词 </el-text>
+    <el-text type="info" size="small"> 拖动左侧的小圆点可以调整热词顺序 </el-text>
+
+    <!-- 表单 -->
+    <el-form label-width="80px" :model="formData" class="m-t-8px">
+      <div v-if="formData.hotKeywords.length">
+        <VueDraggable
+          :list="formData.hotKeywords"
+          item-key="index"
+          handle=".drag-icon"
+          :forceFallback="true"
+          :animation="200"
+        >
+          <template #item="{ index }">
+            <div class="mb-4px flex flex-row items-center gap-4px rounded bg-gray-100 p-8px">
+              <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
+              <el-input v-model="formData.hotKeywords[index]" placeholder="请输入热词" />
+              <Icon icon="ep:delete" class="text-red-500" @click="deleteHotWord(index)" />
+            </div>
+          </template>
+        </VueDraggable>
+      </div>
+      <el-form-item label-width="0">
+        <el-button @click="handleAddHotWord" type="primary" plain class="m-t-8px w-full">
+          添加热词
+        </el-button>
+      </el-form-item>
+      <el-form-item label="框体样式">
+        <el-radio-group v-model="formData!.borderRadius">
+          <el-tooltip content="方形" placement="top">
+            <el-radio-button :label="0">
+              <Icon icon="tabler:input-search" />
+            </el-radio-button>
+          </el-tooltip>
+          <el-tooltip content="圆形" placement="top">
+            <el-radio-button :label="10">
+              <Icon icon="iconoir:input-search" />
+            </el-radio-button>
+          </el-tooltip>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="提示文字" prop="placeholder">
+        <el-input v-model="formData.placeholder" />
+      </el-form-item>
+      <el-form-item label="文本位置" prop="placeholderPosition">
+        <el-radio-group v-model="formData!.placeholderPosition">
+          <el-tooltip content="居左" placement="top">
+            <el-radio-button label="left">
+              <Icon icon="ant-design:align-left-outlined" />
+            </el-radio-button>
+          </el-tooltip>
+          <el-tooltip content="居中" placement="top">
+            <el-radio-button label="center">
+              <Icon icon="ant-design:align-center-outlined" />
+            </el-radio-button>
+          </el-tooltip>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="扫一扫" prop="showScan">
+        <el-switch v-model="formData!.showScan" />
+      </el-form-item>
+      <el-form-item label="框体高度" prop="height">
+        <el-slider v-model="formData!.height" :max="50" :min="28" show-input input-size="small" />
+      </el-form-item>
+      <el-form-item label="框体颜色" prop="backgroundColor">
+        <ColorInput v-model="formData.backgroundColor" />
+      </el-form-item>
+      <el-form-item class="lef" label="文本颜色" prop="textColor">
+        <ColorInput v-model="formData.textColor" />
+      </el-form-item>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import VueDraggable from 'vuedraggable'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import { SearchProperty } from '@/components/DiyEditor/components/mobile/SearchBar/config'
+
+/** 搜索框属性面板 */
+defineOptions({ name: 'SearchProperty' })
+
+const props = defineProps<{ modelValue: SearchProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+/* 添加热词 */
+const handleAddHotWord = () => {
+  formData.value.hotKeywords.push('')
+}
+/* 删除热词 */
+const deleteHotWord = (index: number) => {
+  formData.value.hotKeywords.splice(index, 1)
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 97 - 0
src/components/DiyEditor/components/mobile/TabBar/config.ts

@@ -0,0 +1,97 @@
+import { DiyComponent } from '@/components/DiyEditor/util'
+
+/** 底部导航菜单属性 */
+export interface TabBarProperty {
+  // 选项列表
+  items: TabBarItemProperty[]
+  // 主题
+  theme: string
+  // 样式
+  style: TabBarStyle
+}
+
+// 选项属性
+export interface TabBarItemProperty {
+  // 标签文字
+  text: string
+  // 链接
+  url: string
+  // 默认图标链接
+  iconUrl: string
+  // 选中的图标链接
+  activeIconUrl: string
+}
+
+// 样式
+export interface TabBarStyle {
+  // 背景类型
+  bgType: 'color' | 'img'
+  // 背景颜色
+  bgColor: string
+  // 图片链接
+  bgImg: string
+  // 默认颜色
+  color: string
+  // 选中的颜色
+  activeColor: string
+}
+
+// 定义组件
+export const component = {
+  id: 'TabBar',
+  name: '底部导航',
+  icon: 'fluent:table-bottom-row-16-filled',
+  property: {
+    theme: 'red',
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      color: '#282828',
+      activeColor: '#fc4141'
+    },
+    items: [
+      {
+        text: '首页',
+        url: '/pages/index/index',
+        iconUrl: 'http://mall.yudao.iocoder.cn/static/images/1-001.png',
+        activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/1-002.png'
+      },
+      {
+        text: '分类',
+        url: '/pages/index/category?id=3',
+        iconUrl: 'http://mall.yudao.iocoder.cn/static/images/2-001.png',
+        activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/2-002.png'
+      },
+      {
+        text: '购物车',
+        url: '/pages/index/cart',
+        iconUrl: 'http://mall.yudao.iocoder.cn/static/images/3-001.png',
+        activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/3-002.png'
+      },
+      {
+        text: '我的',
+        url: '/pages/index/user',
+        iconUrl: 'http://mall.yudao.iocoder.cn/static/images/4-001.png',
+        activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/4-002.png'
+      }
+    ]
+  }
+} as DiyComponent<TabBarProperty>
+
+export const THEME_LIST = [
+  { id: 'red', name: '中国红', icon: 'icon-park-twotone:theme', color: '#d10019' },
+  { id: 'orange', name: '桔橙', icon: 'icon-park-twotone:theme', color: '#f37b1d' },
+  { id: 'gold', name: '明黄', icon: 'icon-park-twotone:theme', color: '#fbbd08' },
+  { id: 'green', name: '橄榄绿', icon: 'icon-park-twotone:theme', color: '#8dc63f' },
+  { id: 'cyan', name: '天青', icon: 'icon-park-twotone:theme', color: '#1cbbb4' },
+  { id: 'blue', name: '海蓝', icon: 'icon-park-twotone:theme', color: '#0081ff' },
+  { id: 'purple', name: '姹紫', icon: 'icon-park-twotone:theme', color: '#6739b6' },
+  { id: 'brightRed', name: '嫣红', icon: 'icon-park-twotone:theme', color: '#e54d42' },
+  { id: 'forestGreen', name: '森绿', icon: 'icon-park-twotone:theme', color: '#39b54a' },
+  { id: 'mauve', name: '木槿', icon: 'icon-park-twotone:theme', color: '#9c26b0' },
+  { id: 'pink', name: '桃粉', icon: 'icon-park-twotone:theme', color: '#e03997' },
+  { id: 'brown', name: '棕褐', icon: 'icon-park-twotone:theme', color: '#a5673f' },
+  { id: 'grey', name: '玄灰', icon: 'icon-park-twotone:theme', color: '#8799a3' },
+  { id: 'gray', name: '草灰', icon: 'icon-park-twotone:theme', color: '#aaaaaa' },
+  { id: 'black', name: '墨黑', icon: 'icon-park-twotone:theme', color: '#333333' }
+]

+ 59 - 0
src/components/DiyEditor/components/mobile/TabBar/index.vue

@@ -0,0 +1,59 @@
+<template>
+  <div class="tab-bar">
+    <div
+      class="tab-bar-bg"
+      :style="{
+        background:
+          property.style.bgType === 'color'
+            ? property.style.bgColor
+            : `url(${property.style.bgImg})`,
+        backgroundSize: '100% 100%',
+        backgroundRepeat: 'no-repeat'
+      }"
+    >
+      <div v-for="(item, index) in property.items" :key="index" class="tab-bar-item">
+        <img :src="index === 0 ? item.activeIconUrl : item.iconUrl" alt="" />
+        <span :style="{ color: index === 0 ? property.style.activeColor : property.style.color }">
+          {{ item.text }}
+        </span>
+      </div>
+    </div>
+  </div>
+</template>
+<script setup lang="ts">
+import { TabBarProperty } from './config'
+
+/** 页面底部导航栏 */
+defineOptions({ name: 'TabBar' })
+
+defineProps<{ property: TabBarProperty }>()
+</script>
+<style lang="scss" scoped>
+.tab-bar {
+  z-index: 2;
+  width: 100%;
+
+  .tab-bar-bg {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: space-around;
+    padding: 8px 0;
+
+    .tab-bar-item {
+      display: flex;
+      width: 100%;
+      font-size: 12px;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+
+      img {
+        width: 26px;
+        height: 26px;
+        border-radius: 4px;
+      }
+    }
+  }
+}
+</style>

+ 145 - 0
src/components/DiyEditor/components/mobile/TabBar/property.vue

@@ -0,0 +1,145 @@
+<template>
+  <div class="tab-bar">
+    <!-- 表单 -->
+    <el-form :model="formData" label-width="80px">
+      <el-form-item label="主题" prop="theme">
+        <el-select v-model="formData!.theme" @change="handleThemeChange">
+          <el-option
+            v-for="(theme, index) in THEME_LIST"
+            :key="index"
+            :label="theme.name"
+            :value="theme.id"
+          >
+            <template #default>
+              <div class="flex items-center justify-between">
+                <Icon :icon="theme.icon" :color="theme.color" />
+                <span>{{ theme.name }}</span>
+              </div>
+            </template>
+          </el-option>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="默认颜色">
+        <ColorInput v-model="formData!.style.color" />
+      </el-form-item>
+      <el-form-item label="选中颜色">
+        <ColorInput v-model="formData!.style.activeColor" />
+      </el-form-item>
+      <el-form-item label="导航背景">
+        <el-radio-group v-model="formData!.style.bgType">
+          <el-radio-button label="color">纯色</el-radio-button>
+          <el-radio-button label="img">图片</el-radio-button>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="选择颜色" v-if="formData!.style.bgType === 'color'">
+        <ColorInput v-model="formData!.style.bgColor" />
+      </el-form-item>
+      <el-form-item label="选择图片" v-if="formData!.style.bgType === 'img'">
+        <UploadImg v-model="formData!.style.bgImg" width="100%" height="50px" class="min-w-200px">
+          <template #tip> 建议尺寸 375 * 50 </template>
+        </UploadImg>
+      </el-form-item>
+
+      <el-text tag="p">图标设置</el-text>
+      <el-text type="info" size="small"> 拖动左上角的小圆点可对其排序, 图标建议尺寸 44*44 </el-text>
+      <draggable
+        :list="formData!.items"
+        item-key="index"
+        :forceFallback="true"
+        :animation="200"
+        handle=".drag-icon"
+        class="m-t-8px"
+      >
+        <template #item="{ element, index }">
+          <div class="mb-4px flex flex-row gap-4px rounded bg-gray-100 p-8px">
+            <div class="flex flex-col items-start justify-between">
+              <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
+              <Icon
+                icon="ep:delete"
+                class="cursor-pointer text-red-5"
+                @click="handleDeleteItem(index)"
+                v-if="formData.items.length > 1"
+              />
+            </div>
+            <div class="w-full flex flex-col">
+              <div class="m-b-8px flex items-center justify-around">
+                <div class="flex flex-col items-center justify-between">
+                  <UploadImg
+                    v-model="element.iconUrl"
+                    width="40px"
+                    height="40px"
+                    :show-delete="false"
+                    :show-btn-text="false"
+                  />
+                  <el-text size="small">默认图片</el-text>
+                </div>
+                <div>
+                  <UploadImg
+                    v-model="element.activeIconUrl"
+                    width="40px"
+                    height="40px"
+                    :show-delete="false"
+                    :show-btn-text="false"
+                  />
+                  <el-text>选中图片</el-text>
+                </div>
+              </div>
+              <el-form-item prop="text" label-width="0" class="m-b-8px!">
+                <el-input v-model="element.text" placeholder="请输入文字" />
+              </el-form-item>
+              <el-form-item prop="url" label-width="0" class="m-b-0!">
+                <el-input v-model="element.url" placeholder="请选择链接" />
+              </el-form-item>
+            </div>
+          </div>
+        </template>
+      </draggable>
+
+      <el-form-item label-width="0">
+        <!-- 添加导航按钮 -->
+        <el-tooltip content="最多添加5个">
+          <el-button
+            @click="handleAddItem"
+            class="m-b-16px w-full"
+            type="primary"
+            plain
+            :disabled="formData!.items.length >= 5"
+          >
+            添加导航
+          </el-button>
+        </el-tooltip>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script setup lang="ts">
+import draggable from 'vuedraggable' //拖拽组件
+import { TabBarItemProperty, TabBarProperty, THEME_LIST } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+// 底部导航栏
+defineOptions({ name: 'TabBarProperty' })
+
+const props = defineProps<{ modelValue: TabBarProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+/** 添加导航项 */
+const handleAddItem = () => {
+  formData?.value?.items?.push({} as TabBarItemProperty)
+}
+/** 删除导航项 */
+const handleDeleteItem = (index: number) => {
+  formData?.value?.items?.splice(index, 1)
+}
+
+// 要的主题
+const handleThemeChange = () => {
+  const theme = THEME_LIST.find((theme) => theme.id === formData.value.theme)
+  if (theme?.color) {
+    formData.value.style.activeColor = theme.color
+  }
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 65 - 0
src/components/DiyEditor/components/mobile/TitleBar/config.ts

@@ -0,0 +1,65 @@
+import { DiyComponent } from '@/components/DiyEditor/util'
+
+/** 标题栏属性 */
+export interface TitleBarProperty {
+  // 主标题
+  title: string
+  // 副标题
+  description: string
+  // 标题大小
+  titleSize: number
+  // 描述大小
+  descriptionSize: number
+  // 标题粗细
+  titleWeight: number
+  // 显示位置
+  position: 'left' | 'center'
+  // 描述粗细
+  descriptionWeight: number
+  // 标题颜色
+  titleColor: string
+  // 描述颜色
+  descriptionColor: string
+  // 背景颜色
+  backgroundColor: string
+  // 底部分割线
+  showBottomBorder: false
+  // 查看更多
+  more: {
+    // 是否显示查看更多
+    show: false
+    // 样式选择
+    type: 'text' | 'icon' | 'all'
+    // 自定义文字
+    text: string
+    // 链接
+    url: string
+  }
+}
+
+// 定义组件
+export const component = {
+  id: 'TitleBar',
+  name: '标题栏',
+  icon: 'material-symbols:line-start',
+  property: {
+    title: '主标题',
+    description: '副标题',
+    titleSize: 16,
+    descriptionSize: 12,
+    titleWeight: 400,
+    position: 'left',
+    descriptionWeight: 200,
+    titleColor: 'rgba(50, 50, 51, 10)',
+    descriptionColor: 'rgba(150, 151, 153, 10)',
+    backgroundColor: 'rgba(255, 255, 255, 10)',
+    showBottomBorder: false,
+    more: {
+      //查看更多
+      show: false,
+      type: 'icon',
+      text: '查看更多',
+      url: ''
+    }
+  }
+} as DiyComponent<TitleBarProperty>

+ 80 - 0
src/components/DiyEditor/components/mobile/TitleBar/index.vue

@@ -0,0 +1,80 @@
+<template>
+  <div
+    class="title-bar"
+    :style="{
+      background: property.backgroundColor,
+      borderBottom: property.showBottomBorder ? '1px solid #F9F9F9' : '1px solid #fff'
+    }"
+  >
+    <div>
+      <!-- 标题 -->
+      <div
+        :style="{
+          fontSize: `${property.titleSize}px`,
+          fontWeight: property.titleWeight,
+          color: property.titleColor,
+          textAlign: property.position
+        }"
+        v-if="property.title"
+      >
+        {{ property.title }}
+      </div>
+      <!-- 副标题 -->
+      <div
+        :style="{
+          fontSize: `${property.descriptionSize}px`,
+          fontWeight: property.descriptionWeight,
+          color: property.descriptionColor,
+          textAlign: property.position
+        }"
+        class="m-t-8px"
+        v-if="property.description"
+      >
+        {{ property.description }}
+      </div>
+    </div>
+    <!-- 更多 -->
+    <div
+      class="more"
+      v-show="property.more.show"
+      :style="{
+        color: property.more.type === 'text' ? '#38f' : ''
+      }"
+    >
+      {{ property.more.type === 'icon' ? '' : property.more.text }}
+      <Icon icon="ep:arrow-right" v-if="property.more.type !== 'text'" />
+    </div>
+  </div>
+</template>
+<script setup lang="ts">
+import { TitleBarProperty } from './config'
+
+/** 标题栏 */
+defineOptions({ name: 'TitleBar' })
+
+defineProps<{ property: TitleBarProperty }>()
+</script>
+<style scoped lang="scss">
+.title-bar {
+  position: relative;
+  width: 100%;
+  min-height: 20px;
+  padding: 8px 16px;
+  border: 2px solid #fff;
+  box-sizing: border-box;
+
+  /* 更多 */
+  .more {
+    position: absolute;
+    top: 0;
+    right: 8px;
+    bottom: 0;
+    display: flex;
+    margin: auto;
+    font-size: 10px;
+    color: #969799;
+    align-items: center;
+    justify-content: center;
+  }
+}
+</style>

+ 115 - 0
src/components/DiyEditor/components/mobile/TitleBar/property.vue

@@ -0,0 +1,115 @@
+<template>
+  <section class="title-bar">
+    <el-form label-width="85px" :model="formData" :rules="rules">
+      <el-form-item label="主标题" prop="title">
+        <el-input
+          v-model="formData.title"
+          placeholder="请输入主标题"
+          show-word-limit
+          maxlength="20"
+        />
+      </el-form-item>
+      <el-form-item label="副标题" prop="description">
+        <el-input
+          type="textarea"
+          v-model="formData.description"
+          placeholder="请输入副标题"
+          maxlength="50"
+          show-word-limit
+        />
+      </el-form-item>
+      <el-form-item label="显示位置" prop="position">
+        <el-radio-group v-model="formData!.position">
+          <el-tooltip content="居左" placement="top">
+            <el-radio-button label="left">
+              <Icon icon="ant-design:align-left-outlined" />
+            </el-radio-button>
+          </el-tooltip>
+          <el-tooltip content="居中" placement="top">
+            <el-radio-button label="center">
+              <Icon icon="ant-design:align-center-outlined" />
+            </el-radio-button>
+          </el-tooltip>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="标题大小" prop="titleSize">
+        <el-slider v-model="formData.titleSize" :max="60" :min="10" show-input input-size="small" />
+      </el-form-item>
+      <el-form-item label="副标题大小" prop="descriptionSize">
+        <el-slider
+          v-model="formData.descriptionSize"
+          :max="60"
+          :min="10"
+          show-input
+          input-size="small"
+        />
+      </el-form-item>
+      <el-form-item label="标题粗细" prop="titleWeight">
+        <el-slider
+          v-model="formData.titleWeight"
+          :min="100"
+          :max="900"
+          :step="100"
+          show-input
+          input-size="small"
+        />
+      </el-form-item>
+      <el-form-item label="副标题粗细" prop="descriptionWeight">
+        <el-slider
+          v-model="formData.descriptionWeight"
+          :min="100"
+          :max="900"
+          :step="100"
+          show-input
+          input-size="small"
+        />
+      </el-form-item>
+      <el-form-item label="标题颜色" prop="titleColor">
+        <ColorInput v-model="formData.titleColor" />
+      </el-form-item>
+      <el-form-item label="副标题颜色" prop="descriptionColor">
+        <ColorInput v-model="formData.descriptionColor" />
+      </el-form-item>
+      <el-form-item label="背景颜色" prop="backgroundColor">
+        <ColorInput v-model="formData.backgroundColor" />
+      </el-form-item>
+      <el-form-item label="底部分割线" prop="showBottomBorder">
+        <el-switch v-model="formData!.showBottomBorder" />
+      </el-form-item>
+      <el-form-item label="查看更多" prop="more.show">
+        <el-checkbox v-model="formData.more.show" />
+      </el-form-item>
+      <!-- 更多样式选择 -->
+      <template v-if="formData.more.show">
+        <el-form-item label="样式" prop="more.type">
+          <el-radio-group v-model="formData.more.type">
+            <el-radio label="text">文字</el-radio>
+            <el-radio label="icon">图标</el-radio>
+            <el-radio label="all">文字+图标</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="更多文字" prop="more.text" v-show="formData.more.type !== 'icon'">
+          <el-input v-model="formData.more.text" />
+        </el-form-item>
+        <el-form-item label="跳转链接" prop="more.url">
+          <el-input v-model="formData.more.url" placeholder="请输入跳转链接" />
+        </el-form-item>
+      </template>
+    </el-form>
+  </section>
+</template>
+<script setup lang="ts">
+import { TitleBarProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+// 导航栏属性面板
+defineOptions({ name: 'TitleBarProperty' })
+
+const props = defineProps<{ modelValue: TitleBarProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+// 表单校验
+const rules = {}
+</script>
+
+<style scoped lang="scss"></style>

+ 37 - 0
src/components/DiyEditor/components/mobile/VideoPlayer/config.ts

@@ -0,0 +1,37 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 视频播放属性 */
+export interface VideoPlayerProperty {
+  // 视频链接
+  videoUrl: string
+  // 封面链接
+  posterUrl: string
+  // 是否自动播放
+  autoplay: boolean
+  // 组件样式
+  style: VideoPlayerStyle
+}
+
+// 视频播放样式
+export interface VideoPlayerStyle extends ComponentStyle {
+  // 视频高度
+  height: number
+}
+
+// 定义组件
+export const component = {
+  id: 'VideoPlayer',
+  name: '视频播放',
+  icon: 'ep:video-play',
+  property: {
+    videoUrl: '',
+    posterUrl: '',
+    autoplay: false,
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8,
+      height: 300
+    } as ComponentStyle
+  }
+} as DiyComponent<VideoPlayerProperty>

+ 30 - 0
src/components/DiyEditor/components/mobile/VideoPlayer/index.vue

@@ -0,0 +1,30 @@
+<template>
+  <div class="w-full" :style="{ height: `${property.style.height}px` }">
+    <el-image class="w-full w-full" :src="property.posterUrl" v-if="property.posterUrl" />
+    <video
+      v-else
+      class="w-full w-full"
+      :src="property.videoUrl"
+      :poster="property.posterUrl"
+      :autoplay="property.autoplay"
+      controls
+    ></video>
+  </div>
+</template>
+<script setup lang="ts">
+import { VideoPlayerProperty } from './config'
+
+/** 视频播放 */
+defineOptions({ name: 'VideoPlayer' })
+
+defineProps<{ property: VideoPlayerProperty }>()
+</script>
+
+<style scoped lang="scss">
+/* 图片 */
+img {
+  display: block;
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 55 - 0
src/components/DiyEditor/components/mobile/VideoPlayer/property.vue

@@ -0,0 +1,55 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <template #style="{ formData }">
+      <el-form-item label="高度" prop="height">
+        <el-slider
+          v-model="formData.height"
+          :max="500"
+          :min="100"
+          show-input
+          input-size="small"
+          :show-input-controls="false"
+        />
+      </el-form-item>
+    </template>
+    <el-form label-width="80px" :model="formData">
+      <el-form-item label="上传视频" prop="videoUrl">
+        <UploadFile
+          v-model="formData.videoUrl"
+          :file-type="['mp4']"
+          :limit="1"
+          :file-size="100"
+          class="min-w-80px"
+        />
+      </el-form-item>
+      <el-form-item label="上传封面" prop="posterUrl">
+        <UploadImg
+          v-model="formData.posterUrl"
+          draggable="false"
+          height="80px"
+          width="100%"
+          class="min-w-80px"
+        >
+          <template #tip> 建议宽度750 </template>
+        </UploadImg>
+      </el-form-item>
+      <el-form-item label="自动播放" prop="autoplay">
+        <el-switch v-model="formData.autoplay" />
+      </el-form-item>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import { VideoPlayerProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+
+// 视频播放属性面板
+defineOptions({ name: 'VideoPlayerProperty' })
+
+const props = defineProps<{ modelValue: VideoPlayerProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+</script>
+
+<style scoped lang="scss"></style>

+ 61 - 0
src/components/DiyEditor/components/mobile/index.ts

@@ -0,0 +1,61 @@
+/*
+ * 组件注册
+ *
+ * 组件规范:
+ * 1. 每个子目录就是一个独立的组件,每个目录包括以下三个文件:
+ * 2. config.ts:组件配置,必选,用于定义组件、组件默认的属性、定义属性的类型
+ * 3. index.vue:组件展示,用于展示组件的渲染效果。可以不提供,如 Page(页面设置),只需要属性配置表单即可
+ * 4. property.vue:组件属性表单,用于配置组件,必选,
+ *
+ * 注:
+ * 组件ID以config.ts中配置的id为准,与组件目录的名称无关,但还是建议组件目录的名称与组件ID保持一致
+ */
+
+// 导入组件界面模块
+const viewModules: Record<string, any> = import.meta.glob('./*/*.vue')
+// 导入配置模块
+const configModules: Record<string, any> = import.meta.glob('./*/config.ts', { eager: true })
+
+// 界面模块
+const components = {}
+// 组件配置模块
+const componentConfigs = {}
+
+// 组件界面的类型
+type ViewType = 'index' | 'property'
+
+/**
+ * 注册组件的界面模块
+ *
+ * @param componentId 组件ID
+ * @param configPath 配置模块的文件路径
+ * @param viewType 组件界面的类型
+ */
+const registerComponentViewModule = (
+  componentId: string,
+  configPath: string,
+  viewType: ViewType
+) => {
+  const viewPath = configPath.replace('config.ts', `${viewType}.vue`)
+  const viewModule = viewModules[viewPath]
+  if (viewModule) {
+    // 定义异步组件
+    components[componentId] = defineAsyncComponent(viewModule)
+  }
+}
+
+// 注册
+Object.keys(configModules).forEach((modulePath: string) => {
+  const component = configModules[modulePath].component
+  const componentId = component?.id
+  if (componentId) {
+    // 注册组件
+    componentConfigs[componentId] = component
+    // 注册预览界面
+    registerComponentViewModule(componentId, modulePath, 'index')
+    // 注册属性配置表单
+    registerComponentViewModule(`${componentId}Property`, modulePath, 'property')
+  }
+})
+
+export { components, componentConfigs }

+ 470 - 0
src/components/DiyEditor/index.vue

@@ -0,0 +1,470 @@
+<template>
+  <el-container class="editor">
+    <!-- 顶部:工具栏 -->
+    <el-header class="editor-header">
+      <!-- 左侧操作区 -->
+      <slot name="toolBarLeft"></slot>
+      <!-- 中心操作区 -->
+      <div class="header-center flex flex-1 items-center justify-center">
+        <span>{{ title }}</span>
+      </div>
+      <!-- 右侧操作区 -->
+      <el-button-group class="header-right">
+        <el-tooltip content="重置">
+          <el-button @click="handleReset">
+            <Icon icon="system-uicons:reset-alt" :size="24" />
+          </el-button>
+        </el-tooltip>
+        <el-tooltip content="预览">
+          <el-button @click="handlePreview">
+            <Icon icon="ep:view" :size="24" />
+          </el-button>
+        </el-tooltip>
+        <el-tooltip content="保存">
+          <el-button @click="handleSave">
+            <Icon icon="ep:check" :size="24" />
+          </el-button>
+        </el-tooltip>
+      </el-button-group>
+    </el-header>
+    <!-- 中心区域 -->
+    <el-container class="editor-container">
+      <!-- 左侧:组件库 -->
+      <ComponentLibrary ref="componentLibrary" :list="libs" v-if="libs && libs.length > 0" />
+      <!-- 中心设计区域 -->
+      <div class="editor-center page-prop-area" @click="handlePageSelected">
+        <!-- 手机顶部 -->
+        <div class="editor-design-top">
+          <!-- 手机顶部状态栏 -->
+          <img src="@/assets/imgs/diy/statusBar.png" alt="" class="status-bar" />
+          <!-- 手机顶部导航栏 -->
+          <ComponentContainer
+            v-if="showNavigationBar"
+            :component="navigationBarComponent"
+            :show-toolbar="false"
+            :active="selectedComponent?.id === navigationBarComponent.id"
+            @click="handleNavigationBarSelected"
+            class="cursor-pointer!"
+          />
+        </div>
+        <!-- 手机页面编辑区域 -->
+        <el-scrollbar
+          height="100%"
+          wrap-class="editor-design-center page-prop-area"
+          view-class="phone-container"
+          :view-style="{
+            backgroundColor: pageConfigComponent.property.backgroundColor,
+            backgroundImage: `url(${pageConfigComponent.property.backgroundImage})`
+          }"
+        >
+          <draggable
+            class="page-prop-area drag-area"
+            v-model="pageComponents"
+            item-key="index"
+            :animation="200"
+            filter=".component-toolbar"
+            ghost-class="draggable-ghost"
+            :force-fallback="true"
+            group="component"
+            @change="handleComponentChange"
+          >
+            <template #item="{ element, index }">
+              <ComponentContainer
+                :component="element"
+                :active="selectedComponentIndex === index"
+                :can-move-up="index > 0"
+                :can-move-down="index < pageComponents.length - 1"
+                @move="(direction) => handleMoveComponent(index, direction)"
+                @copy="handleCopyComponent(index)"
+                @delete="handleDeleteComponent(index)"
+                @click="handleComponentSelected(element, index)"
+              />
+            </template>
+          </draggable>
+        </el-scrollbar>
+        <!-- 手机底部导航 -->
+        <div v-if="showTabBar" :class="['editor-design-bottom', 'component', 'cursor-pointer!']">
+          <ComponentContainer
+            :component="tabBarComponent"
+            :show-toolbar="false"
+            :active="selectedComponent?.id === tabBarComponent.id"
+            @click="handleTabBarSelected"
+          />
+        </div>
+      </div>
+      <!-- 右侧属性面板 -->
+      <el-aside class="editor-right" width="350px" v-if="selectedComponent?.property">
+        <el-card
+          shadow="never"
+          body-class="h-[calc(100%-var(--el-card-padding)-var(--el-card-padding))]"
+          class="h-full"
+        >
+          <!-- 组件名称 -->
+          <template #header>
+            <div class="flex items-center gap-8px">
+              <Icon :icon="selectedComponent.icon" color="gray" />
+              <span>{{ selectedComponent.name }}</span>
+            </div>
+          </template>
+          <el-scrollbar
+            class="m-[calc(0px-var(--el-card-padding))]"
+            view-class="p-[var(--el-card-padding)] p-b-[calc(var(--el-card-padding)+var(--el-card-padding))] property"
+          >
+            <component
+              :is="selectedComponent.id + 'Property'"
+              v-model="selectedComponent.property"
+            />
+          </el-scrollbar>
+        </el-card>
+      </el-aside>
+    </el-container>
+  </el-container>
+</template>
+<script lang="ts">
+// 注册所有的组件
+import { components } from './components/mobile/index'
+export default {
+  components: { ...components }
+}
+</script>
+<script lang="ts" setup>
+import draggable from 'vuedraggable'
+import ComponentLibrary from './components/ComponentLibrary.vue'
+import { cloneDeep, includes } from 'lodash-es'
+import { component as PAGE_CONFIG_COMPONENT } from '@/components/DiyEditor/components/mobile/PageConfig/config'
+import { component as NAVIGATION_BAR_COMPONENT } from './components/mobile/NavigationBar/config'
+import { component as TAB_BAR_COMPONENT } from './components/mobile/TabBar/config'
+import { isString } from '@/utils/is'
+import { DiyComponent, DiyComponentLibrary, PageConfig } from '@/components/DiyEditor/util'
+import { componentConfigs } from '@/components/DiyEditor/components/mobile'
+
+/** 页面装修详情页 */
+defineOptions({ name: 'DiyPageDetail' })
+
+// 消息弹窗
+const message = useMessage()
+// 左侧组件库
+const componentLibrary = ref()
+// 页面设置组件
+const pageConfigComponent = ref<DiyComponent<any>>(cloneDeep(PAGE_CONFIG_COMPONENT))
+// 顶部导航栏
+const navigationBarComponent = ref<DiyComponent<any>>(cloneDeep(NAVIGATION_BAR_COMPONENT))
+// 底部导航菜单
+const tabBarComponent = ref<DiyComponent<any>>(cloneDeep(TAB_BAR_COMPONENT))
+
+// 选中的组件,默认选中顶部导航栏
+const selectedComponent = ref<DiyComponent<any>>()
+// 选中的组件索引
+const selectedComponentIndex = ref<number>(-1)
+// 组件列表
+const pageComponents = ref<DiyComponent<any>[]>([])
+// 定义属性
+const props = defineProps<{
+  // 页面配置,支持Json字符串
+  modelValue: string | PageConfig
+  // 标题
+  title: string
+  // 组件库
+  libs: DiyComponentLibrary[]
+  // 是否显示顶部导航栏
+  showNavigationBar: boolean
+  // 是否显示底部导航菜单
+  showTabBar: boolean
+  // 是否显示页面配置
+  showPageConfig: boolean
+}>()
+
+// 监听传入的页面配置
+watch(
+  () => props.modelValue,
+  () => {
+    const modelValue = isString(props.modelValue)
+      ? (JSON.parse(props.modelValue) as PageConfig)
+      : props.modelValue
+    pageConfigComponent.value.property = modelValue?.page || PAGE_CONFIG_COMPONENT.property
+    navigationBarComponent.value.property =
+      modelValue?.navigationBar || NAVIGATION_BAR_COMPONENT.property
+    tabBarComponent.value.property = modelValue?.tabBar || TAB_BAR_COMPONENT.property
+    // 查找对应的页面组件
+    pageComponents.value = (modelValue?.components || []).map((item) => {
+      const component = componentConfigs[item.id]
+      return { ...component, property: item.property }
+    })
+  },
+  {
+    immediate: true
+  }
+)
+// 保存
+const handleSave = () => {
+  const pageConfig = {
+    page: pageConfigComponent.value.property,
+    navigationBar: navigationBarComponent.value.property,
+    tabBar: tabBarComponent.value.property,
+    components: pageComponents.value.map((component) => {
+      // 只保留APP有用的字段
+      return { id: component.id, property: component.property }
+    })
+  } as PageConfig
+  if (!props.showTabBar) {
+    delete pageConfig.tabBar
+  }
+  // 发送数据更新通知
+  const modelValue = isString(props.modelValue) ? JSON.stringify(pageConfig) : pageConfig
+  emits('update:modelValue', modelValue)
+  // 发送保存通知
+  emits('save', pageConfig)
+}
+
+// 处理页面选中:显示属性表单
+const handlePageSelected = (event: any) => {
+  if (!props.showPageConfig) return
+
+  // 配置了样式 page-prop-area 的元素,才显示页面设置
+  if (includes(event?.target?.classList, 'page-prop-area')) {
+    handleComponentSelected(unref(pageConfigComponent))
+  }
+}
+
+/**
+ * 选中组件
+ *
+ * @param component 组件
+ * @param index 组件的索引
+ */
+const handleComponentSelected = (component: DiyComponent<any>, index: number = -1) => {
+  selectedComponent.value = component
+  selectedComponentIndex.value = index
+}
+
+// 选中顶部导航栏
+const handleNavigationBarSelected = () => {
+  handleComponentSelected(unref(navigationBarComponent))
+}
+
+// 选中底部导航菜单
+const handleTabBarSelected = () => {
+  handleComponentSelected(unref(tabBarComponent))
+}
+
+// 组件变动
+const handleComponentChange = (dragEvent: any) => {
+  // 新增,即从组件库拖拽添加组件
+  if (dragEvent.added) {
+    const { element, newIndex } = dragEvent.added
+    handleComponentSelected(element, newIndex)
+  } else if (dragEvent.moved) {
+    // 拖拽排序
+    const { newIndex } = dragEvent.moved
+    // 保持选中
+    selectedComponentIndex.value = newIndex
+  }
+}
+
+// 交换组件
+const swapComponent = (oldIndex: number, newIndex: number) => {
+  ;[pageComponents.value[oldIndex], pageComponents.value[newIndex]] = [
+    pageComponents.value[newIndex],
+    pageComponents.value[oldIndex]
+  ]
+  // 保持选中
+  selectedComponentIndex.value = newIndex
+}
+
+/** 移动组件 */
+const handleMoveComponent = (index: number, direction: number) => {
+  const newIndex = index + direction
+  if (newIndex < 0 || newIndex >= pageComponents.value.length) return
+
+  swapComponent(index, newIndex)
+}
+/** 复制组件 */
+const handleCopyComponent = (index: number) => {
+  const component = cloneDeep(pageComponents.value[index])
+  pageComponents.value.splice(index + 1, 0, component)
+}
+/**
+ * 删除组件
+ * @param index 当前组件index
+ */
+const handleDeleteComponent = (index: number) => {
+  // 删除组件
+  pageComponents.value.splice(index, 1)
+  if (index < pageComponents.value.length) {
+    // 1. 不是最后一个组件时,删除后选中下面的组件
+    let bottomIndex = index
+    handleComponentSelected(pageComponents.value[bottomIndex], bottomIndex)
+  } else if (pageComponents.value.length > 0) {
+    // 2. 不是第一个组件时,删除后选中上面的组件
+    let topIndex = index - 1
+    handleComponentSelected(pageComponents.value[topIndex], topIndex)
+  } else {
+    // 3. 组件全部删除之后,显示页面设置
+    handleComponentSelected(unref(pageConfigComponent))
+  }
+}
+
+// 工具栏操作
+const emits = defineEmits(['reset', 'preview', 'save', 'update:modelValue'])
+// 重置
+const handleReset = () => {
+  message.warning('开发中~')
+  emits('reset')
+}
+// 预览
+const handlePreview = () => {
+  message.warning('开发中~')
+  emits('preview')
+}
+
+// 设置默认选中的组件
+const setDefaultSelectedComponent = () => {
+  if (props.showPageConfig) {
+    selectedComponent.value = unref(pageConfigComponent)
+  } else if (props.showNavigationBar) {
+    selectedComponent.value = unref(navigationBarComponent)
+  } else if (props.showTabBar) {
+    selectedComponent.value = unref(tabBarComponent)
+  }
+}
+watch(
+  () => [props.showPageConfig, props.showNavigationBar, props.showTabBar],
+  () => setDefaultSelectedComponent()
+)
+onMounted(() => setDefaultSelectedComponent())
+</script>
+<style lang="scss" scoped>
+/* 手机宽度 */
+$phone-width: 375px;
+$toolbar-height: 42px;
+
+/* 根节点样式 */
+.editor {
+  display: flex;
+  height: 100%;
+  margin: calc(0px - var(--app-content-padding));
+  flex-direction: column;
+
+  /* 顶部:工具栏 */
+  .editor-header {
+    display: flex;
+    height: $toolbar-height;
+    padding: 0;
+    background-color: var(--el-bg-color);
+    border-bottom: solid 1px var(--el-border-color);
+    align-items: center;
+    justify-content: space-between;
+
+    /* 工具栏:右侧按钮 */
+    .header-right {
+      height: 100%;
+
+      .el-button {
+        height: 100%;
+      }
+    }
+
+    /* 隐藏工具栏按钮的边框 */
+    :deep(.el-radio-button__inner),
+    :deep(.el-button) {
+      border-top: none !important;
+      border-bottom: none !important;
+      border-radius: 0 !important;
+    }
+  }
+
+  /* 中心操作区 */
+  .editor-container {
+    height: calc(
+      100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) -
+        $toolbar-height
+    );
+
+    /* 右侧属性面板 */
+    .editor-right {
+      overflow: hidden;
+      box-shadow: -8px 0 8px -8px rgb(0 0 0 / 12%);
+      flex-shrink: 0;
+
+      /* 属性面板顶部:减少内边距 */
+      :deep(.el-card__header) {
+        padding: 8px 16px;
+      }
+
+      /* 属性面板分组 */
+      :deep(.property-group) {
+        margin: 0 -20px;
+
+        &.el-card {
+          border: none;
+        }
+
+        /* 属性分组名称 */
+        .el-card__header {
+          padding: 8px 32px;
+          background: var(--el-bg-color-page);
+          border: none;
+        }
+
+        .el-card__body {
+          border: none;
+        }
+      }
+    }
+
+    /* 中心区域 */
+    .editor-center {
+      position: relative;
+      display: flex;
+      width: 100%;
+      margin: 16px 0 0;
+      overflow: hidden;
+      background-color: var(--app-content-bg-color);
+      flex: 1 1 0;
+      flex-direction: column;
+      justify-content: center;
+
+      /* 手机顶部 */
+      .editor-design-top {
+        display: flex;
+        width: $phone-width;
+        margin: 0 auto;
+        flex-direction: column;
+
+        /* 手机顶部状态栏 */
+        .status-bar {
+          width: $phone-width;
+          height: 20px;
+          background-color: #fff;
+        }
+      }
+
+      /* 手机底部导航 */
+      .editor-design-bottom {
+        width: $phone-width;
+        margin: 0 auto;
+      }
+
+      /* 手机页面编辑区域 */
+      :deep(.editor-design-center) {
+        width: 100%;
+
+        /* 主体内容 */
+        .phone-container {
+          position: relative;
+          width: $phone-width;
+          height: 100%;
+          margin: 0 auto;
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+
+          .drag-area {
+            width: 100%;
+            height: 100%;
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 117 - 0
src/components/DiyEditor/util.ts

@@ -0,0 +1,117 @@
+import { ref, Ref } from 'vue'
+import { PageConfigProperty } from '@/components/DiyEditor/components/mobile/PageConfig/config'
+import { NavigationBarProperty } from '@/components/DiyEditor/components/mobile/NavigationBar/config'
+import { TabBarProperty } from '@/components/DiyEditor/components/mobile/TabBar/config'
+
+// 页面装修组件
+export interface DiyComponent<T> {
+  // 组件唯一标识
+  id: string
+  // 组件名称
+  name: string
+  // 组件图标
+  icon: string
+  // 组件属性
+  property: T
+}
+
+// 页面装修组件库
+export interface DiyComponentLibrary {
+  // 组件库名称
+  name: string
+  // 是否展开
+  extended: boolean
+  // 组件列表
+  components: string[]
+}
+
+// 组件样式
+export interface ComponentStyle {
+  // 背景类型
+  bgType: 'color' | 'img'
+  // 背景颜色
+  bgColor: string
+  // 背景图片
+  bgImg: string
+  // 外边距
+  margin: number
+  marginTop: number
+  marginRight: number
+  marginBottom: number
+  marginLeft: number
+  // 内边距
+  padding: number
+  paddingTop: number
+  paddingRight: number
+  paddingBottom: number
+  paddingLeft: number
+  // 边框圆角
+  borderRadius: number
+  borderTopLeftRadius: number
+  borderTopRightRadius: number
+  borderBottomRightRadius: number
+  borderBottomLeftRadius: number
+}
+
+// 页面配置
+export interface PageConfig {
+  // 页面属性
+  page: PageConfigProperty
+  // 顶部导航栏属性
+  navigationBar: NavigationBarProperty
+  // 底部导航菜单属性
+  tabBar?: TabBarProperty
+  // 页面组件列表
+  components: PageComponent[]
+}
+// 页面组件,只保留组件ID,组件属性
+export interface PageComponent extends Pick<DiyComponent<any>, 'id' | 'property'> {}
+
+// 属性表单监听
+export function usePropertyForm<T>(modelValue: T, emit: Function): { formData: Ref<T> } {
+  const formData = ref<T>()
+  // 监听属性数据变动
+  watch(
+    () => modelValue,
+    () => {
+      formData.value = modelValue
+    },
+    {
+      deep: true,
+      immediate: true
+    }
+  )
+  // 监听表单数据变动
+  watch(
+    () => formData.value,
+    () => {
+      emit('update:modelValue', formData.value)
+    },
+    {
+      deep: true
+    }
+  )
+
+  return { formData }
+}
+
+// 页面组件库
+export const PAGE_LIBS = [
+  {
+    name: '基础组件',
+    extended: true,
+    components: ['SearchBar', 'NoticeBar', 'MenuSwiper', 'MenuGrid', 'MenuList']
+  },
+  {
+    name: '图文组件',
+    extended: true,
+    components: ['ImageBar', 'Carousel', 'TitleBar', 'VideoPlayer', 'Divider', 'MagicCube']
+  },
+  { name: '商品组件', extended: true, components: ['ProductCard'] },
+  {
+    name: '会员组件',
+    extended: true,
+    components: ['UserCard', 'OrderCard', 'WalletCard', 'CouponCard']
+  },
+  { name: '营销组件', extended: true, components: ['Combination', 'Seckill', 'Point', 'Coupon'] }
+] as DiyComponentLibrary[]

+ 59 - 0
src/components/InputWithColor/index.vue

@@ -0,0 +1,59 @@
+<template>
+  <el-input v-model="valueRef" v-bind="$attrs">
+    <template #append>
+      <el-color-picker v-model="colorRef" :predefine="PREDEFINE_COLORS" />
+    </template>
+  </el-input>
+</template>
+
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import { PREDEFINE_COLORS } from '@/utils/color'
+
+/**
+ * 带颜色选择器输入框
+ */
+defineOptions({ name: 'InputWithColor' })
+
+const props = defineProps({
+  modelValue: propTypes.string.def('').isRequired,
+  color: propTypes.string.def('').isRequired
+})
+
+watch(
+  () => props.modelValue,
+  (val: string) => {
+    if (val === unref(valueRef)) return
+    valueRef.value = val
+  }
+)
+
+const emit = defineEmits(['update:modelValue', 'update:color'])
+
+// 输入框的值
+const valueRef = ref(props.modelValue)
+watch(
+  () => valueRef.value,
+  (val: string) => {
+    emit('update:modelValue', val)
+  }
+)
+// 颜色
+const colorRef = ref(props.color)
+watch(
+  () => colorRef.value,
+  (val: string) => {
+    emit('update:color', val)
+  }
+)
+</script>
+<style scoped lang="scss">
+:deep(.el-input-group__append) {
+  padding: 0;
+  .el-color-picker__trigger {
+    padding: 0;
+    border-left: none;
+    border-radius: 0 var(--el-input-border-radius) var(--el-input-border-radius) 0;
+  }
+}
+</style>

+ 270 - 0
src/components/MagicCubeEditor/index.vue

@@ -0,0 +1,270 @@
+<template>
+  <div class="relative">
+    <table class="cube-table">
+      <!-- 底层:魔方矩阵 -->
+      <tbody>
+        <tr v-for="(rowCubes, row) in cubes" :key="row">
+          <td
+            v-for="(cube, col) in rowCubes"
+            :key="col"
+            :class="['cube', { active: cube.active }]"
+            :style="{
+              width: `${cubeSize}px`,
+              height: `${cubeSize}px`
+            }"
+            @click="handleCubeClick(row, col)"
+            @mouseenter="handleCellHover(row, col)"
+          >
+            <Icon icon="ep-plus" />
+          </td>
+        </tr>
+      </tbody>
+      <!-- 顶层:热区 -->
+      <div
+        v-for="(hotArea, index) in hotAreas"
+        :key="index"
+        class="hot-area"
+        :style="{
+          top: `${cubeSize * hotArea.top}px`,
+          left: `${cubeSize * hotArea.left}px`,
+          height: `${cubeSize * hotArea.height}px`,
+          width: `${cubeSize * hotArea.width}px`
+        }"
+        @click="handleHotAreaSelected(hotArea, index)"
+        @mouseover="exitHotAreaSelectMode"
+      >
+        <!-- 右上角热区删除按钮 -->
+        <div
+          v-if="selectedHotAreaIndex === index"
+          class="btn-delete"
+          @click="handleDeleteHotArea(index)"
+        >
+          <Icon icon="ep:circle-close-filled" />
+        </div>
+        {{ `${hotArea.width}×${hotArea.height}` }}
+      </div>
+    </table>
+  </div>
+</template>
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import * as vueTypes from 'vue-types'
+import { Point, Rect, isContains, isOverlap, createRect } from './util'
+
+// 魔方编辑器
+// 有两部分组成:
+// 1. 魔方矩阵:位于底层,由方块组件的二维表格,用于创建热区
+//    操作方法:
+//    1.1 点击其中一个方块就会进入热区选择模式
+//    1.2 再次点击另外一个方块时,结束热区选择模式
+//    1.3 在两个方块中间的区域创建热区
+//    如果两次点击的都是同一方块,就只创建一个格子的热区
+// 2. 热区:位于顶层,采用绝对定位,覆盖在魔方矩阵上面。
+defineOptions({ name: 'MagicCubeEditor' })
+
+/**
+ * 方块
+ * @property active 是否激活
+ */
+type Cube = Point & { active: boolean }
+
+// 定义属性
+const props = defineProps({
+  // 热区列表
+  modelValue: vueTypes.array<any>().isRequired,
+  // 行数,默认 4 行
+  rows: propTypes.number.def(4),
+  // 列数,默认 4 列
+  cols: propTypes.number.def(4),
+  // 方块大小,单位px,默认75px
+  cubeSize: propTypes.number.def(75)
+})
+
+// 魔方矩阵:所有的方块
+const cubes = ref<Cube[][]>([])
+// 监听行数、列数变化
+watch(
+  () => [props.rows, props.cols],
+  () => {
+    // 清空魔方
+    cubes.value = []
+    if (!props.rows || !props.cols) return
+
+    // 初始化魔方
+    for (let row = 0; row < props.rows; row++) {
+      cubes.value[row] = []
+      for (let col = 0; col < props.cols; col++) {
+        cubes.value[row].push({ x: col, y: row, active: false })
+      }
+    }
+  },
+  { immediate: true }
+)
+
+// 热区列表
+const hotAreas = ref<Rect[]>([])
+// 初始化热区
+watch(
+  () => props.modelValue,
+  () => (hotAreas.value = props.modelValue || []),
+  { immediate: true }
+)
+
+// 热区起始方块
+const hotAreaBeginCube = ref<Cube>()
+// 是否开启了热区选择模式
+const isHotAreaSelectMode = () => !!hotAreaBeginCube.value
+/**
+ * 处理鼠标点击方块
+ *
+ * @param currentRow 当前行号
+ * @param currentCol 当前列号
+ */
+const handleCubeClick = (currentRow: number, currentCol: number) => {
+  const currentCube = cubes.value[currentRow][currentCol]
+  // 情况1:进入热区选择模式
+  if (!isHotAreaSelectMode()) {
+    hotAreaBeginCube.value = currentCube
+    hotAreaBeginCube.value.active = true
+    return
+  }
+
+  // 情况2:结束热区选择模式
+  hotAreas.value.push(createRect(hotAreaBeginCube.value!, currentCube))
+  // 结束热区选择模式
+  exitHotAreaSelectMode()
+  // 创建后就选中热区
+  let hotAreaIndex = hotAreas.value.length - 1
+  handleHotAreaSelected(hotAreas.value[hotAreaIndex], hotAreaIndex)
+  // 发送热区变动通知
+  emitUpdateModelValue()
+}
+/**
+ * 处理鼠标经过方块
+ *
+ * @param currentRow 当前行号
+ * @param currentCol 当前列号
+ */
+const handleCellHover = (currentRow: number, currentCol: number) => {
+  // 当前没有进入热区选择模式
+  if (!isHotAreaSelectMode()) return
+
+  // 当前已选的区域
+  const currentSelectedArea = createRect(
+    hotAreaBeginCube.value!,
+    cubes.value[currentRow][currentCol]
+  )
+  // 热区不允许重叠
+  for (const hotArea of hotAreas.value) {
+    // 检查是否重叠
+    if (isOverlap(hotArea, currentSelectedArea)) {
+      // 结束热区选择模式
+      exitHotAreaSelectMode()
+
+      return
+    }
+  }
+
+  // 激活选中区域内部的方块
+  eachCube((_, __, cube) => {
+    cube.active = isContains(currentSelectedArea, cube)
+  })
+}
+/**
+ * 处理热区删除
+ *
+ * @param index 热区索引
+ */
+const handleDeleteHotArea = (index: number) => {
+  hotAreas.value.splice(index, 1)
+  // 结束热区选择模式
+  exitHotAreaSelectMode()
+  // 发送热区变动通知
+  emitUpdateModelValue()
+}
+
+// 发送模型更新
+const emit = defineEmits(['update:modelValue', 'hotAreaSelected'])
+// 发送热区变动通知
+const emitUpdateModelValue = () => emit('update:modelValue', hotAreas)
+
+// 热区选中
+const selectedHotAreaIndex = ref(-1)
+const handleHotAreaSelected = (hotArea: Rect, index: number) => {
+  selectedHotAreaIndex.value = index
+  emit('hotAreaSelected', hotArea, index)
+}
+
+/**
+ * 结束热区选择模式
+ */
+function exitHotAreaSelectMode() {
+  // 移除方块激活标记
+  eachCube((_, __, cube) => {
+    if (cube.active) {
+      cube.active = false
+    }
+  })
+
+  // 清除起点
+  hotAreaBeginCube.value = undefined
+}
+
+/**
+ * 迭代魔方矩阵
+ * @param callback 回调
+ */
+const eachCube = (callback: (x: number, y: number, cube: Cube) => void) => {
+  for (let x = 0; x < cubes.value.length; x++) {
+    for (let y = 0; y < cubes.value[x].length; y++) {
+      callback(x, y, cubes.value[x][y])
+    }
+  }
+}
+</script>
+<style lang="scss" scoped>
+.cube-table {
+  position: relative;
+  border-spacing: 0;
+  border-collapse: collapse;
+
+  .cube {
+    border: 1px solid var(--el-border-color);
+    text-align: center;
+    color: var(--el-text-color-secondary);
+    cursor: pointer;
+    box-sizing: border-box;
+    &.active {
+      background: var(--el-color-primary-light-9);
+    }
+  }
+
+  .hot-area {
+    position: absolute;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border: 1px solid var(--el-color-primary);
+    background: var(--el-color-primary-light-8);
+    color: var(--el-color-primary);
+    box-sizing: border-box;
+    border-spacing: 0;
+    border-collapse: collapse;
+    cursor: pointer;
+
+    .btn-delete {
+      z-index: 1;
+      position: absolute;
+      top: -8px;
+      right: -8px;
+      height: 16px;
+      width: 16px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      border-radius: 50%;
+      background-color: #fff;
+    }
+  }
+}
+</style>

+ 72 - 0
src/components/MagicCubeEditor/util.ts

@@ -0,0 +1,72 @@
+// 坐标点
+export interface Point {
+  x: number
+  y: number
+}
+
+// 矩形
+export interface Rect {
+  // 左上角 X 轴坐标
+  left: number
+  // 左上角 Y 轴坐标
+  top: number
+  // 右下角 X 轴坐标
+  right: number
+  // 右下角 Y 轴坐标
+  bottom: number
+  // 矩形宽度
+  width: number
+  // 矩形高度
+  height: number
+}
+
+/**
+ * 判断两个矩形是否重叠
+ * @param a 矩形 A
+ * @param b 矩形 B
+ */
+export const isOverlap = (a: Rect, b: Rect): boolean => {
+  return (
+    a.left < b.left + b.width &&
+    a.left + a.width > b.left &&
+    a.top < b.top + b.height &&
+    a.height + a.top > b.top
+  )
+}
+/**
+ * 检查坐标点是否在矩形内
+ * @param hotArea 矩形
+ * @param point 坐标
+ */
+export const isContains = (hotArea: Rect, point: Point): boolean => {
+  return (
+    point.x >= hotArea.left &&
+    point.x < hotArea.right &&
+    point.y >= hotArea.top &&
+    point.y < hotArea.bottom
+  )
+}
+
+/**
+ * 在两个坐标点中间,创建一个矩形
+ *
+ * 存在以下情况:
+ * 1. 两个坐标点是同一个位置,只占一个位置的正方形,宽高都为 1
+ * 2. X 轴坐标相同,只占一行的矩形,高度为 1
+ * 3. Y 轴坐标相同,只占一列的矩形,宽度为 1
+ * 4. 多行多列的矩形
+ *
+ * @param a 坐标点一
+ * @param b 坐标点二
+ */
+export const createRect = (a: Point, b: Point): Rect => {
+  // 计算矩形的范围
+  const [left, left2] = [a.x, b.x].sort()
+  const [top, top2] = [a.y, b.y].sort()
+  const right = left2 + 1
+  const bottom = top2 + 1
+  const height = bottom - top
+  const width = right - left
+
+  return { left, right, top, bottom, height, width }
+}

+ 41 - 16
src/components/UploadFile/src/UploadFile.vue

@@ -33,11 +33,10 @@
   </div>
 </template>
 <script lang="ts" setup>
-import { PropType } from 'vue'
-
 import { propTypes } from '@/utils/propTypes'
 import { getAccessToken, getTenantId } from '@/utils/auth'
 import type { UploadInstance, UploadUserFile, UploadProps, UploadRawFile } from 'element-plus'
+import { isArray, isString } from '@/utils/is'
 
 defineOptions({ name: 'UploadFile' })
 
@@ -45,10 +44,7 @@ const message = useMessage() // 消息弹窗
 const emit = defineEmits(['update:modelValue'])
 
 const props = defineProps({
-  modelValue: {
-    type: Array as PropType<UploadUserFile[]>,
-    required: true
-  },
+  modelValue: propTypes.oneOfType<string | string[]>([String, Array<String>]).isRequired,
   title: propTypes.string.def('文件上传'),
   updateUrl: propTypes.string.def(import.meta.env.VITE_UPLOAD_URL),
   fileType: propTypes.array.def(['doc', 'xls', 'ppt', 'txt', 'pdf']), // 文件类型, 例如['png', 'jpg', 'jpeg']
@@ -62,7 +58,7 @@ const props = defineProps({
 const valueRef = ref(props.modelValue)
 const uploadRef = ref<UploadInstance>()
 const uploadList = ref<UploadUserFile[]>([])
-const fileList = ref<UploadUserFile[]>(props.modelValue)
+const fileList = ref<UploadUserFile[]>([])
 const uploadNumber = ref<number>(0)
 const uploadHeaders = ref({
   Authorization: 'Bearer ' + getAccessToken(),
@@ -109,7 +105,7 @@ const handleFileSuccess: UploadProps['onSuccess'] = (res: any): void => {
     fileList.value = fileList.value.concat(uploadList.value)
     uploadList.value = []
     uploadNumber.value = 0
-    emit('update:modelValue', listToString(fileList.value))
+    emitUpdateModelValue()
   }
 }
 // 文件数超出提示
@@ -125,20 +121,49 @@ const handleRemove = (file) => {
   const findex = fileList.value.map((f) => f.name).indexOf(file.name)
   if (findex > -1) {
     fileList.value.splice(findex, 1)
-    emit('update:modelValue', listToString(fileList.value))
+    emitUpdateModelValue()
   }
 }
 const handlePreview: UploadProps['onPreview'] = (uploadFile) => {
   console.log(uploadFile)
 }
-// 对象转成指定字符串分隔
-const listToString = (list: UploadUserFile[], separator?: string) => {
-  let strs = ''
-  separator = separator || ','
-  for (let i in list) {
-    strs += list[i].url + separator
+
+// 监听模型绑定值变动
+watch(
+  () => props.modelValue,
+  () => {
+    const files: string[] = []
+    // 情况1:字符串
+    if (isString(props.modelValue)) {
+      // 情况1.1:逗号分隔的多值
+      if (props.modelValue.includes(',')) {
+        files.concat(props.modelValue.split(','))
+      } else if (props.modelValue.length > 0) {
+        files.push(props.modelValue)
+      }
+    } else if (isArray(props.modelValue)) {
+      // 情况2:字符串
+      files.concat(props.modelValue)
+    } else if (props.modelValue == null) {
+      // 情况3:undefined 不处理
+    } else {
+      throw new Error('不支持的 modelValue 类型')
+    }
+    fileList.value = files.map((url: string) => {
+      return { url, name: url.substring(url.lastIndexOf('/') + 1) } as UploadUserFile
+    })
+  },
+  { immediate: true }
+)
+// 发送文件链接列表更新
+const emitUpdateModelValue = () => {
+  // 情况1:数组结果
+  let result: string | string[] = fileList.value.map((file) => file.url!)
+  // 情况2:逗号分隔的字符串
+  if (isString(props.modelValue)) {
+    result = result.join(',')
   }
-  return strs != '' ? strs.substr(0, strs.length - 1) : ''
+  emit('update:modelValue', result)
 }
 </script>
 <style scoped lang="scss">

+ 9 - 5
src/components/UploadFile/src/UploadImg.vue

@@ -18,15 +18,15 @@
         <div class="upload-handle" @click.stop>
           <div class="handle-icon" @click="editImg">
             <Icon icon="ep:edit" />
-            <span>{{ t('action.edit') }}</span>
+            <span v-if="showBtnText">{{ t('action.edit') }}</span>
           </div>
           <div class="handle-icon" @click="imgViewVisible = true">
             <Icon icon="ep:zoom-in" />
-            <span>{{ t('action.detail') }}</span>
+            <span v-if="showBtnText">{{ t('action.detail') }}</span>
           </div>
-          <div class="handle-icon" @click="deleteImg">
+          <div class="handle-icon" @click="deleteImg" v-if="showDelete">
             <Icon icon="ep:delete" />
-            <span>{{ t('action.del') }}</span>
+            <span v-if="showBtnText">{{ t('action.del') }}</span>
           </div>
         </div>
       </template>
@@ -81,7 +81,11 @@ const props = defineProps({
   fileType: propTypes.array.def(['image/jpeg', 'image/png', 'image/gif']), // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"])
   height: propTypes.string.def('150px'), // 组件高度 ==> 非必传(默认为 150px)
   width: propTypes.string.def('150px'), // 组件宽度 ==> 非必传(默认为 150px)
-  borderradius: propTypes.string.def('8px') // 组件边框圆角 ==> 非必传(默认为 8px)
+  borderradius: propTypes.string.def('8px'), // 组件边框圆角 ==> 非必传(默认为 8px)
+  // 是否显示删除按钮
+  showDelete: propTypes.bool.def(true),
+  // 是否显示按钮文字
+  showBtnText: propTypes.bool.def(true)
 })
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗

+ 1 - 1
src/components/Verifition/src/Verify/VerifySlide.vue

@@ -277,7 +277,7 @@ const end = () => {
   endMovetime.value = +new Date()
   //判断是否重合
   if (status.value && isEnd.value == false) {
-    var moveLeftDistance = parseInt((moveBlockLeft.value || '').replace('px', ''))
+    var moveLeftDistance = parseInt((moveBlockLeft.value || '0').replace('px', ''))
     moveLeftDistance = (moveLeftDistance * 310) / parseInt(setSize.imgWidth)
     let data = {
       captchaType: captchaType.value,

+ 44 - 0
src/components/VerticalButtonGroup/index.vue

@@ -0,0 +1,44 @@
+<template>
+  <el-button-group v-bind="$attrs">
+    <slot></slot>
+  </el-button-group>
+</template>
+
+<script setup lang="ts">
+/**
+ * 垂直按钮组
+ * Element官方的按钮组只支持水平显示,通过重写样式实现垂直布局
+ */
+defineOptions({ name: 'VerticalButtonGroup' })
+</script>
+
+<style scoped lang="scss">
+.el-button-group {
+  display: inline-flex;
+  flex-direction: column;
+}
+
+.el-button-group > :deep(.el-button:first-child) {
+  border-bottom-color: var(--el-button-divide-border-color);
+  border-top-right-radius: var(--el-border-radius-base);
+  border-bottom-right-radius: 0;
+  border-bottom-left-radius: 0;
+}
+
+.el-button-group > :deep(.el-button:last-child) {
+  border-top-color: var(--el-button-divide-border-color);
+  border-top-right-radius: 0;
+  border-bottom-left-radius: var(--el-border-radius-base);
+  border-top-left-radius: 0;
+}
+
+.el-button-group :deep(.el-button--primary:not(:first-child, :last-child)) {
+  border-top-color: var(--el-button-divide-border-color);
+  border-bottom-color: var(--el-button-divide-border-color);
+}
+
+.el-button-group > :deep(.el-button:not(:last-child)) {
+  margin-right: 0;
+  margin-bottom: -1px;
+}
+</style>

+ 4 - 0
src/config/axios/service.ts

@@ -217,6 +217,10 @@ const refreshToken = async () => {
 const handleAuthorized = () => {
   const { t } = useI18n()
   if (!isRelogin.show) {
+    // 如果已经到重新登录页面则不进行弹窗提示
+    if (window.location.href.includes('login?redirect=')) {
+      return
+    }
     isRelogin.show = true
     ElMessageBox.confirm(t('sys.api.timeoutMessage'), t('common.confirmTitle'), {
       showCancelButton: false,

+ 1 - 1
src/layout/components/Footer/src/Footer.vue

@@ -19,6 +19,6 @@ const title = computed(() => appStore.getTitle)
     :class="prefixCls"
     class="h-[var(--app-footer-height)] bg-[var(--app-content-bg-color)] text-center leading-[var(--app-footer-height)] text-[var(--el-text-color-placeholder)] dark:bg-[var(--el-bg-color)]"
   >
-    <p style="font-size: 14px">Copyright ©2022-{{ title }}</p>
+    <span class="text-14px">Copyright ©2022-{{ title }}</span>
   </div>
 </template>

+ 1 - 1
src/layout/components/Message/src/Message.vue

@@ -88,8 +88,8 @@ onMounted(() => {
 }
 
 .message-list {
-  height: 400px;
   display: flex;
+  height: 400px;
   flex-direction: column;
 
   .message-item {

+ 56 - 0
src/router/modules/remaining.ts

@@ -459,6 +459,62 @@ const remainingRouter: AppRouteRecordRaw[] = [
         component: () => import('@/views/pay/cashier/index.vue')
       }
     ]
+  },
+  {
+    path: '/diy',
+    name: 'DiyCenter',
+    meta: { hidden: true },
+    component: Layout,
+    children: [
+      {
+        path: 'template/decorate/:id',
+        name: 'DiyTemplateDecorate',
+        meta: {
+          title: '模板装修',
+          noCache: true,
+          hidden: true
+        },
+        component: () => import('@/views/mall/promotion/diy/template/decorate.vue')
+      },
+      {
+        path: 'page/decorate/:id',
+        name: 'DiyPageDecorate',
+        meta: {
+          title: '页面装修',
+          noCache: true,
+          hidden: true
+        },
+        component: () => import('@/views/mall/promotion/diy/page/decorate.vue')
+      }
+    ]
+  },
+  {
+    path: '/crm',
+    component: Layout,
+    name: 'CrmCenter',
+    meta: { hidden: true },
+    children: [
+      {
+        path: 'customer/detail/:id',
+        name: 'CrmCustomerDetail',
+        meta: {
+          title: '客户详情',
+          noCache: true,
+          hidden: true
+        },
+        component: () => import('@/views/crm/customer/detail/index.vue')
+      },
+      {
+        path: 'contact/detail/:id',
+        name: 'CrmContactDetail',
+        meta: {
+          title: '联系人详情',
+          noCache: true,
+          hidden: true
+        },
+        component: () => import('@/views/crm/contact/detail/index.vue')
+      }
+    ]
   }
 ]
 

+ 21 - 0
src/utils/color.ts

@@ -151,3 +151,24 @@ const subtractLight = (color: string, amount: number) => {
   const c = cc < 0 ? 0 : cc
   return c.toString(16).length > 1 ? c.toString(16) : `0${c.toString(16)}`
 }
+
+// 预设颜色
+export const PREDEFINE_COLORS = [
+  '#ff4500',
+  '#ff8c00',
+  '#ffd700',
+  '#90ee90',
+  '#00ced1',
+  '#1e90ff',
+  '#c71585',
+  '#409EFF',
+  '#909399',
+  '#C0C4CC',
+  '#b7390b',
+  '#ff7800',
+  '#fad400',
+  '#5b8c5f',
+  '#00babd',
+  '#1f73c3',
+  '#711f57'
+]

+ 4 - 1
src/utils/dict.ts

@@ -144,6 +144,8 @@ export enum DICT_TYPE {
   PAY_REFUND_STATUS = 'pay_refund_status', // 退款订单状态
   PAY_NOTIFY_STATUS = 'pay_notify_status', // 商户支付回调状态
   PAY_NOTIFY_TYPE = 'pay_notify_type', // 商户支付回调状态
+  PAY_TRANSFER_STATUS = 'pay_transfer_status', // 转账订单状态
+  PAY_TRANSFER_TYPE = 'pay_transfer_type', // 转账订单状态
 
   // ========== MP 模块 ==========
   MP_AUTO_REPLY_REQUEST_MATCH = 'mp_auto_reply_request_match', // 自动回复请求匹配类型
@@ -192,5 +194,6 @@ export enum DICT_TYPE {
   CRM_RETURN_TYPE = 'crm_return_type',
   CRM_CUSTOMER_INDUSTRY = 'crm_customer_industry',
   CRM_CUSTOMER_LEVEL = 'crm_customer_level',
-  CRM_CUSTOMER_SOURCE = 'crm_customer_source'
+  CRM_CUSTOMER_SOURCE = 'crm_customer_source',
+  CRM_PRODUCT_STATUS = 'crm_product_status'
 }

+ 23 - 8
src/views/Login/components/LoginForm.vue

@@ -193,10 +193,10 @@ const loginData = reactive({
 })
 
 const socialList = [
-  { icon: 'ant-design:github-filled', type: 0 },
   { icon: 'ant-design:wechat-filled', type: 30 },
-  { icon: 'ant-design:alipay-circle-filled', type: 0 },
-  { icon: 'ant-design:dingtalk-circle-filled', type: 20 }
+  { icon: 'ant-design:dingtalk-circle-filled', type: 20 },
+  { icon: 'ant-design:github-filled', type: 0 },
+  { icon: 'ant-design:alipay-circle-filled', type: 0 }
 ]
 
 // 获取验证码
@@ -210,7 +210,7 @@ const getCode = async () => {
     verify.value.show()
   }
 }
-//获取租户ID
+// 获取租户 ID
 const getTenantId = async () => {
   if (loginData.tenantEnable === 'true') {
     const res = await LoginApi.getTenantIdByName(loginData.loginForm.tenantName)
@@ -230,6 +230,15 @@ const getCookie = () => {
     }
   }
 }
+// 根据域名,获得租户信息
+const getTenantByWebsite = async () => {
+  const website = location.host
+  const res = await LoginApi.getTenantByWebsite(website)
+  if (res) {
+    loginData.loginForm.tenantName = res.name
+    authUtil.setTenantId(res.id)
+  }
+}
 const loading = ref() // ElLoading.service 返回的实例
 // 登录
 const handleLogin = async (params) => {
@@ -278,10 +287,15 @@ const doSocialLogin = async (type: number) => {
   } else {
     loginLoading.value = true
     if (loginData.tenantEnable === 'true') {
-      await message.prompt('请输入租户名称', t('common.reminder')).then(async ({ value }) => {
-        const res = await LoginApi.getTenantIdByName(value)
-        authUtil.setTenantId(res)
-      })
+      // 尝试先通过 tenantName 获取租户
+      await getTenantId()
+      // 如果获取不到,则需要弹出提示,进行处理
+      if (!authUtil.getTenantId()) {
+        await message.prompt('请输入租户名称', t('common.reminder')).then(async ({ value }) => {
+          const res = await LoginApi.getTenantIdByName(value)
+          authUtil.setTenantId(res)
+        })
+      }
     }
     // 计算 redirectUri
     // tricky: type、redirect需要先encode一次,否则钉钉回调会丢失。
@@ -307,6 +321,7 @@ watch(
 )
 onMounted(() => {
   getCookie()
+  getTenantByWebsite()
 })
 </script>
 

+ 0 - 1
src/views/Profile/components/UserSocial.vue

@@ -66,7 +66,6 @@ const bindSocial = () => {
   socialBind(type, code, state).then(() => {
     message.success('绑定成功')
     emit('update:activeName', 'userSocial')
-    initSocial()
   })
 }
 

+ 2 - 3
src/views/crm/clue/ClueForm.vue

@@ -10,7 +10,7 @@
       <el-form-item label="线索名称" prop="name">
         <el-input v-model="formData.name" placeholder="请输入线索名称" />
       </el-form-item>
-      <!-- TODO 客户选择 -->
+      <!-- TODO wanwan 客户选择 -->
       <el-form-item label="客户" prop="customerId">
         <el-input v-model="formData.customerId" placeholder="请选择客户" />
       </el-form-item>
@@ -31,7 +31,7 @@
       <el-form-item label="地址" prop="address">
         <el-input v-model="formData.address" placeholder="请输入地址" />
       </el-form-item>
-      <!-- TODO 负责人选择 -->
+      <!-- TODO wanwan 负责人选择 -->
       <el-form-item label="负责人" prop="ownerUserId">
         <el-input v-model="formData.ownerUserId" placeholder="请输入负责人" />
       </el-form-item>
@@ -46,7 +46,6 @@
   </Dialog>
 </template>
 <script setup lang="ts">
-import { DICT_TYPE, getBoolDictOptions } from '@/utils/dict'
 import * as ClueApi from '@/api/crm/clue'
 
 const { t } = useI18n() // 国际化

+ 1 - 1
src/views/crm/clue/index.vue

@@ -96,7 +96,7 @@
         :formatter="dateFormatter"
         width="180px"
       />
-      <el-table-column label="操作" align="center">
+      <el-table-column label="操作" align="center" min-width="110" fixed="right">
         <template #default="scope">
           <el-button
             link

+ 115 - 0
src/views/crm/components/CrmPermissionForm.vue

@@ -0,0 +1,115 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle" width="30%">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+    >
+      <el-form-item v-if="formType === 'create'" label="选择人员" prop="userId">
+        <el-select v-model="formData.userId">
+          <el-option
+            v-for="item in userOptions"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="权限级别" prop="level">
+        <el-radio-group v-model="formData.level">
+          <!-- TODO @puhui999:搞个字典配置?然后这里 remove 掉负责人 -->
+          <el-radio :label="CrmPermissionLevelEnum.READ">只读</el-radio>
+          <el-radio :label="CrmPermissionLevelEnum.WRITE">读写</el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as UserApi from '@/api/system/user'
+import * as PermissionApi from '@/api/crm/permission'
+import { CrmPermissionLevelEnum } from './index'
+
+defineOptions({ name: 'CrmPermissionForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
+const formData = ref<PermissionApi.PermissionVO & { ids?: number[] }>({
+  userId: undefined, // 用户编号
+  bizType: undefined, // Crm 类型
+  bizId: undefined, // Crm 类型数据编号
+  level: undefined // 权限级别
+})
+const formRules = reactive({
+  userId: [{ required: true, message: '人员不能为空', trigger: 'blur' }],
+  level: [{ required: true, message: '权限级别不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: 'create' | 'update', bizType: number, bizId: number, ids?: number[]) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type) + '团队成员'
+  formType.value = type
+  resetForm(bizType, bizId)
+  // 修改时,设置数据
+  if (ids) {
+    formData.value.ids = ids
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value
+    if (formType.value === 'create') {
+      await PermissionApi.createPermission(unref(data))
+      message.success(t('common.createSuccess'))
+    } else {
+      await PermissionApi.updatePermission(unref(data))
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = (bizType: number, bizId: number) => {
+  formRef.value?.resetFields()
+  formData.value = {
+    userId: undefined, // 用户编号
+    bizType, // Crm 类型
+    bizId, // Crm 类型数据编号
+    level: undefined // 权限级别
+  }
+}
+onMounted(async () => {
+  // 获得用户列表
+  // TODO 芋艿:用户列表的选择组件
+  userOptions.value = await UserApi.getSimpleUserList()
+})
+</script>

+ 156 - 0
src/views/crm/components/CrmTeamList.vue

@@ -0,0 +1,156 @@
+<template>
+  <!-- 操作栏 -->
+  <el-row justify="end">
+    <el-button type="primary" @click="handleAdd">
+      <Icon class="mr-5px" icon="ep:plus" />
+      新增
+    </el-button>
+    <el-button @click="handleEdit">
+      <Icon class="mr-5px" icon="ep:edit" />
+      编辑
+    </el-button>
+    <el-button @click="handleRemove">
+      <Icon class="mr-5px" icon="ep:delete" />
+      移除
+    </el-button>
+    <el-button type="danger" @click="handleQuit"> 退出团队</el-button>
+  </el-row>
+  <!--  团队成员展示 -->
+  <el-table
+    v-loading="loading"
+    :data="list"
+    :show-overflow-tooltip="true"
+    :stripe="true"
+    class="mt-20px"
+    @selection-change="handleSelectionChange"
+  >
+    <el-table-column type="selection" width="55" />
+    <el-table-column align="center" label="姓名" prop="nickname" />
+    <el-table-column align="center" label="部门" prop="deptName" />
+    <el-table-column align="center" label="岗位" prop="postNames" />
+    <el-table-column align="center" label="权限级别" prop="level">
+      <template #default="{ row }">
+        <el-tag>{{ getLevelName(row.level) }}</el-tag>
+      </template>
+    </el-table-column>
+    <el-table-column :formatter="dateFormatter" align="center" label="加入时间" prop="createTime" />
+  </el-table>
+  <CrmPermissionForm ref="crmPermissionFormRef" />
+</template>
+<script lang="ts" setup>
+// TODO @puhui999:改成 CrmPermissionList
+import { dateFormatter } from '@/utils/formatTime'
+import { ElTable } from 'element-plus'
+import * as PermissionApi from '@/api/crm/permission'
+import { useUserStoreWithOut } from '@/store/modules/user'
+import CrmPermissionForm from './CrmPermissionForm.vue'
+import { CrmPermissionLevelEnum } from './index'
+
+defineOptions({ name: 'CrmTeam' })
+
+const message = useMessage() // 消息
+
+const props = defineProps<{
+  bizType: number
+  bizId: number
+}>()
+const loading = ref(true) // 列表的加载中
+const list = ref<PermissionApi.PermissionVO[]>([
+  // TODO 测试数据
+  {
+    id: 1, // 数据权限编号
+    userId: 1, // 用户编号
+    bizType: 1, // Crm 类型
+    bizId: 1, // Crm 类型数据编号
+    level: 1, // 权限级别
+    deptName: '研发部门', // 部门名称
+    nickname: '芋道源码', // 用户昵称
+    postNames: '全栈开发工程师', // 岗位名称数组
+    createTime: new Date()
+  }
+]) // 列表的数据
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await PermissionApi.getPermissionList({
+      bizType: props.bizType,
+      bizId: props.bizId
+    })
+    list.value = data
+  } finally {
+    loading.value = false
+  }
+}
+
+// TODO @puhui999:字典格式化
+/**
+ * 获得权限级别名称
+ * @param level 权限级别
+ */
+const getLevelName = computed(() => (level: number) => {
+  switch (level) {
+    case CrmPermissionLevelEnum.OWNER:
+      return '负责人'
+    case CrmPermissionLevelEnum.READ:
+      return '只读'
+    case CrmPermissionLevelEnum.WRITE:
+      return '读写'
+    default:
+      break
+  }
+})
+// TODO @puhui999:空行稍微注意下哈;一些注释补齐下;
+const multipleSelection = ref<PermissionApi.PermissionVO[]>([])
+const handleSelectionChange = (val: PermissionApi.PermissionVO[]) => {
+  multipleSelection.value = val
+}
+// TODO @puhui999:一些变量命名,看看有没可能跟列表界面的 index.vue 保持他统一的风格;
+const crmPermissionFormRef = ref<InstanceType<typeof CrmPermissionForm>>()
+const handleEdit = () => {
+  if (multipleSelection.value?.length === 0) {
+    message.warning('请先选择团队成员后操作!')
+    return
+  }
+  const ids = multipleSelection.value?.map((item) => item.id)
+  crmPermissionFormRef.value?.open('update', props.bizType, props.bizId, ids)
+}
+const handleRemove = async () => {
+  if (multipleSelection.value?.length === 0) {
+    message.warning('请先选择团队成员后操作!')
+    return
+  }
+  await message.delConfirm()
+  const ids = multipleSelection.value?.map((item) => item.id)
+  await PermissionApi.deletePermission({
+    bizType: props.bizType,
+    bizId: props.bizId,
+    ids
+  })
+}
+const handleAdd = () => {
+  crmPermissionFormRef.value?.open('create', props.bizType, props.bizId)
+}
+
+const userStore = useUserStoreWithOut()
+const handleQuit = async () => {
+  const permission = list.value.find(
+    (item) => item.userId === userStore.getUser.id && item.level === CrmPermissionLevelEnum.OWNER
+  )
+  if (permission) {
+    message.warning('负责人不能退出团队!')
+    return
+  }
+  const userPermission = list.value.find((item) => item.userId === userStore.getUser.id)
+  await PermissionApi.quitTeam(userPermission?.id)
+}
+
+watch(
+  () => props.bizId,
+  () => {
+    getList()
+  },
+  { immediate: true, deep: true }
+)
+</script>

+ 17 - 0
src/views/crm/components/index.ts

@@ -0,0 +1,17 @@
+import CrmTeam from './CrmTeamList.vue'
+
+enum CrmBizTypeEnum {
+  CRM_LEADS = 1, // 线索
+  CRM_CUSTOMER = 2, // 客户
+  CRM_CONTACTS = 3, // 联系人
+  CRM_BUSINESS = 5, // 商机
+  CRM_CONTRACT = 6 // 合同
+}
+
+enum CrmPermissionLevelEnum {
+  OWNER = 1, // 负责人
+  READ = 2, // 读
+  WRITE = 3 // 写
+}
+
+export { CrmTeam, CrmBizTypeEnum, CrmPermissionLevelEnum }

Some files were not shown because too many files changed in this diff