Quellcode durchsuchen

Merge remote-tracking branch 'origin/dev' into dev

Wanwan vor 1 Jahr
Ursprung
Commit
57474c0644
72 geänderte Dateien mit 3525 neuen und 833 gelöschten Zeilen
  1. 3 0
      .vscode/settings.json
  2. 16 17
      package.json
  3. 43 0
      src/api/crm/product/index.ts
  4. 33 0
      src/api/crm/productCategory/index.ts
  5. 1 1
      src/api/crm/receivablePlan/index.ts
  6. 5 0
      src/api/login/index.ts
  7. 37 0
      src/api/system/social/client/index.ts
  8. 24 0
      src/api/system/social/user/index.ts
  9. 1 1
      src/components/Card/src/CardTitle.vue
  10. 6 7
      src/components/ConfigGlobal/src/ConfigGlobal.vue
  11. 222 0
      src/components/DiyEditor/components/ComponentContainer.vue
  12. 163 0
      src/components/DiyEditor/components/ComponentContainerProperty.vue
  13. 2 1
      src/components/DiyEditor/components/ComponentLibrary.vue
  14. 32 26
      src/components/DiyEditor/components/mobile/Carousel/config.ts
  15. 27 59
      src/components/DiyEditor/components/mobile/Carousel/index.vue
  16. 116 99
      src/components/DiyEditor/components/mobile/Carousel/property.vue
  17. 27 0
      src/components/DiyEditor/components/mobile/ImageBar/config.ts
  18. 24 0
      src/components/DiyEditor/components/mobile/ImageBar/index.vue
  19. 34 0
      src/components/DiyEditor/components/mobile/ImageBar/property.vue
  20. 1 1
      src/components/DiyEditor/components/mobile/NavigationBar/config.ts
  21. 14 6
      src/components/DiyEditor/components/mobile/SearchBar/config.ts
  22. 1 6
      src/components/DiyEditor/components/mobile/SearchBar/index.vue
  23. 72 73
      src/components/DiyEditor/components/mobile/SearchBar/property.vue
  24. 37 0
      src/components/DiyEditor/components/mobile/VideoPlayer/config.ts
  25. 30 0
      src/components/DiyEditor/components/mobile/VideoPlayer/index.vue
  26. 55 0
      src/components/DiyEditor/components/mobile/VideoPlayer/property.vue
  27. 108 249
      src/components/DiyEditor/index.vue
  28. 62 1
      src/components/DiyEditor/util.ts
  29. 1 1
      src/components/ShortcutDateRangePicker/index.vue
  30. 2 2
      src/components/Sticky/src/Sticky.vue
  31. 2 2
      src/components/Tooltip/src/Tooltip.vue
  32. 39 16
      src/components/UploadFile/src/UploadFile.vue
  33. 40 0
      src/components/VerticalButtonGroup/index.vue
  34. 49 0
      src/hooks/web/useGuide.ts
  35. 0 47
      src/hooks/web/useIntro.ts
  36. 21 0
      src/hooks/web/useNetwork.ts
  37. 60 0
      src/hooks/web/useNow.ts
  38. 63 0
      src/hooks/web/useTagsView.ts
  39. 29 31
      src/hooks/web/useValidator.ts
  40. 1 1
      src/layout/components/Footer/src/Footer.vue
  41. 3 2
      src/layout/components/Message/src/Message.vue
  42. 3 1
      src/utils/dict.ts
  43. 2 2
      src/utils/formatTime.ts
  44. 16 2
      src/utils/index.ts
  45. 23 8
      src/views/Login/components/LoginForm.vue
  46. 6 5
      src/views/Profile/Index.vue
  47. 19 5
      src/views/Profile/components/UserSocial.vue
  48. 70 0
      src/views/crm/product/ProductDetail.vue
  49. 185 0
      src/views/crm/product/ProductForm.vue
  50. 269 0
      src/views/crm/product/index.vue
  51. 110 0
      src/views/crm/productCategory/ProductCategoryForm.vue
  52. 138 0
      src/views/crm/productCategory/index.vue
  53. 28 18
      src/views/crm/receivable/ReceivableForm.vue
  54. 11 7
      src/views/crm/receivable/index.vue
  55. 27 18
      src/views/crm/receivablePlan/ReceivablePlanForm.vue
  56. 49 50
      src/views/crm/receivablePlan/index.vue
  57. 2 26
      src/views/mall/promotion/diy/page/decorate.vue
  58. 2 25
      src/views/mall/promotion/diy/template/decorate.vue
  59. 196 0
      src/views/mall/promotion/rewardActivity/RewardForm.vue
  60. 213 0
      src/views/mall/promotion/rewardActivity/index.vue
  61. 1 1
      src/views/mall/trade/delivery/expressTemplate/ExpressTemplateForm.vue
  62. 1 1
      src/views/system/loginlog/LoginLogDetail.vue
  63. 5 5
      src/views/system/menu/MenuForm.vue
  64. 1 1
      src/views/system/menu/index.vue
  65. 154 0
      src/views/system/social/client/SocialClientForm.vue
  66. 227 0
      src/views/system/social/client/index.vue
  67. 60 0
      src/views/system/social/user/SocialUserDetail.vue
  68. 190 0
      src/views/system/social/user/index.vue
  69. 5 5
      src/views/system/tenant/TenantForm.vue
  70. 1 1
      src/views/system/tenant/index.vue
  71. 0 1
      tsconfig.json
  72. 5 2
      uno.config.ts

+ 3 - 0
.vscode/settings.json

@@ -102,6 +102,7 @@
     "codemirror",
     "commitlint",
     "cropperjs",
+    "echart",
     "echarts",
     "esnext",
     "esno",
@@ -116,10 +117,12 @@
     "sider",
     "sortablejs",
     "stylelint",
+    "svgs",
     "unocss",
     "unplugin",
     "unref",
     "videojs",
+    "VITE",
     "vitejs",
     "vueuse",
     "wangeditor",

+ 16 - 17
package.json

@@ -36,20 +36,20 @@
     "@wangeditor/editor-for-vue": "^5.1.10",
     "@zxcvbn-ts/core": "^3.0.4",
     "animate.css": "^4.1.1",
-    "axios": "^1.5.1",
+    "axios": "^1.6.0",
     "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.5.0",
+    "diagram-js": "^12.6.0",
+    "driver.js": "^1.3.0",
     "echarts": "^5.4.3",
     "echarts-wordcloud": "^2.1.0",
     "element-plus": "2.4.1",
     "fast-xml-parser": "^4.3.2",
     "highlight.js": "^11.9.0",
-    "intro.js": "^7.2.0",
     "jsencrypt": "^3.3.2",
     "lodash-es": "^4.17.21",
     "min-dash": "^4.1.1",
@@ -64,7 +64,7 @@
     "video.js": "^7.21.5",
     "vue": "^3.3.7",
     "vue-dompurify-html": "^4.1.4",
-    "vue-i18n": "^9.5.0",
+    "vue-i18n": "^9.6.2",
     "vue-router": "^4.2.5",
     "vue-types": "^5.1.1",
     "vuedraggable": "^4.1.0",
@@ -72,20 +72,19 @@
     "xml-js": "^1.6.11"
   },
   "devDependencies": {
-    "@commitlint/cli": "^18.0.0",
-    "@commitlint/config-conventional": "^18.0.0",
-    "@iconify/json": "^2.2.132",
+    "@commitlint/cli": "^18.2.0",
+    "@commitlint/config-conventional": "^18.1.0",
+    "@iconify/json": "^2.2.135",
     "@intlify/unplugin-vue-i18n": "^1.4.0",
     "@purge-icons/generated": "^0.9.0",
-    "@types/intro.js": "^5.1.3",
     "@types/lodash-es": "^4.17.10",
-    "@types/node": "^20.8.8",
+    "@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.0",
-    "@typescript-eslint/parser": "^6.9.0",
+    "@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",
     "@vitejs/plugin-legacy": "^4.1.1",
@@ -99,24 +98,24 @@
     "eslint-config-prettier": "^9.0.0",
     "eslint-define-config": "^1.24.1",
     "eslint-plugin-prettier": "^5.0.1",
-    "eslint-plugin-vue": "^9.18.0",
+    "eslint-plugin-vue": "^9.18.1",
     "lint-staged": "^15.0.2",
     "postcss": "^8.4.31",
     "postcss-html": "^1.5.0",
     "postcss-scss": "^4.0.9",
     "prettier": "^3.0.3",
     "rimraf": "^5.0.5",
-    "rollup": "^4.1.4",
-    "sass": "^1.69.4",
+    "rollup": "^4.1.5",
+    "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.22.0",
+    "terser": "^5.23.0",
     "typescript": "5.2.2",
     "unocss": "^0.57.1",
-    "unplugin-auto-import": "^0.16.6",
+    "unplugin-auto-import": "^0.16.7",
     "unplugin-element-plus": "^0.8.0",
     "unplugin-vue-components": "^0.25.2",
     "vite": "4.5.0",
@@ -128,7 +127,7 @@
     "vite-plugin-svg-icons": "^2.0.1",
     "vite-plugin-top-level-await": "^1.3.1",
     "vue-eslint-parser": "^9.3.2",
-    "vue-tsc": "^1.8.20"
+    "vue-tsc": "^1.8.22"
   },
   "license": "MIT",
   "repository": {

+ 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 - 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' })

+ 37 - 0
src/api/system/social/client/index.ts

@@ -0,0 +1,37 @@
+import request from '@/config/axios'
+
+export interface SocialClientVO {
+  id: number
+  name: string
+  socialType: number
+  userType: number
+  clientId: string
+  clientSecret: string
+  agentId: string
+  status: number
+}
+
+// 查询社交客户端列表
+export const getSocialClientPage = async (params) => {
+  return await request.get({ url: `/system/social-client/page`, params })
+}
+
+// 查询社交客户端详情
+export const getSocialClient = async (id: number) => {
+  return await request.get({ url: `/system/social-client/get?id=` + id })
+}
+
+// 新增社交客户端
+export const createSocialClient = async (data: SocialClientVO) => {
+  return await request.post({ url: `/system/social-client/create`, data })
+}
+
+// 修改社交客户端
+export const updateSocialClient = async (data: SocialClientVO) => {
+  return await request.put({ url: `/system/social-client/update`, data })
+}
+
+// 删除社交客户端
+export const deleteSocialClient = async (id: number) => {
+  return await request.delete({ url: `/system/social-client/delete?id=` + id })
+}

+ 24 - 0
src/api/system/social/user/index.ts

@@ -0,0 +1,24 @@
+import request from '@/config/axios'
+
+export interface SocialUserVO {
+  id: number
+  type: number
+  openid: string
+  token: string
+  rawTokenInfo: string
+  nickname: string
+  avatar: string
+  rawUserInfo: string
+  code: string
+  state: string
+}
+
+// 查询社交用户列表
+export const getSocialUserPage = async (params) => {
+  return await request.get({ url: `/system/social-user/page`, params })
+}
+
+// 查询社交用户详情
+export const getSocialUser = async (id: number) => {
+  return await request.get({ url: `/system/social-user/get?id=` + id })
+}

+ 1 - 1
src/components/Card/src/CardTitle.vue

@@ -3,7 +3,7 @@ defineComponent({
   name: 'CardTitle'
 })
 
-const { title } = defineProps({
+defineProps({
   title: {
     type: String,
     required: true

+ 6 - 7
src/components/ConfigGlobal/src/ConfigGlobal.vue

@@ -1,20 +1,19 @@
-<script lang="ts" setup>
+<script setup lang="ts">
+import { provide, computed, watch, onMounted } from 'vue'
 import { propTypes } from '@/utils/propTypes'
+import { ComponentSize, ElConfigProvider } from 'element-plus'
 import { useLocaleStore } from '@/store/modules/locale'
+import { useWindowSize } from '@vueuse/core'
 import { useAppStore } from '@/store/modules/app'
 import { setCssVar } from '@/utils'
 import { useDesign } from '@/hooks/web/useDesign'
-import { ElementPlusSize } from '@/types/elementPlus'
-import { useWindowSize } from '@vueuse/core'
-
-defineOptions({ name: 'ConfigGlobal' })
 
 const { variables } = useDesign()
 
 const appStore = useAppStore()
 
 const props = defineProps({
-  size: propTypes.oneOf<ElementPlusSize>(['default', 'small', 'large']).def('default')
+  size: propTypes.oneOf<ComponentSize>(['default', 'small', 'large']).def('default')
 })
 
 provide('configGlobal', props)
@@ -53,9 +52,9 @@ const currentLocale = computed(() => localeStore.currentLocale)
 
 <template>
   <ElConfigProvider
+    :namespace="variables.elNamespace"
     :locale="currentLocale.elLocale"
     :message="{ max: 1 }"
-    :namespace="variables.elNamespace"
     :size="size"
   >
     <slot></slot>

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

@@ -0,0 +1,222 @@
+<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 {
+    display: block;
+    position: absolute;
+    left: -$active-border-width;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    /* 鼠标放到组件上时 */
+    &:hover {
+      border: $hover-border-width dashed var(--el-color-primary);
+      box-shadow: 0 0 5px 0 rgba(24, 144, 255, 0.3);
+      .component-name {
+        /* 防止加了边框之后,位置移动 */
+        left: $name-position - $hover-border-width;
+        top: $hover-border-width;
+      }
+    }
+    /* 左侧:组件名称 */
+    .component-name {
+      display: block;
+      position: absolute;
+      width: 80px;
+      text-align: center;
+      line-height: 25px;
+      height: 25px;
+      background: #fff;
+      font-size: 12px;
+      left: $name-position;
+      top: $active-border-width;
+      box-shadow:
+        0 0 4px #00000014,
+        0 2px 6px #0000000f,
+        0 4px 8px 2px #0000000a;
+      /* 右侧小三角 */
+      &:after {
+        position: absolute;
+        top: 7.5px;
+        right: -10px;
+        content: ' ';
+        height: 0;
+        width: 0;
+        border: 5px solid transparent;
+        border-left-color: #fff;
+      }
+    }
+    /* 右侧:组件操作工具栏 */
+    .component-toolbar {
+      display: none;
+      position: absolute;
+      top: 0;
+      right: $toolbar-position;
+      /* 左侧小三角 */
+      &:before {
+        position: absolute;
+        top: 10px;
+        left: -10px;
+        content: ' ';
+        height: 0;
+        width: 0;
+        border: 5px solid transparent;
+        border-right-color: #2d8cf0;
+      }
+    }
+  }
+  /* 组件选中时 */
+  &.active {
+    margin-bottom: 4px;
+
+    .component-wrap {
+      border: $active-border-width solid var(--el-color-primary) !important;
+      box-shadow: 0 0 10px 0 rgba(24, 144, 255, 0.3);
+      margin-bottom: $active-border-width + $active-border-width;
+
+      .component-name {
+        background: var(--el-color-primary);
+        color: #fff;
+        /* 防止加了边框之后,位置移动 */
+        left: $name-position - $active-border-width !important;
+        top: 0 !important;
+        &:after {
+          border-left-color: var(--el-color-primary);
+        }
+      }
+      .component-toolbar {
+        display: block;
+      }
+    }
+  }
+}
+</style>

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

@@ -0,0 +1,163 @@
+<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>

+ 2 - 1
src/components/DiyEditor/components/ComponentLibrary.vue

@@ -1,5 +1,5 @@
 <template>
-  <el-aside class="editor-left" width="260px">
+  <el-aside class="editor-left" width="261px">
     <el-scrollbar>
       <el-collapse v-model="extendGroups">
         <el-collapse-item
@@ -11,6 +11,7 @@
           <draggable
             class="component-container"
             ghost-class="draggable-ghost"
+            item-key="index"
             :list="group.components"
             :sort="false"
             :group="{ name: 'component', pull: 'clone', put: false }"

+ 32 - 26
src/components/DiyEditor/components/mobile/Carousel/config.ts

@@ -1,27 +1,30 @@
-import { DiyComponent } from '@/components/DiyEditor/util'
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
 
 /** 轮播图属性 */
 export interface CarouselProperty {
-  // 选择模板
-  swiperType: number
-  // 图片圆角
-  borderRadius: number
-  // 页面边距
-  pageMargin: number
-  // 图片边距
-  imageMargin: number
-  // 分页类型
-  pagingType: 'bullets' | 'fraction' | 'progressbar'
-  // 一行个数
-  rowIndividual: number
-  // 添加图片
+  // 类型:默认 | 卡片
+  type: 'default' | 'card'
+  // 指示器样式:点 | 数字
+  indicator: 'dot' | 'number'
+  // 是否自动播放
+  autoplay: boolean
+  // 播放间隔
+  interval: number
+  // 轮播内容
   items: CarouselItemProperty[]
+  // 组件样式
+  style: ComponentStyle
 }
-
+// 轮播内容属性
 export interface CarouselItemProperty {
-  title: string
+  // 类型:图片 | 视频
+  type: 'img' | 'video'
+  // 图片链接
   imgUrl: string
-  link: string
+  // 视频链接
+  videoUrl: string
+  // 跳转链接
+  url: string
 }
 
 // 定义组件
@@ -30,15 +33,18 @@ export const component = {
   name: '轮播图',
   icon: 'system-uicons:carousel',
   property: {
-    swiperType: 0, // 选择模板
-    borderRadius: 0, // 图片圆角
-    pageMargin: 0, // 页面边距
-    imageMargin: 0, // 图片边距
-    pagingType: 'bullets', // 分页类型
-    rowIndividual: 2, // 一行个数
+    type: 'default',
+    indicator: 'dot',
+    autoplay: false,
+    interval: 3,
     items: [
-      { imgUrl: 'https://static.iocoder.cn/mall/banner-01.jpg' },
-      { imgUrl: 'https://static.iocoder.cn/mall/banner-02.jpg' }
-    ] as CarouselItemProperty[]
+      { 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>

+ 27 - 59
src/components/DiyEditor/components/mobile/Carousel/index.vue

@@ -6,70 +6,38 @@
   >
     <Icon icon="tdesign:image" class="text-gray-8 text-120px!" />
   </div>
-  <!-- 一行一个 -->
-  <div
-    v-if="property.swiperType === 0"
-    class="flex flex-col"
-    :style="{
-      paddingLeft: property.pageMargin + 'px',
-      paddingRight: property.pageMargin + 'px'
-    }"
-  >
-    <div v-for="(item, index) in property.items" :key="index">
-      <div
-        class="img-item"
-        :style="{
-          marginBottom: property.imageMargin + 'px',
-          borderRadius: property.borderRadius + 'px'
-        }"
-      >
-        <img alt="" :src="item.imgUrl" />
-        <div v-if="item.title" class="title">{{ item.title }}</div>
-      </div>
-    </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 p-y-2px bottom-10px right-10px rounded-xl bg-black p-x-8px text-10px text-white opacity-40"
+      >{{ currentIndex }} / {{ property.items.length }}</div
+    >
   </div>
-  <el-carousel height="174px" v-else :type="property.swiperType === 3 ? 'card' : ''">
-    <el-carousel-item v-for="(item, index) in property.items" :key="index">
-      <div class="img-item" :style="{ borderRadius: property.borderRadius + 'px' }">
-        <img alt="" :src="item.imgUrl" />
-        <div v-if="item.title" class="title">{{ item.title }}</div>
-      </div>
-    </el-carousel-item>
-  </el-carousel>
 </template>
 <script setup lang="ts">
 import { CarouselProperty } from './config'
 
-/** 页面顶部导航栏 */
-defineOptions({ name: 'NavigationBar' })
+/** 轮播图 */
+defineOptions({ name: 'Carousel' })
 
-const props = defineProps<{ property: CarouselProperty }>()
-</script>
+defineProps<{ property: CarouselProperty }>()
 
-<style scoped lang="scss">
-.img-item {
-  width: 100%;
-  position: relative;
-  overflow: hidden;
-  &:last-child {
-    margin: 0 !important;
-  }
-  /* 图片 */
-  img {
-    width: 100%;
-    height: 100%;
-    display: block;
-  }
-  .title {
-    height: 36px;
-    width: 100%;
-    background-color: rgba(51, 51, 51, 0.8);
-    text-align: center;
-    line-height: 36px;
-    color: #fff;
-    position: absolute;
-    bottom: 0;
-    left: 0;
-  }
+const currentIndex = ref(0)
+const handleIndexChange = (index: number) => {
+  currentIndex.value = index + 1
 }
-</style>
+</script>
+
+<style scoped lang="scss"></style>

+ 116 - 99
src/components/DiyEditor/components/mobile/Carousel/property.vue

@@ -1,103 +1,120 @@
 <template>
-  <el-form label-width="80px" :model="formData">
-    <el-form-item label="选择模板" prop="swiperType">
-      <el-radio-group v-model="formData.swiperType">
-        <el-tooltip class="item" content="一行一个" placement="bottom">
-          <el-radio-button :label="0">
-            <Icon icon="icon-park-twotone:multi-picture-carousel" />
-          </el-radio-button>
-        </el-tooltip>
-        <el-tooltip class="item" content="轮播海报" placement="bottom">
-          <el-radio-button :label="1">
-            <Icon icon="system-uicons:carousel" />
-          </el-radio-button>
-        </el-tooltip>
-        <el-tooltip class="item" content="多图单行" placement="bottom">
-          <el-radio-button :label="2">
-            <Icon icon="icon-park-twotone:carousel" />
-          </el-radio-button>
-        </el-tooltip>
-        <el-tooltip class="item" content="立体轮播" placement="bottom">
-          <el-radio-button :label="3">
-            <Icon icon="ic:round-view-carousel" />
-          </el-radio-button>
-        </el-tooltip>
-      </el-radio-group>
-    </el-form-item>
-
-    <el-text tag="p">添加图片</el-text>
-    <el-text type="info" size="small"> 拖动左上角的小圆点可对其排序 </el-text>
-
-    <!-- 图片广告 -->
-    <div v-if="formData.items[0]">
-      <draggable
-        :list="formData.items"
-        :force-fallback="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="handleDeleteImage(index)"
-                v-if="formData.items.length > 1"
-              />
-            </div>
-            <div class="flex flex-1 flex-col items-center justify-between gap-8px">
-              <UploadImg
-                v-model="element.imgUrl"
-                draggable="false"
-                height="80px"
-                width="100%"
-                class="min-w-80px"
-              />
-              <!-- 标题 -->
-              <el-input v-model="element.title" placeholder="标题,选填" />
-              <!-- 输入链接 -->
-              <el-input placeholder="链接,选填" v-model="element.link" />
-            </div>
-          </div>
+  <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>
-      </draggable>
-    </div>
-    <el-button @click="handleAddImage" type="primary" plain class="w-full"> 添加图片 </el-button>
-    <el-form-item label="一行个数" prop="rowIndividual" v-show="formData.swiperType === 2">
-      <!-- 单选框 -->
-      <el-radio-group v-model="formData.rowIndividual">
-        <el-radio :label="2">2个</el-radio>
-        <el-radio :label="3">3个</el-radio>
-        <el-radio :label="4">4个</el-radio>
-        <el-radio :label="5">5个</el-radio>
-        <el-radio :label="6">6个</el-radio>
-      </el-radio-group>
-    </el-form-item>
-    <el-form-item label="分页类型" prop="pagingType">
-      <el-radio-group v-model="formData.pagingType">
-        <el-radio :label="0">不显示</el-radio>
-        <el-radio label="bullets">样式一</el-radio>
-        <el-radio label="fraction">样式二</el-radio>
-        <el-radio label="progressbar">样式三</el-radio>
-      </el-radio-group>
-    </el-form-item>
-    <el-form-item label="图片圆角" prop="borderRadius">
-      <el-slider v-model="formData.borderRadius" :max="30" />
-    </el-form-item>
-    <el-form-item label="页面边距" prop="pageMargin" v-show="formData.swiperType === 0">
-      <el-slider v-model="formData.pageMargin" :max="20" />
-    </el-form-item>
-    <el-form-item
-      label="图片边距"
-      prop="imageMargin"
-      v-show="formData.swiperType === 0 || formData.swiperType === 2"
-    >
-      <el-slider v-model="formData.imageMargin" :max="20" />
-    </el-form-item>
-  </el-form>
+        <el-button @click="handleAddImage" type="primary" plain class="w-full">
+          添加图片
+        </el-button>
+      </el-card>
+    </el-form>
+  </ComponentContainerProperty>
 </template>
 
 <script setup lang="ts">
@@ -117,7 +134,7 @@ const handleAddImage = () => {
   formData.value.items.push({} as CarouselItemProperty)
 }
 // 删除图片
-const handleDeleteImage = (index) => {
+const handleDeleteImage = (index: number) => {
   formData.value.items.splice(index, 1)
 }
 </script>

+ 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 {
+  width: 100%;
+  height: 100%;
+  display: block;
+}
+</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>

+ 1 - 1
src/components/DiyEditor/components/mobile/NavigationBar/config.ts

@@ -29,7 +29,7 @@ export const component = {
     title: '页面标题',
     description: '',
     navBarHeight: 35,
-    backgroundColor: '#f5f5f5',
+    backgroundColor: '#fff',
     backgroundImage: '',
     styleType: 'default',
     alwaysShow: true,

+ 14 - 6
src/components/DiyEditor/components/mobile/SearchBar/config.ts

@@ -1,4 +1,4 @@
-import { DiyComponent } from '@/components/DiyEditor/util'
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
 
 /** 搜索框属性 */
 export interface SearchProperty {
@@ -7,10 +7,10 @@ export interface SearchProperty {
   borderRadius: number // 框体样式
   placeholder: string // 占位文字
   placeholderPosition: PlaceholderPosition // 占位文字位置
-  backgroundColor: string // 背景颜色
-  borderColor: string // 框体颜色
+  backgroundColor: string // 框体颜色
   textColor: string // 字体颜色
   hotKeywords: string[] // 热词
+  style: ComponentStyle
 }
 
 // 文字位置
@@ -27,9 +27,17 @@ export const component = {
     borderRadius: 0,
     placeholder: '搜索商品',
     placeholderPosition: 'left',
-    backgroundColor: 'rgb(249, 249, 249)',
-    borderColor: 'rgb(255, 255, 255)',
+    backgroundColor: 'rgb(238, 238, 238)',
     textColor: 'rgb(150, 151, 153)',
-    hotKeywords: []
+    hotKeywords: [],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8,
+      paddingTop: 8,
+      paddingRight: 8,
+      paddingBottom: 8,
+      paddingLeft: 8
+    } as ComponentStyle
   }
 } as DiyComponent<SearchProperty>

+ 1 - 6
src/components/DiyEditor/components/mobile/SearchBar/index.vue

@@ -2,8 +2,6 @@
   <div
     class="search-bar"
     :style="{
-      background: property.backgroundColor,
-      border: `1px solid ${property.backgroundColor}`,
       color: property.textColor
     }"
   >
@@ -12,7 +10,7 @@
       class="inner"
       :style="{
         height: `${property.height}px`,
-        background: property.borderColor,
+        background: property.backgroundColor,
         borderRadius: `${property.borderRadius}px`
       }"
     >
@@ -44,13 +42,10 @@ defineProps<{ property: SearchProperty }>()
 
 <style scoped lang="scss">
 .search-bar {
-  position: relative;
   /* 搜索框 */
   .inner {
     position: relative;
-    width: calc(100% - 16px);
     min-height: 28px;
-    margin: 5px auto;
     display: flex;
     align-items: center;
     font-size: 14px;

+ 72 - 73
src/components/DiyEditor/components/mobile/SearchBar/property.vue

@@ -1,78 +1,77 @@
 <template>
-  <el-text tag="p"> 搜索热词 </el-text>
-  <el-text type="info" size="small"> 拖动左侧的小圆点可以调整热词顺序 </el-text>
+  <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 label="框体颜色" prop="borderColor">
-      <ColorInput v-model="formData.borderColor" />
-    </el-form-item>
-    <el-form-item class="lef" label="文本颜色" prop="textColor">
-      <ColorInput v-model="formData.textColor" />
-    </el-form-item>
-  </el-form>
+    <!-- 表单 -->
+    <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">

+ 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 {
+  width: 100%;
+  height: 100%;
+  display: block;
+}
+</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>

+ 108 - 249
src/components/DiyEditor/index.vue

@@ -33,111 +33,63 @@
       <ComponentLibrary ref="componentLibrary" :list="libs" v-if="libs && libs.length > 0" />
       <!-- 中心设计区域 -->
       <div class="editor-center page-prop-area" @click="handlePageSelected">
-        <div class="editor-design">
-          <!-- 手机顶部 -->
-          <div class="editor-design-top">
-            <!-- 手机顶部状态栏 -->
-            <img src="@/assets/imgs/diy/statusBar.png" alt="" class="status-bar" />
-            <!-- 手机顶部导航栏 -->
-            <NavigationBar
-              v-if="showNavigationBar"
-              :property="navigationBarComponent.property"
-              @click="handleNavigationBarSelected"
-              :class="[
-                'component',
-                'cursor-pointer!',
-                { active: selectedComponent?.id === navigationBarComponent.id }
-              ]"
-            />
-          </div>
-          <!-- 手机页面编辑区域 -->
-          <el-scrollbar class="editor-design-center" height="100%" view-class="page-prop-area">
-            <div
-              class="phone-container"
-              :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 }">
-                  <div class="component-container" @click="handleComponentSelected(element, index)">
-                    <!-- 左侧组件名 -->
-                    <div
-                      :class="['component-name', { active: selectedComponentIndex === index }]"
-                      v-if="element.name"
-                    >
-                      {{ element.name }}
-                    </div>
-                    <!-- 组件内容区 -->
-                    <div :class="['component', { active: selectedComponentIndex === index }]">
-                      <component
-                        :is="element.id"
-                        :property="element.property"
-                        :data-type="element.id"
-                      />
-                    </div>
-                    <!-- 左侧:组件操作工具栏 -->
-                    <div
-                      class="component-toolbar"
-                      v-if="element.name && selectedComponentIndex === index"
-                    >
-                      <el-button-group type="primary">
-                        <el-tooltip content="上移" placement="right">
-                          <el-button
-                            :disabled="index === 0"
-                            @click.stop="handleMoveComponent(index, -1)"
-                          >
-                            <Icon icon="ep:arrow-up" />
-                          </el-button>
-                        </el-tooltip>
-                        <el-tooltip content="下移" placement="right">
-                          <el-button
-                            :disabled="index === pageComponents.length - 1"
-                            @click.stop="handleMoveComponent(index, 1)"
-                          >
-                            <Icon icon="ep:arrow-down" />
-                          </el-button>
-                        </el-tooltip>
-                        <el-tooltip content="复制" placement="right">
-                          <el-button @click.stop="handleCopyComponent(index)">
-                            <Icon icon="ep:copy-document" />
-                          </el-button>
-                        </el-tooltip>
-                        <el-tooltip content="删除" placement="right">
-                          <el-button @click.stop="handleDeleteComponent(index)">
-                            <Icon icon="ep:delete" />
-                          </el-button>
-                        </el-tooltip>
-                      </el-button-group>
-                    </div>
-                  </div>
-                </template>
-              </draggable>
-            </div>
-          </el-scrollbar>
-          <!-- 手机底部导航 -->
-          <div
-            v-if="showTabBar"
-            :class="[
-              'editor-design-bottom',
-              'component',
-              'cursor-pointer!',
-              { active: selectedComponent?.id === tabBarComponent.id }
-            ]"
+        <!-- 手机顶部 -->
+        <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"
           >
-            <TabBar :property="tabBarComponent.property" @click="handleTabBarSelected" />
-          </div>
+            <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>
       <!-- 右侧属性面板 -->
@@ -178,8 +130,6 @@ export default {
 <script lang="ts" setup>
 import draggable from 'vuedraggable'
 import ComponentLibrary from './components/ComponentLibrary.vue'
-import NavigationBar from './components/mobile/NavigationBar/index.vue'
-import TabBar from './components/mobile/TabBar/index.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'
@@ -256,6 +206,9 @@ const handleSave = () => {
       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)
@@ -383,6 +336,7 @@ onMounted(() => setDefaultSelectedComponent())
 <style lang="scss" scoped>
 /* 手机宽度 */
 $phone-width: 375px;
+$toolbar-height: 42px;
 /* 根节点样式 */
 .editor {
   height: 100%;
@@ -394,7 +348,7 @@ $phone-width: 375px;
     display: flex;
     align-items: center;
     justify-content: space-between;
-    height: auto;
+    height: $toolbar-height;
     padding: 0;
     border-bottom: solid 1px var(--el-border-color);
     background-color: var(--el-bg-color);
@@ -416,176 +370,81 @@ $phone-width: 375px;
   /* 中心操作区 */
   .editor-container {
     height: calc(
-      100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) - 42px
+      100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) -
+        $toolbar-height
     );
     /* 右侧属性面板 */
     .editor-right {
       flex-shrink: 0;
       box-shadow: -8px 0 8px -8px rgba(0, 0, 0, 0.12);
+      overflow: hidden;
       /* 属性面板顶部:减少内边距 */
       :deep(.el-card__header) {
         padding: 8px 16px;
       }
       /* 属性面板分组 */
-      .property-group {
-        /* 属性分组 */
-        :deep(.el-card__header) {
+      :deep(.property-group) {
+        margin: 0 -20px;
+        &.el-card {
+          border: none;
+        }
+        /* 属性分组名称 */
+        .el-card__header {
           border: none;
           background: var(--el-bg-color-page);
+          padding: 8px 32px;
+        }
+        .el-card__body {
+          border: none;
         }
       }
     }
 
     /* 中心区域 */
     .editor-center {
+      position: relative;
       flex: 1 1 0;
-      padding: 16px 0;
       background-color: var(--app-content-bg-color);
       display: flex;
+      flex-direction: column;
       justify-content: center;
-      /* 中心设计区域 */
-      .editor-design {
-        position: relative;
-        height: 100%;
-        width: 100%;
+      margin: 16px 0 0 0;
+      overflow: hidden;
+      width: 100%;
+
+      /* 手机顶部 */
+      .editor-design-top {
+        width: $phone-width;
+        margin: 0 auto;
         display: flex;
         flex-direction: column;
-        align-items: center;
-        overflow: hidden;
-
-        /* 组件 */
-        .component {
-          border: 1px solid #fff;
-          width: $phone-width;
-          cursor: move;
-          /* 鼠标放到组件上时 */
-          &:hover {
-            border: 1px dashed var(--el-color-primary);
-          }
-        }
-        /* 组件选中 */
-        .component.active {
-          border: 2px solid var(--el-color-primary);
-        }
-        /* 手机顶部 */
-        .editor-design-top {
-          width: $phone-width;
-          /* 手机顶部状态栏 */
-          .status-bar {
-            height: 20px;
-            width: $phone-width;
-            background-color: #fff;
-          }
-        }
-        /* 手机底部导航 */
-        .editor-design-bottom {
+        /* 手机顶部状态栏 */
+        .status-bar {
+          height: 20px;
           width: $phone-width;
+          background-color: #fff;
         }
-        /* 手机页面编辑区域 */
-        .editor-design-center {
-          width: 100%;
-          flex: 1 1 0;
-
-          :deep(.el-scrollbar__view) {
-            height: 100%;
-          }
+      }
+      /* 手机底部导航 */
+      .editor-design-bottom {
+        width: $phone-width;
+        margin: 0 auto;
+      }
+      /* 手机页面编辑区域 */
+      :deep(.editor-design-center) {
+        width: 100%;
 
-          /* 主体内容 */
-          .phone-container {
+        /* 主体内容 */
+        .phone-container {
+          position: relative;
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          height: 100%;
+          width: $phone-width;
+          margin: 0 auto;
+          .drag-area {
             height: 100%;
-            box-sizing: border-box;
-            position: relative;
-            background-repeat: no-repeat;
-            background-size: 100% 100%;
-            width: $phone-width;
-            margin: 0 auto;
-            .drag-area {
-              height: 100%;
-            }
-
-            /* 组件容器(左侧:组件名称,中间:组件,右侧:操作工具栏) */
-            .component-container {
-              width: 100%;
-              position: relative;
-              /* 左侧:组件名称 */
-              .component-name {
-                position: absolute;
-                width: 80px;
-                text-align: center;
-                line-height: 25px;
-                height: 25px;
-                background: #fff;
-                font-size: 12px;
-                left: -85px;
-                top: 0;
-                box-shadow:
-                  0 0 4px #00000014,
-                  0 2px 6px #0000000f,
-                  0 4px 8px 2px #0000000a;
-                /* 右侧小三角 */
-                &:after {
-                  position: absolute;
-                  top: 7.5px;
-                  right: -10px;
-                  content: ' ';
-                  height: 0;
-                  width: 0;
-                  border: 5px solid transparent;
-                  border-left-color: #fff;
-                }
-              }
-              /* 组件选中按钮 */
-              .component-name.active {
-                background: var(--el-color-primary);
-                color: #fff;
-                &:after {
-                  border-left-color: var(--el-color-primary);
-                }
-              }
-              /* 右侧:组件操作工具栏 */
-              .component-toolbar {
-                position: absolute;
-                top: 0;
-                right: -57px;
-                /* 左侧小三角 */
-                &:before {
-                  position: absolute;
-                  top: 10px;
-                  left: -10px;
-                  content: ' ';
-                  height: 0;
-                  width: 0;
-                  border: 5px solid transparent;
-                  border-right-color: #2d8cf0;
-                }
-
-                /* 重写 Element 按钮组的样式(官方只支持水平显示,增加垂直显示的样式) */
-                .el-button-group {
-                  display: inline-flex;
-                  flex-direction: column;
-                }
-                .el-button-group > .el-button:first-child {
-                  border-bottom-left-radius: 0;
-                  border-bottom-right-radius: 0;
-                  border-top-right-radius: var(--el-border-radius-base);
-                  border-bottom-color: var(--el-button-divide-border-color);
-                }
-                .el-button-group > .el-button:last-child {
-                  border-top-left-radius: 0;
-                  border-top-right-radius: 0;
-                  border-bottom-left-radius: var(--el-border-radius-base);
-                  border-top-color: var(--el-button-divide-border-color);
-                }
-                .el-button-group .el-button--primary:not(:first-child):not(:last-child) {
-                  border-top-color: var(--el-button-divide-border-color);
-                  border-bottom-color: var(--el-button-divide-border-color);
-                }
-                .el-button-group > .el-button:not(:last-child) {
-                  margin-bottom: -1px;
-                  margin-right: 0;
-                }
-              }
-            }
+            width: 100%;
           }
         }
       }

+ 62 - 1
src/components/DiyEditor/util.ts

@@ -3,19 +3,56 @@ import { PageConfigProperty } from '@/components/DiyEditor/components/mobile/Pag
 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 {
   // 页面属性
@@ -23,7 +60,7 @@ export interface PageConfig {
   // 顶部导航栏属性
   navigationBar: NavigationBarProperty
   // 底部导航菜单属性
-  tabBar: TabBarProperty
+  tabBar?: TabBarProperty
   // 页面组件列表
   components: PageComponent[]
 }
@@ -57,3 +94,27 @@ export function usePropertyForm<T>(modelValue: T, emit: Function): { formData: R
 
   return { formData }
 }
+
+// 页面组件库
+export const PAGE_LIBS = [
+  {
+    name: '基础组件',
+    extended: true,
+    components: [
+      'SearchBar',
+      'NoticeBar',
+      'GridNavigation',
+      'ListNavigation',
+      'Divider',
+      'TitleBar'
+    ]
+  },
+  { name: '图文组件', extended: true, components: ['ImageBar', 'Carousel', 'VideoPlayer'] },
+  { name: '商品组件', extended: true, components: ['ProductCard'] },
+  {
+    name: '会员组件',
+    extended: true,
+    components: ['UserCard', 'OrderCard', 'WalletCard', 'CouponCard']
+  },
+  { name: '营销组件', extended: true, components: ['Combination', 'Seckill', 'Point', 'Coupon'] }
+] as DiyComponentLibrary[]

+ 1 - 1
src/components/ShortcutDateRangePicker/index.vue

@@ -27,7 +27,7 @@ import * as DateUtil from '@/utils/formatTime'
 defineOptions({ name: 'ShortcutDateRangePicker' })
 
 const shortcutDays = ref(7) // 日期快捷天数(单选按钮组), 默认7天
-const times = ref<[dayjs.ConfigType, dayjs.ConfigType]>(['', '']) // 时间范围参数
+const times = ref<[string, string]>(['', '']) // 时间范围参数
 defineExpose({ times }) // 暴露时间范围参数
 /** 日期快捷选择 */
 const shortcuts = [

+ 2 - 2
src/components/Sticky/src/Sticky.vue

@@ -32,7 +32,7 @@ onMounted(() => {
 
   scrollContainer.value = getScrollContainer(refSticky.value!, true)
   useEventListener(scrollContainer, 'scroll', handleScroll)
-  useEventListener('resize', handleReize)
+  useEventListener('resize', handleResize)
   handleScroll()
 })
 onActivated(() => {
@@ -103,7 +103,7 @@ const handleScroll = () => {
     reset()
   }
 }
-const handleReize = () => {
+const handleResize = () => {
   if (isSticky.value && refSticky.value) {
     width.value = refSticky.value.getBoundingClientRect().width + 'px'
   }

+ 2 - 2
src/components/Tooltip/src/Tooltip.vue

@@ -4,13 +4,13 @@ import { propTypes } from '@/utils/propTypes'
 defineOptions({ name: 'Tooltip' })
 
 defineProps({
-  titel: propTypes.string.def(''),
+  title: propTypes.string.def(''),
   message: propTypes.string.def(''),
   icon: propTypes.string.def('ep:question-filled')
 })
 </script>
 <template>
-  <span>{{ titel }}</span>
+  <span>{{ title }}</span>
   <ElTooltip :content="message" placement="top">
     <Icon :icon="icon" class="relative top-1px ml-1px" />
   </ElTooltip>

+ 39 - 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,47 @@ 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 {
+      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">

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

@@ -0,0 +1,40 @@
+<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-left-radius: 0;
+  border-bottom-right-radius: 0;
+  border-top-right-radius: var(--el-border-radius-base);
+  border-bottom-color: var(--el-button-divide-border-color);
+}
+.el-button-group > :deep(.el-button:last-child) {
+  border-top-left-radius: 0;
+  border-top-right-radius: 0;
+  border-bottom-left-radius: var(--el-border-radius-base);
+  border-top-color: var(--el-button-divide-border-color);
+}
+.el-button-group :deep(.el-button--primary:not(:first-child):not(: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-bottom: -1px;
+  margin-right: 0;
+}
+</style>

+ 49 - 0
src/hooks/web/useGuide.ts

@@ -0,0 +1,49 @@
+import { Config, driver } from 'driver.js'
+import 'driver.js/dist/driver.css'
+import { useDesign } from '@/hooks/web/useDesign'
+import { useI18n } from '@/hooks/web/useI18n'
+
+const { t } = useI18n()
+
+const { variables } = useDesign()
+
+export const useGuide = (options?: Config) => {
+  const driverObj = driver(
+    options || {
+      showProgress: true,
+      nextBtnText: t('common.nextLabel'),
+      prevBtnText: t('common.prevLabel'),
+      doneBtnText: t('common.doneLabel'),
+      steps: [
+        {
+          element: `#${variables.namespace}-menu`,
+          popover: {
+            title: t('common.menu'),
+            description: t('common.menuDes'),
+            side: 'right'
+          }
+        },
+        {
+          element: `#${variables.namespace}-tool-header`,
+          popover: {
+            title: t('common.tool'),
+            description: t('common.toolDes'),
+            side: 'left'
+          }
+        },
+        {
+          element: `#${variables.namespace}-tags-view`,
+          popover: {
+            title: t('common.tagsView'),
+            description: t('common.tagsViewDes'),
+            side: 'bottom'
+          }
+        }
+      ]
+    }
+  )
+
+  return {
+    ...driverObj
+  }
+}

+ 0 - 47
src/hooks/web/useIntro.ts

@@ -1,47 +0,0 @@
-import introJs from 'intro.js'
-import { IntroJs, Step, Options } from 'intro.js'
-import 'intro.js/introjs.css'
-
-import { useDesign } from '@/hooks/web/useDesign'
-
-export const useIntro = (setps?: Step[], options?: Options) => {
-  const { t } = useI18n()
-
-  const { variables } = useDesign()
-
-  const defaultSetps: Step[] = setps || [
-    {
-      element: `#${variables.namespace}-menu`,
-      title: t('common.menu'),
-      intro: t('common.menuDes'),
-      position: 'right'
-    },
-    {
-      element: `#${variables.namespace}-tool-header`,
-      title: t('common.tool'),
-      intro: t('common.toolDes'),
-      position: 'left'
-    },
-    {
-      element: `#${variables.namespace}-tags-view`,
-      title: t('common.tagsView'),
-      intro: t('common.tagsViewDes'),
-      position: 'bottom'
-    }
-  ]
-
-  const defaultOptions: Options = options || {
-    prevLabel: t('common.prevLabel'),
-    nextLabel: t('common.nextLabel'),
-    skipLabel: t('common.skipLabel'),
-    doneLabel: t('common.doneLabel')
-  }
-
-  const introRef: IntroJs = introJs()
-
-  introRef.addSteps(defaultSetps).setOptions(defaultOptions)
-
-  return {
-    introRef
-  }
-}

+ 21 - 0
src/hooks/web/useNetwork.ts

@@ -0,0 +1,21 @@
+import { ref, onBeforeUnmount } from 'vue'
+
+const useNetwork = () => {
+  const online = ref(true)
+
+  const updateNetwork = () => {
+    online.value = navigator.onLine
+  }
+
+  window.addEventListener('online', updateNetwork)
+  window.addEventListener('offline', updateNetwork)
+
+  onBeforeUnmount(() => {
+    window.removeEventListener('online', updateNetwork)
+    window.removeEventListener('offline', updateNetwork)
+  })
+
+  return { online }
+}
+
+export { useNetwork }

+ 60 - 0
src/hooks/web/useNow.ts

@@ -0,0 +1,60 @@
+import { dateUtil } from '@/utils/dateUtil'
+import { reactive, toRefs } from 'vue'
+import { tryOnMounted, tryOnUnmounted } from '@vueuse/core'
+
+export const useNow = (immediate = true) => {
+  let timer: IntervalHandle
+
+  const state = reactive({
+    year: 0,
+    month: 0,
+    week: '',
+    day: 0,
+    hour: '',
+    minute: '',
+    second: 0,
+    meridiem: ''
+  })
+
+  const update = () => {
+    const now = dateUtil()
+
+    const h = now.format('HH')
+    const m = now.format('mm')
+    const s = now.get('s')
+
+    state.year = now.get('y')
+    state.month = now.get('M') + 1
+    state.week = '星期' + ['日', '一', '二', '三', '四', '五', '六'][now.day()]
+    state.day = now.get('date')
+    state.hour = h
+    state.minute = m
+    state.second = s
+
+    state.meridiem = now.format('A')
+  }
+
+  function start() {
+    update()
+    clearInterval(timer)
+    timer = setInterval(() => update(), 1000)
+  }
+
+  function stop() {
+    clearInterval(timer)
+  }
+
+  tryOnMounted(() => {
+    immediate && start()
+  })
+
+  tryOnUnmounted(() => {
+    stop()
+  })
+
+  return {
+    ...toRefs(state),
+    start,
+    stop
+  }
+}

+ 63 - 0
src/hooks/web/useTagsView.ts

@@ -0,0 +1,63 @@
+import { useTagsViewStoreWithOut } from '@/store/modules/tagsView'
+import { RouteLocationNormalizedLoaded, useRouter } from 'vue-router'
+import { computed, nextTick, unref } from 'vue'
+
+export const useTagsView = () => {
+  const tagsViewStore = useTagsViewStoreWithOut()
+
+  const { replace, currentRoute } = useRouter()
+
+  const selectedTag = computed(() => tagsViewStore.getSelectedTag)
+
+  const closeAll = (callback?: Fn) => {
+    tagsViewStore.delAllViews()
+    callback?.()
+  }
+
+  const closeLeft = (callback?: Fn) => {
+    tagsViewStore.delLeftViews(unref(selectedTag) as RouteLocationNormalizedLoaded)
+    callback?.()
+  }
+
+  const closeRight = (callback?: Fn) => {
+    tagsViewStore.delRightViews(unref(selectedTag) as RouteLocationNormalizedLoaded)
+    callback?.()
+  }
+
+  const closeOther = (callback?: Fn) => {
+    tagsViewStore.delOthersViews(unref(selectedTag) as RouteLocationNormalizedLoaded)
+    callback?.()
+  }
+
+  const closeCurrent = (view?: RouteLocationNormalizedLoaded, callback?: Fn) => {
+    if (view?.meta?.affix) return
+    tagsViewStore.delView(view || unref(currentRoute))
+
+    callback?.()
+  }
+
+  const refreshPage = async (view?: RouteLocationNormalizedLoaded, callback?: Fn) => {
+    tagsViewStore.delCachedView()
+    const { path, query } = view || unref(currentRoute)
+    await nextTick()
+    replace({
+      path: '/redirect' + path,
+      query: query
+    })
+    callback?.()
+  }
+
+  const setTitle = (title: string, path?: string) => {
+    tagsViewStore.setTitle(title, path)
+  }
+
+  return {
+    closeAll,
+    closeLeft,
+    closeRight,
+    closeOther,
+    closeCurrent,
+    refreshPage,
+    setTitle
+  }
+}

+ 29 - 31
src/hooks/web/useValidator.ts

@@ -1,54 +1,53 @@
-const { t } = useI18n()
+import { useI18n } from '@/hooks/web/useI18n'
+import { FormItemRule } from 'element-plus'
 
-type Callback = (error?: string | Error | undefined) => void
+const { t } = useI18n()
 
 interface LengthRange {
   min: number
   max: number
-  message: string
+  message?: string
 }
 
 export const useValidator = () => {
-  const required = (message?: string) => {
+  const required = (message?: string): FormItemRule => {
     return {
       required: true,
       message: message || t('common.required')
     }
   }
 
-  const lengthRange = (val: any, callback: Callback, options: LengthRange) => {
+  const lengthRange = (options: LengthRange): FormItemRule => {
     const { min, max, message } = options
-    if (val.length < min || val.length > max) {
-      callback(new Error(message))
-    } else {
-      callback()
-    }
-  }
 
-  const notSpace = (val: any, callback: Callback, message: string) => {
-    // 用户名不能有空格
-    if (val.indexOf(' ') !== -1) {
-      callback(new Error(message))
-    } else {
-      callback()
+    return {
+      min,
+      max,
+      message: message || t('common.lengthRange', { min, max })
     }
   }
 
-  const notSpecialCharacters = (val: any, callback: Callback, message: string) => {
-    // 密码不能是特殊字符
-    if (/[`~!@#$%^&*()_+<>?:"{},.\/;'[\]]/gi.test(val)) {
-      callback(new Error(message))
-    } else {
-      callback()
+  const notSpace = (message?: string): FormItemRule => {
+    return {
+      validator: (_, val, callback) => {
+        if (val?.indexOf(' ') !== -1) {
+          callback(new Error(message || t('common.notSpace')))
+        } else {
+          callback()
+        }
+      }
     }
   }
 
-  // 两个字符串是否想等
-  const isEqual = (val1: string, val2: string, callback: Callback, message: string) => {
-    if (val1 === val2) {
-      callback()
-    } else {
-      callback(new Error(message))
+  const notSpecialCharacters = (message?: string): FormItemRule => {
+    return {
+      validator: (_, val, callback) => {
+        if (/[`~!@#$%^&*()_+<>?:"{},.\/;'[\]]/gi.test(val)) {
+          callback(new Error(message || t('common.notSpecialCharacters')))
+        } else {
+          callback()
+        }
+      }
     }
   }
 
@@ -56,7 +55,6 @@ export const useValidator = () => {
     required,
     lengthRange,
     notSpace,
-    notSpecialCharacters,
-    isEqual
+    notSpecialCharacters
   }
 }

+ 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>

+ 3 - 2
src/layout/components/Message/src/Message.vue

@@ -53,7 +53,7 @@ onMounted(() => {
       </template>
       <ElTabs v-model="activeName">
         <ElTabPane label="我的站内信" name="notice">
-          <div class="message-list">
+          <el-scrollbar class="message-list">
             <template v-for="item in list" :key="item.id">
               <div class="message-item">
                 <img alt="" class="message-icon" src="@/assets/imgs/avatar.gif" />
@@ -67,7 +67,7 @@ onMounted(() => {
                 </div>
               </div>
             </template>
-          </div>
+          </el-scrollbar>
         </ElTabPane>
       </ElTabs>
       <!-- 更多 -->
@@ -88,6 +88,7 @@ onMounted(() => {
 }
 
 .message-list {
+  height: 400px;
   display: flex;
   flex-direction: column;
 

+ 3 - 1
src/utils/dict.ts

@@ -116,6 +116,7 @@ export enum DICT_TYPE {
   SYSTEM_OAUTH2_GRANT_TYPE = 'system_oauth2_grant_type',
   SYSTEM_MAIL_SEND_STATUS = 'system_mail_send_status',
   SYSTEM_NOTIFY_TEMPLATE_TYPE = 'system_notify_template_type',
+  SYSTEM_SOCIAL_TYPE = 'system_social_type',
 
   // ========== INFRA 模块 ==========
   INFRA_BOOLEAN_STRING = 'infra_boolean_string',
@@ -193,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'
 }

+ 2 - 2
src/utils/formatTime.ts

@@ -334,6 +334,6 @@ export function getLast1Year(): [dayjs.ConfigType, dayjs.ConfigType] {
 export function getDateRange(
   beginDate: dayjs.ConfigType,
   endDate: dayjs.ConfigType
-): [dayjs.ConfigType, dayjs.ConfigType] {
-  return [dayjs(beginDate).startOf('d'), dayjs(endDate).endOf('d')]
+): [string, string] {
+  return [dayjs(beginDate).startOf('d').toString(), dayjs(endDate).endOf('d').toString()]
 }

+ 16 - 2
src/utils/index.ts

@@ -34,6 +34,13 @@ export const underlineToHump = (str: string): string => {
   })
 }
 
+/**
+ * 驼峰转横杠
+ */
+export const humpToDash = (str: string): string => {
+  return str.replace(/([A-Z])/g, '-$1').toLowerCase()
+}
+
 export const setCssVar = (prop: string, val: any, dom = document.documentElement) => {
   dom.style.setProperty(prop, val)
 }
@@ -67,7 +74,7 @@ export const trim = (str: string) => {
  * @param {Date | number | string} time 需要转换的时间
  * @param {String} fmt 需要转换的格式 如 yyyy-MM-dd、yyyy-MM-dd HH:mm:ss
  */
-export const formatTime = (time: Date | number | string, fmt: string) => {
+export function formatTime(time: Date | number | string, fmt: string) {
   if (!time) return ''
   else {
     const date = new Date(time)
@@ -98,7 +105,7 @@ export const formatTime = (time: Date | number | string, fmt: string) => {
 /**
  * 生成随机字符串
  */
-export const toAnyString = () => {
+export function toAnyString() {
   const str: string = 'xxxxx-xxxxx-4xxxx-yxxxx-xxxxx'.replace(/[xy]/g, (c: string) => {
     const r: number = (Math.random() * 16) | 0
     const v: number = c === 'x' ? r : (r & 0x3) | 0x8
@@ -107,6 +114,13 @@ export const toAnyString = () => {
   return str
 }
 
+/**
+ * 首字母大写
+ */
+export function firstUpperCase(str: string) {
+  return str.toLowerCase().replace(/( |^)[a-z]/g, (L) => L.toUpperCase())
+}
+
 export const generateUUID = () => {
   if (typeof crypto === 'object') {
     if (typeof crypto.randomUUID === 'function') {

+ 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>
 

+ 6 - 5
src/views/Profile/Index.vue

@@ -15,7 +15,7 @@
         </div>
       </template>
       <div>
-        <el-tabs v-model="activeName" tab-position="top" style="height: 400px" class="profile-tabs">
+        <el-tabs v-model="activeName" class="profile-tabs" style="height: 400px" tab-position="top">
           <el-tab-pane :label="t('profile.info.basicInfo')" name="basicInfo">
             <BasicInfo />
           </el-tab-pane>
@@ -23,17 +23,18 @@
             <ResetPwd />
           </el-tab-pane>
           <el-tab-pane :label="t('profile.info.userSocial')" name="userSocial">
-            <UserSocial />
+            <UserSocial v-model:activeName="activeName" />
           </el-tab-pane>
         </el-tabs>
       </div>
     </el-card>
   </div>
 </template>
-<script setup lang="ts" name="Profile">
-import { BasicInfo, ProfileUser, ResetPwd, UserSocial } from './components/'
-const { t } = useI18n()
+<script lang="ts" setup>
+import { BasicInfo, ProfileUser, ResetPwd, UserSocial } from './components'
 
+const { t } = useI18n()
+defineOptions({ name: 'Profile' })
 const activeName = ref('basicInfo')
 </script>
 <style scoped>

+ 19 - 5
src/views/Profile/components/UserSocial.vue

@@ -27,12 +27,15 @@ import { getUserProfile, ProfileVO } from '@/api/system/user/profile'
 import { socialAuthRedirect, socialBind, socialUnbind } from '@/api/system/user/socialUser'
 
 defineOptions({ name: 'UserSocial' })
-
+defineProps<{
+  activeName: string
+}>()
 const message = useMessage()
 const socialUsers = ref<any[]>([])
 const userInfo = ref<ProfileVO>()
 
 const initSocial = async () => {
+  socialUsers.value = [] // 重置避免无限增长
   const res = await getUserProfile()
   userInfo.value = res
   for (const i in SystemUserSocialTypeEnum) {
@@ -49,9 +52,12 @@ const initSocial = async () => {
   }
 }
 const route = useRoute()
+const emit = defineEmits<{
+  (e: 'update:activeName', v: string): void
+}>()
 const bindSocial = () => {
   // 社交绑定
-  const type = route.query.type
+  const type = getUrlValue('type')
   const code = route.query.code
   const state = route.query.state
   if (!code) {
@@ -59,11 +65,20 @@ const bindSocial = () => {
   }
   socialBind(type, code, state).then(() => {
     message.success('绑定成功')
+    emit('update:activeName', 'userSocial')
     initSocial()
   })
 }
+
+// 双层 encode 需要在回调后进行 decode
+function getUrlValue(key: string): string {
+  const url = new URL(decodeURIComponent(location.href))
+  return url.searchParams.get(key) ?? ''
+}
+
 const bind = (row) => {
-  const redirectUri = location.origin + '/user/profile?type=' + row.type
+  // 双层 encode 解决钉钉回调 type 参数丢失的问题
+  const redirectUri = location.origin + '/user/profile?' + encodeURIComponent(`type=${row.type}`)
   // 进行跳转
   socialAuthRedirect(row.type, encodeURIComponent(redirectUri)).then((res) => {
     window.location.href = res
@@ -83,9 +98,8 @@ onMounted(async () => {
 
 watch(
   () => route,
-  (newRoute) => {
+  () => {
     bindSocial()
-    console.log(newRoute)
   },
   {
     immediate: true

+ 70 - 0
src/views/crm/product/ProductDetail.vue

@@ -0,0 +1,70 @@
+<template>
+  <Dialog v-model="dialogVisible" :max-height="500" :scroll="true" title="产品详情">
+    <el-descriptions :column="1" border>
+      <el-descriptions-item label="产品名称">
+        {{ detailData.name }}
+      </el-descriptions-item>
+      <el-descriptions-item label="创建时间">
+        {{ formatDate(detailData.createTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="状态">
+        <dict-tag :type="DICT_TYPE.CRM_PRODUCT_STATUS" :value="detailData.status" />
+      </el-descriptions-item>
+      <el-descriptions-item label="产品分类">
+        {{ productCategoryList?.find((c) => c.id === detailData.categoryId)?.name }}
+      </el-descriptions-item>
+      <el-descriptions-item label="产品编码">
+        {{ detailData.no }}
+      </el-descriptions-item>
+      <el-descriptions-item label="产品描述">
+        {{ detailData.description }}
+      </el-descriptions-item>
+      <el-descriptions-item label="负责人">
+        {{ detailData.ownerUserId }}
+      </el-descriptions-item>
+      <el-descriptions-item label="单位">
+        <dict-tag :type="DICT_TYPE.PRODUCT_UNIT" :value="detailData.unit" />
+      </el-descriptions-item>
+      <el-descriptions-item label="价格">
+        {{ fenToYuan(detailData.price) }}元
+      </el-descriptions-item>
+    </el-descriptions>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { DICT_TYPE } from '@/utils/dict'
+import * as ProductCategoryApi from '@/api/crm/productCategory'
+import * as ProductApi from '@/api/crm/product'
+import { formatDate } from '@/utils/formatTime'
+import { fenToYuan } from '@/utils'
+import { getSimpleUserList, UserVO } from '@/api/system/user'
+
+defineOptions({ name: 'CrmProductDetail' })
+
+const { t } = useI18n() // 国际化
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref() // 详情数据
+
+/** 打开弹窗 */
+const open = async (data: ProductApi.ProductVO) => {
+  dialogVisible.value = true
+  // 设置数据
+  detailLoading.value = true
+  try {
+    detailData.value = data
+  } finally {
+    detailLoading.value = false
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+const productCategoryList = ref([]) // 产品分类树
+const userList = ref<UserVO[]>([]) // 系统用户
+
+onMounted(async () => {
+  productCategoryList.value = await ProductCategoryApi.getProductCategoryList({})
+  userList.value = await getSimpleUserList()
+})
+</script>

+ 185 - 0
src/views/crm/product/ProductForm.vue

@@ -0,0 +1,185 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <!-- TODO @zange:改成每行两个哈; -->
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="产品名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入产品名称" />
+      </el-form-item>
+      <el-form-item label="产品编码" prop="no">
+        <el-input v-model="formData.no" placeholder="请输入产品编码" />
+      </el-form-item>
+      <el-form-item label="单位" prop="unit">
+        <el-select v-model="formData.unit" class="w-1/1" placeholder="请选择单位">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.PRODUCT_UNIT)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="价格" prop="price">
+        <el-input type="number" v-model="formData.price" placeholder="请输入价格" />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="formData.status" placeholder="请选择状态">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.CRM_PRODUCT_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="产品分类" prop="categoryId">
+        <el-cascader
+          v-model="formData.categoryId"
+          :options="productCategoryList"
+          :props="defaultProps"
+          class="w-1/1"
+          clearable
+          placeholder="请选择产品分类"
+          filterable
+        />
+      </el-form-item>
+      <el-form-item label="产品描述" prop="description">
+        <el-input v-model="formData.description" placeholder="请输入产品描述" />
+      </el-form-item>
+      <el-form-item label="负责人" prop="ownerUserId">
+        <el-select
+          v-model="formData.ownerUserId"
+          placeholder="请选择负责人"
+          :disabled="formData.id"
+        >
+          <el-option
+            v-for="user in userList"
+            :key="user.id"
+            :label="user.nickname"
+            :value="user.id"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import * as ProductApi from '@/api/crm/product'
+import * as ProductCategoryApi from '@/api/crm/productCategory'
+import { defaultProps, handleTree } from '@/utils/tree'
+import { getSimpleUserList, UserVO } from '@/api/system/user'
+import { useUserStore } from '@/store/modules/user'
+
+defineOptions({ name: 'CrmProductForm' })
+
+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 userId = useUserStore().getUser.id // 当前登录的编号
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  no: undefined,
+  unit: undefined,
+  price: undefined,
+  status: undefined,
+  categoryId: undefined,
+  description: undefined,
+  ownerUserId: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }],
+  no: [{ required: true, message: '产品编码不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'change' }],
+  categoryId: [{ required: true, message: '产品分类ID不能为空', trigger: 'blur' }],
+  ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }],
+  unit: [{ required: true, message: '单位不能为空', trigger: 'blur' }],
+  price: [{ required: true, message: '价格不能为空', trigger: 'blur' }]
+})
+
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  formData.value.ownerUserId = userId
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ProductApi.getProduct(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+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 as unknown as ProductApi.ProductVO
+    if (formType.value === 'create') {
+      await ProductApi.createProduct(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ProductApi.updateProduct(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    no: undefined,
+    unit: undefined,
+    price: undefined,
+    status: undefined,
+    categoryId: undefined,
+    description: undefined,
+    ownerUserId: undefined
+  }
+  formRef.value?.resetFields()
+}
+const productCategoryList = ref<any[]>([]) // 产品分类树
+const userList = ref<UserVO[]>([]) // 系统用户
+
+onMounted(async () => {
+  const data = await ProductCategoryApi.getProductCategoryList({})
+  productCategoryList.value = handleTree(data, 'id', 'parentId')
+  userList.value = await getSimpleUserList()
+})
+</script>

+ 269 - 0
src/views/crm/product/index.vue

@@ -0,0 +1,269 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="产品名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入产品名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="产品编码" prop="no">
+        <el-input
+          v-model="queryParams.no"
+          placeholder="请输入产品编码"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px">
+          <el-option
+            v-for="dict in getBoolDictOptions(DICT_TYPE.CRM_PRODUCT_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="产品分类" prop="categoryId">
+        <el-input
+          v-model="queryParams.categoryId"
+          placeholder="请选择产品分类"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button type="primary" @click="openForm('create')" v-hasPermi="['crm:product:create']">
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['crm:product:export']"
+        >
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <!--<el-table-column label="主键id" align="center" prop="id" />-->
+      <el-table-column label="产品名称" align="center" prop="name" />
+      <el-table-column label="产品编码" align="center" prop="no" />
+      <el-table-column label="单位" align="center" prop="unit">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PRODUCT_UNIT" :value="scope.row.unit" />
+        </template>
+      </el-table-column>
+      <el-table-column label="价格" align="center" prop="price">
+        <template #default="{ row }">
+          {{ fenToYuan(row.price) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.CRM_PRODUCT_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="产品分类" align="center" prop="categoryId">
+        <template #default="{ row }">
+          <span>{{ productCategoryList?.find((c) => c.id === row.categoryId)?.name }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="产品描述" align="center" prop="description" />
+      <el-table-column label="负责人" align="center" prop="ownerUserId">
+        <template #default="{ row }">
+          <span>{{ userList?.find((c) => c.id === row.ownerUserId)?.nickname }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center" width="160">
+        <template #default="scope">
+          <el-button
+            v-hasPermi="['crm:product:query']"
+            link
+            type="primary"
+            @click="openDetail(scope.row)"
+          >
+            详情
+          </el-button>
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['crm:product:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['crm:product:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <ProductForm ref="formRef" @success="getList" />
+
+  <!-- 表单弹窗:详情 -->
+  <ProductDetail ref="detailRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { DICT_TYPE, getBoolDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import download from '@/utils/download'
+import * as ProductApi from '@/api/crm/product'
+import ProductForm from './ProductForm.vue'
+import ProductDetail from './ProductDetail.vue'
+import { fenToYuan } from '@/utils'
+import * as ProductCategoryApi from '@/api/crm/productCategory'
+import { getSimpleUserList, UserVO } from '@/api/system/user'
+
+defineOptions({ name: 'CrmProduct' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  no: null,
+  unit: null,
+  price: null,
+  status: null,
+  categoryId: null,
+  description: null,
+  ownerUserId: null,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ProductApi.getProductPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+/** 详情操作 */
+const detailRef = ref()
+const openDetail = (data: ProductApi.ProductVO) => {
+  detailRef.value.open(data)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ProductApi.deleteProduct(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await ProductApi.exportProduct(queryParams)
+    download.excel(data, '产品.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+const productCategoryList = ref([]) // 产品分类树
+const userList = ref<UserVO[]>([]) // 系统用户
+
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+  productCategoryList.value = await ProductCategoryApi.getProductCategoryList({})
+  userList.value = await getSimpleUserList()
+})
+</script>

+ 110 - 0
src/views/crm/productCategory/ProductCategoryForm.vue

@@ -0,0 +1,110 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="父级id" prop="parentId">
+        <el-select v-model="formData.parentId" placeholder="请选择上级分类">
+          <el-option :key="0" label="顶级分类" :value="0" />
+          <el-option
+            v-for="item in productCategoryList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入名称" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import * as ProductCategoryApi from '@/api/crm/productCategory'
+
+defineOptions({ name: 'CrmProductCategoryForm' })
+
+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 formData = ref({
+  id: undefined,
+  name: undefined,
+  parentId: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
+  parentId: [{ required: true, message: '父级分类不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const productCategoryList = ref<any[]>([]) // 产品分类树
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ProductCategoryApi.getProductCategory(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 获得分类树
+  productCategoryList.value = await ProductCategoryApi.getProductCategoryList({ parentId: 0 })
+}
+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 as unknown as ProductCategoryApi.ProductCategoryVO
+    if (formType.value === 'create') {
+      await ProductCategoryApi.createProductCategory(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ProductCategoryApi.updateProductCategory(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    parentId: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 138 - 0
src/views/crm/productCategory/index.vue

@@ -0,0 +1,138 @@
+<template>
+  <!-- TODO @zange:挪到 product 下,建个 category 包,挪进去哈; -->
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['crm:product-category:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" row-key="id" default-expand-all>
+      <el-table-column label="名称" align="center" prop="name" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['crm:product-category:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['crm:product-category:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <ProductCategoryForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import download from '@/utils/download'
+import * as ProductCategoryApi from '@/api/crm/productCategory'
+import ProductCategoryForm from './ProductCategoryForm.vue'
+import { handleTree } from '@/utils/tree'
+
+defineOptions({ name: 'CrmProductCategory' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<any[]>([]) // 列表的数据
+const queryParams = reactive({
+  name: null
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ProductCategoryApi.getProductCategoryList(queryParams)
+    list.value = handleTree(data, 'id', 'parentId')
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ProductCategoryApi.deleteProductCategory(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 28 - 18
src/views/crm/receivable/ReceivableForm.vue

@@ -10,14 +10,14 @@
       <el-form-item label="回款编号" prop="no">
         <el-input v-model="formData.no" placeholder="请输入回款编号" />
       </el-form-item>
-      <!--<el-form-item label="回款计划ID" prop="planId">
-        <el-input v-model="formData.planId" placeholder="请输入回款计划ID" />
-      </el-form-item>-->
-      <el-form-item label="客户ID" prop="customerId">
-        <el-input v-model="formData.customerId" placeholder="请输入客户ID" />
+      <el-form-item label="回款计划" prop="planId">
+        <el-input v-model="formData.planId" placeholder="请输入回款计划" />
+      </el-form-item>
+      <el-form-item label="客户名称" prop="customerId">
+        <el-input v-model="formData.customerId" placeholder="请输入客户名称" />
       </el-form-item>
-      <el-form-item label="合同ID" prop="contractId">
-        <el-input v-model="formData.contractId" placeholder="请输入合同ID" />
+      <el-form-item label="合同名称" prop="contractId">
+        <el-input v-model="formData.contractId" placeholder="请输入合同名称" />
       </el-form-item>
       <!--<el-form-item label="审批状态" prop="checkStatus">
         <el-select v-model="formData.checkStatus" placeholder="请选择审批状态">
@@ -54,15 +54,22 @@
         <el-input-number v-model="formData.price" placeholder="请输入回款金额" />
       </el-form-item>
       <el-form-item label="负责人" prop="ownerUserId">
-        <el-input v-model="formData.ownerUserId" placeholder="请输入负责人" />
+        <el-select v-model="formData.ownerUserId" clearable placeholder="请输入负责人">
+          <el-option
+            v-for="item in userList"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
       </el-form-item>
       <el-form-item label="批次" prop="batchId">
-        <el-input v-model="formData.batchId" placeholder="请输入批次" />
+        <el-input-number v-model="formData.batchId" placeholder="请输入批次" />
       </el-form-item>
-      <!--<el-form-item label="显示顺序" prop="sort">
-        <el-input v-model="formData.sort" placeholder="请输入显示顺序" />
-      </el-form-item>-->
-      <el-form-item label="状态" prop="status">
+      <el-form-item label="显示排序" prop="sort">
+        <el-input-number v-model="formData.sort" :min="0" controls-position="right" />
+      </el-form-item>
+      <!--<el-form-item label="状态" prop="status">
         <el-select v-model="formData.status" placeholder="请选择状态">
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
@@ -71,7 +78,7 @@
             :value="dict.value"
           />
         </el-select>
-      </el-form-item>
+      </el-form-item>-->
       <el-form-item label="备注" prop="remark">
         <el-input type="textarea" :rows="3" v-model="formData.remark" placeholder="请输入备注" />
       </el-form-item>
@@ -85,10 +92,11 @@
 <script setup lang="ts">
 import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
 import * as ReceivableApi from '@/api/crm/receivable'
+import * as UserApi from '@/api/system/user'
 
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
-
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
 const dialogVisible = ref(false) // 弹窗的是否展示
 const dialogTitle = ref('') // 弹窗的标题
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
@@ -112,9 +120,9 @@ const formData = ref({
   status: undefined,
   remark: undefined
 })
-const formRules = reactive({
-  status: [{ required: true, message: '状态不能为空', trigger: 'change' }]
-})
+// const formRules = reactive({
+//   status: [{ required: true, message: '状态不能为空', trigger: 'change' }]
+// })
 const formRef = ref() // 表单 Ref
 
 /** 打开弹窗 */
@@ -132,6 +140,8 @@ const open = async (type: string, id?: number) => {
       formLoading.value = false
     }
   }
+  // 获得用户列表
+  userList.value = await UserApi.getSimpleUserList()
 }
 defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 

+ 11 - 7
src/views/crm/receivable/index.vue

@@ -26,19 +26,19 @@
           class="!w-240px"
         />
       </el-form-item>-->
-      <el-form-item label="客户" prop="customerId">
+      <el-form-item label="客户名称" prop="customerId">
         <el-input
           v-model="queryParams.customerId"
-          placeholder="请输入客户"
+          placeholder="请输入客户名称"
           clearable
           @keyup.enter="handleQuery"
           class="!w-240px"
         />
       </el-form-item>
-      <el-form-item label="合同" prop="contractId">
+      <el-form-item label="合同名称" prop="contractId">
         <el-input
           v-model="queryParams.contractId"
-          placeholder="请输入合同"
+          placeholder="请输入合同名称"
           clearable
           @keyup.enter="handleQuery"
           class="!w-240px"
@@ -103,7 +103,7 @@
           class="!w-240px"
         />
       </el-form-item>-->
-      <el-form-item label="负责人" prop="ownerUserId">
+      <!--<el-form-item label="负责人" prop="ownerUserId">
         <el-input
           v-model="queryParams.ownerUserId"
           placeholder="请输入负责人"
@@ -112,7 +112,7 @@
           class="!w-240px"
         />
       </el-form-item>
-      <!--<el-form-item label="批次" prop="batchId">
+      <el-form-item label="批次" prop="batchId">
         <el-input
           v-model="queryParams.batchId"
           placeholder="请输入批次"
@@ -227,8 +227,12 @@
         :formatter="dateFormatter"
         width="180px"
       />
-      <el-table-column label="操作" align="center" width="130px">
+      <el-table-column label="操作" align="center" width="180px">
         <template #default="scope">
+          <!-- todo @liuhongfeng:用路径参数哈,receivableId -->
+          <!--<router-link :to="'/crm/receivable-plan?receivableId=' + scope.row.receivableId">
+            <el-button link type="primary">详情</el-button>
+          </router-link>-->
           <el-button
             link
             type="primary"

+ 27 - 18
src/views/crm/receivablePlan/ReceivablePlanForm.vue

@@ -7,8 +7,24 @@
       label-width="100px"
       v-loading="formLoading"
     >
-      <el-form-item label="期数" prop="indexNo">
-        <el-input-number v-model="formData.indexNo" placeholder="请输入期数" />
+      <el-form-item label="客户名称" prop="customerId">
+        <el-input v-model="formData.customerId" placeholder="请输入客户名称" />
+      </el-form-item>
+      <el-form-item label="合同名称" prop="contractId">
+        <el-input v-model="formData.contractId" placeholder="请输入合同名称" />
+      </el-form-item>
+      <el-form-item label="负责人" prop="ownerUserId">
+        <el-select v-model="formData.ownerUserId" clearable placeholder="请输入负责人">
+          <el-option
+            v-for="item in userList"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="期数" prop="period">
+        <el-input-number v-model="formData.period" placeholder="请输入期数" />
       </el-form-item>
       <!--<el-form-item label="回款ID" prop="receivableId">
         <el-input v-model="formData.receivableId" placeholder="请输入回款ID" />
@@ -58,18 +74,9 @@
           placeholder="选择提醒日期"
         />
       </el-form-item>
-      <el-form-item label="客户ID" prop="customerId">
-        <el-input v-model="formData.customerId" placeholder="请输入客户ID" />
+      <el-form-item label="显示排序" prop="sort">
+        <el-input-number v-model="formData.sort" :min="0" controls-position="right" />
       </el-form-item>
-      <el-form-item label="合同ID" prop="contractId">
-        <el-input v-model="formData.contractId" placeholder="请输入合同ID" />
-      </el-form-item>
-      <el-form-item label="负责人" prop="ownerUserId">
-        <el-input v-model="formData.ownerUserId" placeholder="请输入负责人" />
-      </el-form-item>
-      <!--<el-form-item label="显示顺序" prop="sort">
-        <el-input v-model="formData.sort" placeholder="请输入显示顺序" />
-      </el-form-item>-->
       <el-form-item label="备注" prop="remark">
         <el-input type="textarea" :rows="3" v-model="formData.remark" placeholder="请输入备注" />
       </el-form-item>
@@ -81,19 +88,18 @@
   </Dialog>
 </template>
 <script setup lang="ts">
-import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
 import * as ReceivablePlanApi from '@/api/crm/receivablePlan'
-
+import * as UserApi from '@/api/system/user'
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
-
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
 const dialogVisible = ref(false) // 弹窗的是否展示
 const dialogTitle = ref('') // 弹窗的标题
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
 const formType = ref('') // 表单的类型:create - 新增;update - 修改
 const formData = ref({
   id: undefined,
-  indexNo: undefined,
+  period: undefined,
   receivableId: undefined,
   status: undefined,
   checkStatus: undefined,
@@ -128,6 +134,9 @@ const open = async (type: string, id?: number) => {
       formLoading.value = false
     }
   }
+
+  // 获得用户列表
+  userList.value = await UserApi.getSimpleUserList()
 }
 defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 
@@ -161,7 +170,7 @@ const submitForm = async () => {
 const resetForm = () => {
   formData.value = {
     id: undefined,
-    indexNo: undefined,
+    period: undefined,
     receivableId: undefined,
     status: undefined,
     checkStatus: undefined,

+ 49 - 50
src/views/crm/receivablePlan/index.vue

@@ -8,10 +8,19 @@
       :inline="true"
       label-width="68px"
     >
-      <el-form-item label="期数" prop="indexNo">
+      <el-form-item label="客户" prop="customerId">
+        <el-input
+          v-model="queryParams.customerId"
+          placeholder="请输入客户"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="合同" prop="contractId">
         <el-input
-          v-model="queryParams.indexNo"
-          placeholder="请输入期数"
+          v-model="queryParams.contractId"
+          placeholder="请输入合同"
           clearable
           @keyup.enter="handleQuery"
           class="!w-240px"
@@ -67,7 +76,7 @@
           class="!w-240px"
         />
       </el-form-item>-->
-      <el-form-item label="提醒日期" prop="remindTime">
+      <!--<el-form-item label="提醒日期" prop="remindTime">
         <el-date-picker
           v-model="queryParams.remindTime"
           value-format="YYYY-MM-DD HH:mm:ss"
@@ -77,26 +86,8 @@
           :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
           class="!w-240px"
         />
-      </el-form-item>
-      <el-form-item label="客户" prop="customerId">
-        <el-input
-          v-model="queryParams.customerId"
-          placeholder="请输入客户"
-          clearable
-          @keyup.enter="handleQuery"
-          class="!w-240px"
-        />
-      </el-form-item>
-      <el-form-item label="合同" prop="contractId">
-        <el-input
-          v-model="queryParams.contractId"
-          placeholder="请输入合同"
-          clearable
-          @keyup.enter="handleQuery"
-          class="!w-240px"
-        />
-      </el-form-item>
-      <el-form-item label="负责人" prop="ownerUserId">
+      </el-form-item>-->
+      <!--<el-form-item label="负责人" prop="ownerUserId">
         <el-input
           v-model="queryParams.ownerUserId"
           placeholder="请输入负责人"
@@ -105,7 +96,7 @@
           class="!w-240px"
         />
       </el-form-item>
-      <!--<el-form-item label="备注" prop="remark">
+      <el-form-item label="备注" prop="remark">
         <el-input
           v-model="queryParams.remark"
           placeholder="请输入备注"
@@ -152,40 +143,44 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
-      <el-table-column label="ID" align="center" prop="id" />
-      <el-table-column label="期数" align="center" prop="indexNo" />
-      <!--<el-table-column label="回款ID" align="center" prop="receivableId" />-->
-      <el-table-column label="完成状态" align="center" prop="status">
-        <template #default="scope">
-          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
-        </template>
-      </el-table-column>
-      <el-table-column label="审批状态" align="center" prop="checkStatus" width="130px">
-        <template #default="scope">
-          <dict-tag :type="DICT_TYPE.CRM_RECEIVABLE_CHECK_STATUS" :value="scope.row.checkStatus" />
-        </template>
-      </el-table-column>
-      <!--<el-table-column label="工作流编号" align="center" prop="processInstanceId" />-->
-      <el-table-column label="回款金额" align="center" prop="price" />
+      <!--<el-table-column label="ID" align="center" prop="id" />-->
+      <el-table-column label="客户名称" align="center" prop="customerId" width="150px" />
+      <el-table-column label="合同名称" align="center" prop="contractId" width="150px" />
+      <el-table-column label="期数" align="center" prop="period" />
+      <el-table-column label="计划回款" align="center" prop="price" />
       <el-table-column
-        label="回款日期"
+        label="计划回款日期"
         align="center"
         prop="returnTime"
         :formatter="dateFormatter2"
         width="180px"
       />
       <el-table-column label="提前几天提醒" align="center" prop="remindDays" />
-      <el-table-column
+      <!--<el-table-column
         label="提醒日期"
         align="center"
         prop="remindTime"
         :formatter="dateFormatter"
         width="180px"
-      />
-      <el-table-column label="客户ID" align="center" prop="customerId" />
-      <el-table-column label="合同ID" align="center" prop="contractId" />
-      <el-table-column label="负责人" align="center" prop="ownerUserId" />
-      <!--<el-table-column label="显示顺序" align="center" prop="sort" />-->
+      />-->
+      <!--<el-table-column label="回款ID" align="center" prop="receivableId" />-->
+      <el-table-column label="完成状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="审批状态" align="center" prop="checkStatus" width="130px">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.CRM_RECEIVABLE_CHECK_STATUS" :value="scope.row.checkStatus" />
+        </template>
+      </el-table-column>
+      <!--<el-table-column label="工作流编号" align="center" prop="processInstanceId" />-->
+      <el-table-column prop="ownerUserId" label="负责人" width="120">
+        <template #default="scope">
+          {{ userList.find((user) => user.id === scope.row.ownerUserId)?.nickname }}
+        </template>
+      </el-table-column>
+      <el-table-column label="显示顺序" align="center" prop="sort" />
       <el-table-column label="备注" align="center" prop="remark" />
       <el-table-column
         label="创建时间"
@@ -234,6 +229,7 @@ import { dateFormatter, dateFormatter2 } from '@/utils/formatTime'
 import download from '@/utils/download'
 import * as ReceivablePlanApi from '@/api/crm/receivablePlan'
 import ReceivablePlanForm from './ReceivablePlanForm.vue'
+import * as UserApi from '@/api/system/user'
 
 defineOptions({ name: 'ReceivablePlan' })
 
@@ -243,10 +239,11 @@ const { t } = useI18n() // 国际化
 const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数
 const list = ref([]) // 列表的数据
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
 const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  indexNo: null,
+  period: null,
   status: null,
   checkStatus: null,
   returnTime: [],
@@ -320,7 +317,9 @@ const handleExport = async () => {
 }
 
 /** 初始化 **/
-onMounted(() => {
-  getList()
+onMounted(async () => {
+  await getList()
+  // 获取用户列表
+  userList.value = await UserApi.getSimpleUserList()
 })
 </script>

+ 2 - 26
src/views/mall/promotion/diy/page/decorate.vue

@@ -3,7 +3,7 @@
     v-if="formData && !formLoading"
     v-model="formData.property"
     :title="formData.name"
-    :libs="componentLibs"
+    :libs="PAGE_LIBS"
     :show-page-config="true"
     :show-navigation-bar="true"
     :show-tab-bar="false"
@@ -13,35 +13,11 @@
 <script setup lang="ts">
 import * as DiyPageApi from '@/api/mall/promotion/diy/page'
 import { useTagsViewStore } from '@/store/modules/tagsView'
-import { DiyComponentLibrary } from '@/components/DiyEditor/util'
+import { PAGE_LIBS } from '@/components/DiyEditor/util'
 
 /** 装修页面表单 */
 defineOptions({ name: 'DiyPageDecorate' })
 
-// 组件库
-const componentLibs = [
-  {
-    name: '基础组件',
-    extended: true,
-    components: [
-      'SearchBar',
-      'NoticeBar',
-      'GridNavigation',
-      'ListNavigation',
-      'Divider',
-      'TitleBar'
-    ]
-  },
-  { name: '图文组件', extended: true, components: ['Carousel'] },
-  { name: '商品组件', extended: true, components: ['ProductCard'] },
-  {
-    name: '会员组件',
-    extended: true,
-    components: ['UserCard', 'OrderCard', 'WalletCard', 'CouponCard']
-  },
-  { name: '营销组件', extended: true, components: ['Combination', 'Seckill', 'Point', 'Coupon'] }
-] as DiyComponentLibrary[]
-
 const message = useMessage() // 消息弹窗
 
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用

+ 2 - 25
src/views/mall/promotion/diy/template/decorate.vue

@@ -28,7 +28,7 @@
 import * as DiyTemplateApi from '@/api/mall/promotion/diy/template'
 import * as DiyPageApi from '@/api/mall/promotion/diy/page'
 import { useTagsViewStore } from '@/store/modules/tagsView'
-import { DiyComponentLibrary } from '@/components/DiyEditor/util'
+import { DiyComponentLibrary, PAGE_LIBS } from '@/components/DiyEditor/util'
 
 /** 装修模板表单 */
 defineOptions({ name: 'DiyTemplateDecorate' })
@@ -62,29 +62,6 @@ const getPageDetail = async (id: any) => {
 
 // 模板组件库
 const templateLibs = [] as DiyComponentLibrary[]
-// 页面组件库
-const pageLibs = [
-  {
-    name: '基础组件',
-    extended: true,
-    components: [
-      'SearchBar',
-      'NoticeBar',
-      'GridNavigation',
-      'ListNavigation',
-      'Divider',
-      'TitleBar'
-    ]
-  },
-  { name: '图文组件', extended: true, components: ['Carousel'] },
-  { name: '商品组件', extended: true, components: ['ProductCard'] },
-  {
-    name: '会员组件',
-    extended: true,
-    components: ['UserCard', 'OrderCard', 'WalletCard', 'CouponCard']
-  },
-  { name: '营销组件', extended: true, components: ['Combination', 'Seckill', 'Point', 'Coupon'] }
-] as DiyComponentLibrary[]
 // 当前组件库
 const libs = ref<DiyComponentLibrary[]>(templateLibs)
 // 模板选项切换
@@ -97,7 +74,7 @@ const handleTemplateItemChange = () => {
   }
 
   // 编辑页面
-  libs.value = pageLibs
+  libs.value = PAGE_LIBS
   currentFormData.value = formData.value!.pages.find(
     (page: DiyPageApi.DiyPageVO) => page.name === templateItems[selectedTemplateItem.value].name
   )

+ 196 - 0
src/views/mall/promotion/rewardActivity/RewardForm.vue

@@ -0,0 +1,196 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="80px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="活动名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入活动名称" />
+      </el-form-item>
+      <el-form-item label="活动时间" prop="startAndEndTime">
+        <el-date-picker
+          v-model="formData.startAndEndTime"
+          type="datetimerange"
+          range-separator="-"
+          :start-placeholder="t('common.startTimeText')"
+          :end-placeholder="t('common.endTimeText')"
+        />
+      </el-form-item>
+      <el-form-item label="条件类型" prop="conditionType">
+        <el-radio-group v-model="formData.conditionType">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_CONDITION_TYPE)"
+            :key="dict.value"
+            :label="parseInt(dict.value)"
+            >{{ dict.label }}</el-radio
+          >
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="优惠设置">
+        <!-- TODO 待实现!这个实现下哈 -->
+      </el-form-item>
+      <el-form-item label="活动商品" prop="productScope">
+        <el-radio-group v-model="formData.productScope">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_PRODUCT_SCOPE)"
+            :key="dict.value"
+            :label="parseInt(dict.value)"
+            >{{ dict.label }}</el-radio
+          >
+        </el-radio-group>
+      </el-form-item>
+      <!-- TODO:活动商品的开发,可以参考优惠劵的,已经搞好啦; -->
+      <el-form-item
+        v-if="formData.productScope === PromotionProductScopeEnum.SPU.scope"
+        prop="productSpuIds"
+      >
+        <el-select
+          v-model="formData.productSpuIds"
+          placeholder="请选择活动商品"
+          clearable
+          size="small"
+          multiple
+          filterable
+          style="width: 400px"
+        >
+          <el-option v-for="item in productSpus" :key="item.id" :label="item.name" :value="item.id">
+            <span style="float: left">{{ item.name }}</span>
+            <span style="float: right; font-size: 13px; color: #8492a6"
+              >¥{{ (item.price / 100.0).toFixed(2) }}</span
+            >
+          </el-option>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="备注" prop="remark">
+        <el-input v-model="formData.remark" placeholder="请输入备注" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { getSpuSimpleList } from '@/api/mall/product/spu'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { CommonStatusEnum } from '@/utils/constants'
+import * as ProductBrandApi from '@/api/mall/product/brand'
+import {
+  PromotionConditionTypeEnum,
+  PromotionProductScopeEnum,
+  PromotionActivityStatusEnum
+} from '@/utils/constants'
+// 商品数据
+const productSpus = ref<any[]>([])
+
+/** 初始化 **/
+onMounted(() => {
+  getSpuSimpleList().then((response) => {
+    productSpus.value = response
+  })
+})
+defineOptions({ name: 'ProductBrandForm' })
+
+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 formData = ref({
+  id: undefined,
+  name: undefined,
+  startAndEndTime: undefined,
+  startTime: undefined,
+  endTime: undefined,
+  conditionType: PromotionConditionTypeEnum.PRICE.type,
+  remark: undefined,
+  productScope: PromotionProductScopeEnum.ALL.scope,
+  productSpuIds: undefined,
+  rules: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '活动名称不能为空', trigger: 'blur' }],
+  startAndEndTime: [{ required: true, message: '活动时间不能为空', trigger: 'blur' }],
+  conditionType: [{ required: true, message: '条件类型不能为空', trigger: 'change' }],
+  productScope: [{ required: true, message: '商品范围不能为空', trigger: 'blur' }],
+  productSpuIds: [{ required: true, message: '商品范围不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      // formData.value = await ProductBrandApi.getBrand(id)
+      formData.value = {
+        conditionType: 10,
+        description: '',
+        id: undefined,
+        name: '测试活动',
+        picUrl: '',
+        productScope: 2,
+        productSpuIds: [634],
+        remark: '测试备注',
+        startAndEndTime: [new Date(), new Date('2023-12-31')],
+        status: 0
+      }
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  console.log(formData.value)
+  message.success('已在控制台打印数据')
+  return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as ProductBrandApi.BrandVO
+    if (formType.value === 'create') {
+      await ProductBrandApi.createBrand(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ProductBrandApi.updateBrand(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: '',
+    picUrl: '',
+    status: CommonStatusEnum.ENABLE,
+    description: ''
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 213 - 0
src/views/mall/promotion/rewardActivity/index.vue

@@ -0,0 +1,213 @@
+<template>
+  <!-- 搜索工作栏 -->
+  <ContentWrap>
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="活动名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入活动名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="活动状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          placeholder="请选择活动状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_ACTIVITY_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="活动时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="活动开始日期"
+          end-placeholder="活动结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['product:brand:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" row-key="id" default-expand-all>
+      <el-table-column label="活动名称" prop="name" />
+      <el-table-column
+        label="活动开始时间"
+        align="center"
+        prop="sort[0]"
+        :formatter="dateFormatter"
+      />
+      <el-table-column
+        label="活动结束时间"
+        align="center"
+        prop="sort[1]"
+        :formatter="dateFormatter"
+      />
+      <el-table-column label="状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PROMOTION_ACTIVITY_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        width="180"
+        :formatter="dateFormatter"
+      />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['product:brand:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['product:brand:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <RewardForm ref="formRef" @success="getList" />
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import * as ProductBrandApi from '@/api/mall/product/brand'
+import RewardForm from './RewardForm.vue'
+
+defineOptions({ name: 'PromotionRewardActivity' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref<any[]>([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  status: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    // const data = await ProductBrandApi.getBrandParam(queryParams)
+    const data = {
+      list: [
+        {
+          createTime: 1693463998000,
+          description: '',
+          id: 3,
+          name: '索尼',
+          picUrl:
+            'http://127.0.0.1:48080/admin-api/infra/file/4/get/f5b7a536306cd1180a42a2211a8212dc23de6b949d30c30d036caa063042f928.png',
+          sort: [+new Date(), +new Date('2023-12-31')],
+          status: 10
+        }
+      ],
+      total: 1
+    }
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  console.log(queryParams)
+  message.success('已打印搜索参数')
+  return
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  message.success('重置查询表单获取数据')
+  return
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    message.success('您以确认删除')
+    return
+    // 发起删除
+    await ProductBrandApi.deleteBrand(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 1 - 1
src/views/mall/trade/delivery/expressTemplate/ExpressTemplateForm.vue

@@ -31,7 +31,7 @@
                 :props="defaultProps2"
                 class="w-1/1"
                 clearable
-                placeholder="请选择商品分类"
+                placeholder="请选择地区"
                 filterable
                 collapse-tags
               />

+ 1 - 1
src/views/system/loginlog/LoginLogDetail.vue

@@ -34,7 +34,7 @@ defineOptions({ name: 'SystemLoginLogDetail' })
 
 const dialogVisible = ref(false) // 弹窗的是否展示
 const detailLoading = ref(false) // 表单的加载中
-const detailData = ref() // 详情数据
+const detailData = ref({}) // 详情数据
 
 /** 打开弹窗 */
 const open = async (data: LoginLogApi.LoginLogVO) => {

+ 5 - 5
src/views/system/menu/MenuForm.vue

@@ -38,7 +38,7 @@
         <template #label>
           <Tooltip
             message="访问的路由地址,如:`user`。如需外网地址时,则以 `http(s)://` 开头"
-            titel="路由地址"
+            title="路由地址"
           />
         </template>
         <el-input v-model="formData.path" clearable placeholder="请输入路由地址" />
@@ -53,7 +53,7 @@
         <template #label>
           <Tooltip
             message="Controller 方法上的权限字符,如:@PreAuthorize(`@ss.hasPermission('system:user:list')`)"
-            titel="权限标识"
+            title="权限标识"
           />
         </template>
         <el-input v-model="formData.permission" clearable placeholder="请输入权限标识" />
@@ -74,7 +74,7 @@
       </el-form-item>
       <el-form-item v-if="formData.type !== 3" label="显示状态" prop="visible">
         <template #label>
-          <Tooltip message="选择隐藏时,路由将不会出现在侧边栏,但仍然可以访问" titel="显示状态" />
+          <Tooltip message="选择隐藏时,路由将不会出现在侧边栏,但仍然可以访问" title="显示状态" />
         </template>
         <el-radio-group v-model="formData.visible">
           <el-radio key="true" :label="true" border>显示</el-radio>
@@ -85,7 +85,7 @@
         <template #label>
           <Tooltip
             message="选择不是时,当该菜单只有一个子菜单时,不展示自己,直接展示子菜单"
-            titel="总是显示"
+            title="总是显示"
           />
         </template>
         <el-radio-group v-model="formData.alwaysShow">
@@ -97,7 +97,7 @@
         <template #label>
           <Tooltip
             message="选择缓存时,则会被 `keep-alive` 缓存,必须填写「组件名称」字段"
-            titel="缓存状态"
+            title="缓存状态"
           />
         </template>
         <el-radio-group v-model="formData.keepAlive">

+ 1 - 1
src/views/system/menu/index.vue

@@ -1,6 +1,6 @@
 <template>
   <doc-alert title="功能权限" url="https://doc.iocoder.cn/resource-permission" />
-  <doc-alert title="菜单路由" url="https://doc.iocoder.cn/vue2/route/" />
+  <doc-alert title="菜单路由" url="https://doc.iocoder.cn/vue3/route/" />
 
   <!-- 搜索工作栏 -->
   <ContentWrap>

+ 154 - 0
src/views/system/social/client/SocialClientForm.vue

@@ -0,0 +1,154 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+    >
+      <el-form-item label="应用名" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入应用名" />
+      </el-form-item>
+      <el-form-item label="社交平台" prop="socialType">
+        <el-radio-group v-model="formData.socialType">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SOCIAL_TYPE)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="用户类型" prop="userType">
+        <el-radio-group v-model="formData.userType">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="客户端编号" prop="clientId">
+        <el-input v-model="formData.clientId" placeholder="请输入客户端编号,对应各平台的appKey" />
+      </el-form-item>
+      <el-form-item label="客户端密钥" prop="clientSecret">
+        <el-input
+          v-model="formData.clientSecret"
+          placeholder="请输入客户端密钥,对应各平台的appSecret"
+        />
+      </el-form-item>
+      <el-form-item label="agentId" prop="agentId" v-if="formData!.socialType === 30">
+        <el-input v-model="formData.agentId" placeholder="授权方的网页应用 ID,有则填" />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </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 { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import * as SocialClientApi from '@/api/system/social/client'
+
+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 formData = ref({
+  id: undefined,
+  name: undefined,
+  socialType: undefined,
+  userType: undefined,
+  clientId: undefined,
+  clientSecret: undefined,
+  agentId: undefined,
+  status: 0
+})
+const formRules = reactive({
+  name: [{ required: true, message: '应用名不能为空', trigger: 'blur' }],
+  socialType: [{ required: true, message: '社交平台不能为空', trigger: 'blur' }],
+  userType: [{ required: true, message: '用户类型不能为空', trigger: 'blur' }],
+  clientId: [{ required: true, message: '客户端编号不能为空', trigger: 'blur' }],
+  clientSecret: [{ required: true, message: '客户端密钥不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await SocialClientApi.getSocialClient(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+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 as unknown as SocialClientApi.SocialClientVO
+    if (formType.value === 'create') {
+      await SocialClientApi.createSocialClient(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await SocialClientApi.updateSocialClient(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    socialType: undefined,
+    userType: undefined,
+    clientId: undefined,
+    clientSecret: undefined,
+    agentId: undefined,
+    status: 0
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 227 - 0
src/views/system/social/client/index.vue

@@ -0,0 +1,227 @@
+<template>
+  <doc-alert title="三方登录" url="https://doc.iocoder.cn/social-user/" />
+
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="130px"
+    >
+      <el-form-item label="应用名" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          class="!w-240px"
+          clearable
+          placeholder="请输入应用名"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="社交平台" prop="socialType">
+        <el-select
+          v-model="queryParams.socialType"
+          class="!w-240px"
+          clearable
+          placeholder="请选择社交平台"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SOCIAL_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="用户类型" prop="userType">
+        <el-select
+          v-model="queryParams.userType"
+          class="!w-240px"
+          clearable
+          placeholder="请选择用户类型"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="客户端编号" prop="clientId">
+        <el-input
+          v-model="queryParams.clientId"
+          class="!w-240px"
+          clearable
+          placeholder="请输入客户端编号"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择状态">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
+        <el-button
+          v-hasPermi="['system:social-client:create']"
+          plain
+          type="primary"
+          @click="openForm('create')"
+        >
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" label="编号" prop="id" />
+      <el-table-column align="center" label="应用名" prop="name" />
+      <el-table-column align="center" label="社交平台" prop="socialType">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.SYSTEM_SOCIAL_TYPE" :value="scope.row.socialType" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="用户类型" prop="userType">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.USER_TYPE" :value="scope.row.userType" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="客户端编号" prop="clientId" width="180px" />
+      <el-table-column align="center" label="状态" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="操作">
+        <template #default="scope">
+          <el-button
+            v-hasPermi="['system:social-client:update']"
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+          >
+            编辑
+          </el-button>
+          <el-button
+            v-hasPermi="['system:social-client:delete']"
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <SocialClientForm ref="formRef" @success="getList" />
+</template>
+
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import * as SocialClientApi from '@/api/system/social/client'
+import SocialClientForm from './SocialClientForm.vue'
+
+defineOptions({ name: 'SocialClient' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  socialType: null,
+  userType: null,
+  clientId: null,
+  status: null
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await SocialClientApi.getSocialClientPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await SocialClientApi.deleteSocialClient(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 60 - 0
src/views/system/social/user/SocialUserDetail.vue

@@ -0,0 +1,60 @@
+<template>
+  <Dialog v-model="dialogVisible" title="详情" width="800">
+    <el-descriptions :column="1" border>
+      <el-descriptions-item label="社交平台" min-width="160">
+        <dict-tag :type="DICT_TYPE.SYSTEM_SOCIAL_TYPE" :value="detailData.type" />
+      </el-descriptions-item>
+      <el-descriptions-item label="用户昵称" min-width="120">
+        {{ detailData.nickname }}
+      </el-descriptions-item>
+      <el-descriptions label="用户头像" min-width="120">
+        <el-image :src="detailData.avatar" class="h-30px w-30px" />
+      </el-descriptions>
+      <el-descriptions-item label="社交 token" min-width="120">
+        {{ detailData.token }}
+      </el-descriptions-item>
+      <el-descriptions-item label="原始 Token 数据" min-width="120">
+        <el-input
+          v-model="detailData.rawTokenInfo"
+          :autosize="{ maxRows: 20 }"
+          :readonly="true"
+          type="textarea"
+        />
+      </el-descriptions-item>
+      <el-descriptions-item label="原始 User 数据" min-width="120">
+        <el-input
+          v-model="detailData.rawUserInfo"
+          :autosize="{ maxRows: 20 }"
+          :readonly="true"
+          type="textarea"
+        />
+      </el-descriptions-item>
+      <el-descriptions-item label="最后一次的认证 code" min-width="120">
+        {{ detailData.code }}
+      </el-descriptions-item>
+      <el-descriptions-item label="最后一次的认证 state" min-width="120">
+        {{ detailData.state }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE } from '@/utils/dict'
+import * as SocialUserApi from '@/api/system/social/user'
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref({}) // 详情数据
+
+/** 打开弹窗 */
+const open = async (id: number) => {
+  dialogVisible.value = true
+  // 设置数据
+  try {
+    detailData.value = await SocialUserApi.getSocialUser(id)
+  } finally {
+    detailLoading.value = false
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>

+ 190 - 0
src/views/system/social/user/index.vue

@@ -0,0 +1,190 @@
+<template>
+  <doc-alert title="三方登录" url="https://doc.iocoder.cn/social-user/" />
+
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="120px"
+    >
+      <el-form-item label="社交平台" prop="type">
+        <el-select
+          v-model="queryParams.type"
+          class="!w-240px"
+          clearable
+          placeholder="请选择社交平台"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SOCIAL_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="用户昵称" prop="nickname">
+        <el-input
+          v-model="queryParams.nickname"
+          class="!w-240px"
+          clearable
+          placeholder="请输入用户昵称"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="社交 openid" prop="openid">
+        <el-input
+          v-model="queryParams.openid"
+          class="!w-240px"
+          clearable
+          placeholder="请输入社交 openid"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+          end-placeholder="结束日期"
+          start-placeholder="开始日期"
+          type="daterange"
+          value-format="YYYY-MM-DD HH:mm:ss"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" label="社交平台" prop="type">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.SYSTEM_SOCIAL_TYPE" :value="scope.row.type" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="社交 openid" prop="openid" />
+      <el-table-column align="center" label="用户昵称" prop="nickname" />
+      <el-table-column align="center" label="用户头像" prop="avatar">
+        <template #default="{ row }">
+          <el-image :src="row.avatar" class="h-30px w-30px" @click="imagePreview(row.avatar)" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180px"
+      />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="更新时间"
+        prop="updateTime"
+        width="180px"
+      />
+      <el-table-column align="center" fixed="right" label="操作">
+        <template #default="scope">
+          <el-button
+            v-hasPermi="['system:social-user:query']"
+            link
+            type="primary"
+            @click="openDetail(scope.row.id)"
+          >
+            详情
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:详情 -->
+  <SocialUserDetail ref="detailRef" />
+</template>
+
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import * as SocialUserApi from '@/api/system/social/user'
+import SocialUserDetail from './SocialUserDetail.vue'
+import { createImageViewer } from '@/components/ImageViewer'
+
+defineOptions({ name: 'SocialUser' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  type: undefined,
+  openid: undefined,
+  nickname: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await SocialUserApi.getSocialUserPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    urlList: [imgUrl]
+  })
+}
+
+/** 详情操作 */
+const detailRef = ref()
+const openDetail = (id: number) => {
+  detailRef.value.open(id)
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 5 - 5
src/views/system/tenant/TenantForm.vue

@@ -54,8 +54,8 @@
           value-format="x"
         />
       </el-form-item>
-      <el-form-item label="绑定域名" prop="domain">
-        <el-input v-model="formData.domain" placeholder="请输入绑定域名" />
+      <el-form-item label="绑定域名" prop="website">
+        <el-input v-model="formData.website" placeholder="请输入绑定域名" />
       </el-form-item>
       <el-form-item label="租户状态" prop="status">
         <el-radio-group v-model="formData.status">
@@ -97,7 +97,7 @@ const formData = ref({
   contactMobile: undefined,
   accountCount: undefined,
   expireTime: undefined,
-  domain: undefined,
+  website: undefined,
   status: CommonStatusEnum.ENABLE
 })
 const formRules = reactive({
@@ -107,7 +107,7 @@ const formRules = reactive({
   status: [{ required: true, message: '租户状态不能为空', trigger: 'blur' }],
   accountCount: [{ required: true, message: '账号额度不能为空', trigger: 'blur' }],
   expireTime: [{ required: true, message: '过期时间不能为空', trigger: 'blur' }],
-  domain: [{ required: true, message: '绑定域名不能为空', trigger: 'blur' }],
+  website: [{ required: true, message: '绑定域名不能为空', trigger: 'blur' }],
   username: [{ required: true, message: '用户名称不能为空', trigger: 'blur' }],
   password: [{ required: true, message: '用户密码不能为空', trigger: 'blur' }]
 })
@@ -170,7 +170,7 @@ const resetForm = () => {
     contactMobile: undefined,
     accountCount: undefined,
     expireTime: undefined,
-    domain: undefined,
+    website: undefined,
     status: CommonStatusEnum.ENABLE
   }
   formRef.value?.resetFields()

+ 1 - 1
src/views/system/tenant/index.vue

@@ -125,7 +125,7 @@
         width="180"
         :formatter="dateFormatter"
       />
-      <el-table-column label="绑定域名" align="center" prop="domain" width="180" />
+      <el-table-column label="绑定域名" align="center" prop="website" width="180" />
       <el-table-column label="租户状态" align="center" prop="status">
         <template #default="scope">
           <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />

+ 0 - 1
tsconfig.json

@@ -27,7 +27,6 @@
       "@intlify/unplugin-vue-i18n/types",
       "vite/client",
       "element-plus/global",
-      "@types/intro.js",
       "@types/qrcode",
       "vite-plugin-svg-icons/client"
     ],

+ 5 - 2
uno.config.ts

@@ -1,5 +1,5 @@
 import { defineConfig, toEscapedSelector as e, presetUno } from 'unocss'
-import transformerVariantGroup from '@unocss/transformer-variant-group'
+// import transformerVariantGroup from '@unocss/transformer-variant-group'
 
 export default defineConfig({
   // ...UnoCSS options
@@ -101,5 +101,8 @@ ${selector}:after {
     ]
   ],
   presets: [presetUno({ dark: 'class', attributify: false })],
-  transformers: [transformerVariantGroup()]
+  // transformers: [transformerVariantGroup()],
+  shortcuts: {
+    'wh-full': 'w-full h-full'
+  }
 })