ydmyzx 6 months ago
parent
commit
3605270f2f
77 changed files with 7469 additions and 0 deletions
  1. 49 0
      src/api/mall/product/comment.ts
  2. 66 0
      src/api/mall/promotion/combination/combinationActivity.ts
  3. 28 0
      src/api/mall/promotion/combination/combinationRecord.ts
  4. 5 0
      src/api/mall/statistics/common.ts
  5. 47 0
      src/api/system/oauth2/client.ts
  6. 89 0
      src/assets/map/json/china.json
  7. 62 0
      src/components/ConfigGlobal/src/ConfigGlobal.vue
  8. 58 0
      src/components/ContentDetailWrap/src/ContentDetailWrap.vue
  9. 36 0
      src/components/ContentWrap/src/ContentWrap.vue
  10. 238 0
      src/components/DiyEditor/components/ComponentContainer.vue
  11. 167 0
      src/components/DiyEditor/components/ComponentContainerProperty.vue
  12. 210 0
      src/components/DiyEditor/components/ComponentLibrary.vue
  13. 50 0
      src/components/DiyEditor/components/mobile/Carousel/config.ts
  14. 73 0
      src/components/DiyEditor/components/mobile/CouponCard/component.tsx
  15. 47 0
      src/components/DiyEditor/components/mobile/CouponCard/config.ts
  16. 29 0
      src/components/DiyEditor/components/mobile/Divider/config.ts
  17. 36 0
      src/components/DiyEditor/components/mobile/FloatingActionButton/config.ts
  18. 43 0
      src/components/DiyEditor/components/mobile/HotZone/config.ts
  19. 27 0
      src/components/DiyEditor/components/mobile/ImageBar/config.ts
  20. 49 0
      src/components/DiyEditor/components/mobile/MagicCube/config.ts
  21. 79 0
      src/components/DiyEditor/components/mobile/MenuGrid/config.ts
  22. 48 0
      src/components/DiyEditor/components/mobile/MenuList/config.ts
  23. 66 0
      src/components/DiyEditor/components/mobile/MenuSwiper/config.ts
  24. 82 0
      src/components/DiyEditor/components/mobile/NavigationBar/config.ts
  25. 46 0
      src/components/DiyEditor/components/mobile/NoticeBar/config.ts
  26. 23 0
      src/components/DiyEditor/components/mobile/PageConfig/config.ts
  27. 26 0
      src/components/DiyEditor/components/mobile/Popover/config.ts
  28. 97 0
      src/components/DiyEditor/components/mobile/ProductCard/config.ts
  29. 64 0
      src/components/DiyEditor/components/mobile/ProductList/config.ts
  30. 25 0
      src/components/DiyEditor/components/mobile/PromotionArticle/config.ts
  31. 64 0
      src/components/DiyEditor/components/mobile/PromotionCombination/config.ts
  32. 64 0
      src/components/DiyEditor/components/mobile/PromotionSeckill/config.ts
  33. 43 0
      src/components/DiyEditor/components/mobile/SearchBar/config.ts
  34. 97 0
      src/components/DiyEditor/components/mobile/TabBar/config.ts
  35. 69 0
      src/components/DiyEditor/components/mobile/TitleBar/config.ts
  36. 21 0
      src/components/DiyEditor/components/mobile/UserCard/config.ts
  37. 23 0
      src/components/DiyEditor/components/mobile/UserCoupon/config.ts
  38. 23 0
      src/components/DiyEditor/components/mobile/UserOrder/config.ts
  39. 23 0
      src/components/DiyEditor/components/mobile/UserWallet/config.ts
  40. 37 0
      src/components/DiyEditor/components/mobile/VideoPlayer/config.ts
  41. 55 0
      src/components/Form/src/componentMap.ts
  42. 423 0
      src/components/bpmnProcessDesigner/package/designer/plugins/content-pad/contentPadProvider.js
  43. 39 0
      src/components/bpmnProcessDesigner/src/utils/directive/clickOutSide.js
  44. 28 0
      src/config/axios/config.ts
  45. 35 0
      src/layout/components/Collapse/src/Collapse.vue
  46. 67 0
      src/layout/components/Setting/src/components/ColorRadioPicker.vue
  47. 56 0
      src/types/components.d.ts
  48. 4 0
      src/types/configGlobal.d.ts
  49. 7 0
      src/types/contextMenu.d.ts
  50. 174 0
      src/utils/color.ts
  51. 439 0
      src/utils/constants.ts
  52. 175 0
      src/views/ai/chat/manager/ChatMessageList.vue
  53. 181 0
      src/views/ai/model/chatModel/ChatModelForm.vue
  54. 183 0
      src/views/ai/model/chatRole/ChatRoleForm.vue
  55. 432 0
      src/views/ai/utils/constants.ts
  56. 153 0
      src/views/crm/backlog/components/ClueFollowList.vue
  57. 39 0
      src/views/crm/backlog/components/common.ts
  58. 259 0
      src/views/crm/clue/ClueForm.vue
  59. 43 0
      src/views/crm/clue/detail/ClueDetailsHeader.vue
  60. 72 0
      src/views/crm/clue/detail/ClueDetailsInfo.vue
  61. 310 0
      src/views/crm/contact/ContactForm.vue
  62. 185 0
      src/views/crm/contact/components/ContactList.vue
  63. 160 0
      src/views/crm/contact/components/ContactListModal.vue
  64. 33 0
      src/views/crm/contact/detail/ContactDetailsHeader.vue
  65. 69 0
      src/views/crm/contact/detail/ContactDetailsInfo.vue
  66. 98 0
      src/views/crm/statistics/rank/components/ContactCountRank.vue
  67. 153 0
      src/views/infra/codegen/components/ColumInfoForm.vue
  68. 131 0
      src/views/infra/config/ConfigForm.vue
  69. 42 0
      src/views/mall/home/components/ComparisonCard.vue
  70. 167 0
      src/views/mall/product/comment/CommentForm.vue
  71. 187 0
      src/views/mall/promotion/combination/activity/CombinationActivityForm.vue
  72. 140 0
      src/views/mall/promotion/combination/activity/combinationActivity.data.ts
  73. 89 0
      src/views/mall/promotion/combination/record/CombinationRecordListDialog.vue
  74. 17 0
      src/views/mall/promotion/kefu/components/tools/constants.ts
  75. 126 0
      src/views/mp/components/wx-msg/comment.scss
  76. 261 0
      src/views/system/oauth2/client/ClientForm.vue
  77. 8 0
      types/components.d.ts

+ 49 - 0
src/api/mall/product/comment.ts

@@ -0,0 +1,49 @@
+import request from '@/config/axios'
+
+export interface CommentVO {
+  id: number
+  userId: number
+  userNickname: string
+  userAvatar: string
+  anonymous: boolean
+  orderId: number
+  orderItemId: number
+  spuId: number
+  spuName: string
+  skuId: number
+  visible: boolean
+  scores: number
+  descriptionScores: number
+  benefitScores: number
+  content: string
+  picUrls: string
+  replyStatus: boolean
+  replyUserId: number
+  replyContent: string
+  replyTime: Date
+}
+
+// 查询商品评论列表
+export const getCommentPage = async (params) => {
+  return await request.get({ url: `/product/comment/page`, params })
+}
+
+// 查询商品评论详情
+export const getComment = async (id: number) => {
+  return await request.get({ url: `/product/comment/get?id=` + id })
+}
+
+// 添加自评
+export const createComment = async (data: CommentVO) => {
+  return await request.post({ url: `/product/comment/create`, data })
+}
+
+// 显示 / 隐藏评论
+export const updateCommentVisible = async (data: any) => {
+  return await request.put({ url: `/product/comment/update-visible`, data })
+}
+
+// 商家回复
+export const replyComment = async (data: any) => {
+  return await request.put({ url: `/product/comment/reply`, data })
+}

+ 66 - 0
src/api/mall/promotion/combination/combinationActivity.ts

@@ -0,0 +1,66 @@
+import request from '@/config/axios'
+import { Sku, Spu } from '@/api/mall/product/spu'
+
+export interface CombinationActivityVO {
+  id?: number
+  name?: string
+  spuId?: number
+  totalLimitCount?: number
+  singleLimitCount?: number
+  startTime?: Date
+  endTime?: Date
+  userSize?: number
+  totalCount?: number
+  successCount?: number
+  orderUserCount?: number
+  virtualGroup?: number
+  status?: number
+  limitDuration?: number
+  products: CombinationProductVO[]
+}
+
+// 拼团活动所需属性
+export interface CombinationProductVO {
+  spuId: number
+  skuId: number
+  combinationPrice: number // 拼团价格
+}
+
+// 扩展 Sku 配置
+export type SkuExtension = Sku & {
+  productConfig: CombinationProductVO
+}
+
+export interface SpuExtension extends Spu {
+  skus: SkuExtension[] // 重写类型
+}
+
+// 查询拼团活动列表
+export const getCombinationActivityPage = async (params) => {
+  return await request.get({ url: '/promotion/combination-activity/page', params })
+}
+
+// 查询拼团活动详情
+export const getCombinationActivity = async (id: number) => {
+  return await request.get({ url: '/promotion/combination-activity/get?id=' + id })
+}
+
+// 新增拼团活动
+export const createCombinationActivity = async (data: CombinationActivityVO) => {
+  return await request.post({ url: '/promotion/combination-activity/create', data })
+}
+
+// 修改拼团活动
+export const updateCombinationActivity = async (data: CombinationActivityVO) => {
+  return await request.put({ url: '/promotion/combination-activity/update', data })
+}
+
+// 关闭拼团活动
+export const closeCombinationActivity = async (id: number) => {
+  return await request.put({ url: '/promotion/combination-activity/close?id=' + id })
+}
+
+// 删除拼团活动
+export const deleteCombinationActivity = async (id: number) => {
+  return await request.delete({ url: '/promotion/combination-activity/delete?id=' + id })
+}

+ 28 - 0
src/api/mall/promotion/combination/combinationRecord.ts

@@ -0,0 +1,28 @@
+import request from '@/config/axios'
+
+export interface CombinationRecordVO {
+  id: number // 拼团记录编号
+  activityId: number // 拼团活动编号
+  nickname: string // 用户昵称
+  avatar: string // 用户头像
+  headId: number // 团长编号
+  expireTime: string // 过期时间
+  userSize: number // 可参团人数
+  userCount: number // 已参团人数
+  status: number // 拼团状态
+  spuName: string // 商品名字
+  picUrl: string // 商品图片
+  virtualGroup: boolean // 是否虚拟成团
+  startTime: string // 开始时间 (订单付款后开始的时间)
+  endTime: string // 结束时间(成团时间/失败时间)
+}
+
+// 查询拼团记录列表
+export const getCombinationRecordPage = async (params: any) => {
+  return await request.get({ url: '/promotion/combination-record/page', params })
+}
+
+// 获得拼团记录的概要信息
+export const getCombinationRecordSummary = async () => {
+  return await request.get({ url: '/promotion/combination-record/get-summary' })
+}

+ 5 - 0
src/api/mall/statistics/common.ts

@@ -0,0 +1,5 @@
+/** 数据对照 Response VO */
+export interface DataComparisonRespVO<T> {
+  value: T
+  reference: T
+}

+ 47 - 0
src/api/system/oauth2/client.ts

@@ -0,0 +1,47 @@
+import request from '@/config/axios'
+
+export interface OAuth2ClientVO {
+  id: number
+  clientId: string
+  secret: string
+  name: string
+  logo: string
+  description: string
+  status: number
+  accessTokenValiditySeconds: number
+  refreshTokenValiditySeconds: number
+  redirectUris: string[]
+  autoApprove: boolean
+  authorizedGrantTypes: string[]
+  scopes: string[]
+  authorities: string[]
+  resourceIds: string[]
+  additionalInformation: string
+  isAdditionalInformationJson: boolean
+  createTime: Date
+}
+
+// 查询 OAuth2 客户端的列表
+export const getOAuth2ClientPage = (params: PageParam) => {
+  return request.get({ url: '/system/oauth2-client/page', params })
+}
+
+// 查询 OAuth2 客户端的详情
+export const getOAuth2Client = (id: number) => {
+  return request.get({ url: '/system/oauth2-client/get?id=' + id })
+}
+
+// 新增 OAuth2 客户端
+export const createOAuth2Client = (data: OAuth2ClientVO) => {
+  return request.post({ url: '/system/oauth2-client/create', data })
+}
+
+// 修改 OAuth2 客户端
+export const updateOAuth2Client = (data: OAuth2ClientVO) => {
+  return request.put({ url: '/system/oauth2-client/update', data })
+}
+
+// 删除 OAuth2
+export const deleteOAuth2Client = (id: number) => {
+  return request.delete({ url: '/system/oauth2-client/delete?id=' + id })
+}

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


+ 62 - 0
src/components/ConfigGlobal/src/ConfigGlobal.vue

@@ -0,0 +1,62 @@
+<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'
+
+const { variables } = useDesign()
+
+const appStore = useAppStore()
+
+const props = defineProps({
+  size: propTypes.oneOf<ComponentSize>(['default', 'small', 'large']).def('default')
+})
+
+provide('configGlobal', props)
+
+// 初始化所有主题色
+onMounted(() => {
+  appStore.setCssVarTheme()
+})
+
+const { width } = useWindowSize()
+
+// 监听窗口变化
+watch(
+  () => width.value,
+  (width: number) => {
+    if (width < 768) {
+      !appStore.getMobile ? appStore.setMobile(true) : undefined
+      setCssVar('--left-menu-min-width', '0')
+      appStore.setCollapse(true)
+      appStore.getLayout !== 'classic' ? appStore.setLayout('classic') : undefined
+    } else {
+      appStore.getMobile ? appStore.setMobile(false) : undefined
+      setCssVar('--left-menu-min-width', '64px')
+    }
+  },
+  {
+    immediate: true
+  }
+)
+
+// 多语言相关
+const localeStore = useLocaleStore()
+
+const currentLocale = computed(() => localeStore.currentLocale)
+</script>
+
+<template>
+  <ElConfigProvider
+    :namespace="variables.elNamespace"
+    :locale="currentLocale.elLocale"
+    :message="{ max: 1 }"
+    :size="size"
+  >
+    <slot></slot>
+  </ElConfigProvider>
+</template>

+ 58 - 0
src/components/ContentDetailWrap/src/ContentDetailWrap.vue

@@ -0,0 +1,58 @@
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import { useDesign } from '@/hooks/web/useDesign'
+
+defineOptions({ name: 'ContentDetailWrap' })
+
+const { t } = useI18n()
+
+const { getPrefixCls } = useDesign()
+
+const prefixCls = getPrefixCls('content-detail-wrap')
+
+defineProps({
+  title: propTypes.string.def(''),
+  message: propTypes.string.def('')
+})
+const emit = defineEmits(['back'])
+const offset = ref(85)
+const contentDetailWrap = ref()
+onMounted(() => {
+  offset.value = contentDetailWrap.value.getBoundingClientRect().top
+})
+</script>
+
+<template>
+  <div ref="contentDetailWrap" :class="[`${prefixCls}-container`]">
+    <Sticky :offset="offset">
+      <div
+        :class="[
+          `${prefixCls}-header`,
+          'flex b-b-1 h-50px items-center text-center bg-white pr-10px'
+        ]"
+      >
+        <div :class="[`${prefixCls}-header__back`, 'flex pl-10px pr-10px ']">
+          <ElButton @click="emit('back')">
+            <Icon class="mr-5px" icon="ep:arrow-left" />
+            {{ t('common.back') }}
+          </ElButton>
+        </div>
+        <div :class="[`${prefixCls}-header__title`, 'flex flex-1  justify-center']">
+          <slot name="title">
+            <label class="text-16px font-700">{{ title }}</label>
+          </slot>
+        </div>
+        <div :class="[`${prefixCls}-header__right`, 'flex  pl-10px pr-10px']">
+          <slot name="right"></slot>
+        </div>
+      </div>
+    </Sticky>
+    <div style="padding: var(--app-content-padding)">
+      <ElCard :class="[`${prefixCls}-body`, 'mb-20px']" shadow="never">
+        <div>
+          <slot></slot>
+        </div>
+      </ElCard>
+    </div>
+  </div>
+</template>

+ 36 - 0
src/components/ContentWrap/src/ContentWrap.vue

@@ -0,0 +1,36 @@
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import { useDesign } from '@/hooks/web/useDesign'
+
+defineOptions({ name: 'ContentWrap' })
+
+const { getPrefixCls } = useDesign()
+
+const prefixCls = getPrefixCls('content-wrap')
+
+defineProps({
+  title: propTypes.string.def(''),
+  message: propTypes.string.def(''),
+  bodyStyle: propTypes.object.def({ padding: '20px' })
+})
+</script>
+
+<template>
+  <ElCard :body-style="bodyStyle" :class="[prefixCls, 'mb-15px']" shadow="never">
+    <template v-if="title" #header>
+      <div class="flex items-center">
+        <span class="text-16px font-700">{{ title }}</span>
+        <ElTooltip v-if="message" effect="dark" placement="right">
+          <template #content>
+            <div class="max-w-200px">{{ message }}</div>
+          </template>
+          <Icon :size="14" class="ml-5px" icon="ep:question-filled" />
+        </ElTooltip>
+        <div class="flex flex-grow pl-20px">
+          <slot name="header"></slot>
+        </div>
+      </div>
+    </template>
+    <slot></slot>
+  </ElCard>
+</template>

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

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

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

@@ -0,0 +1,167 @@
+<template>
+  <el-tabs stretch>
+    <!-- 每个组件的自定义内容 -->
+    <el-tab-pane label="内容" v-if="$slots.default">
+      <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" default-expand-all>
+            <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" :style="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>

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

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

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

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

+ 73 - 0
src/components/DiyEditor/components/mobile/CouponCard/component.tsx

@@ -0,0 +1,73 @@
+import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate'
+import { CouponTemplateValidityTypeEnum, PromotionDiscountTypeEnum } from '@/utils/constants'
+import { floatToFixed2 } from '@/utils'
+import { formatDate } from '@/utils/formatTime'
+import { object } from 'vue-types'
+
+// 优惠值
+export const CouponDiscount = defineComponent({
+  name: 'CouponDiscount',
+  props: {
+    coupon: object<CouponTemplateApi.CouponTemplateVO>()
+  },
+  setup(props) {
+    const coupon = props.coupon as CouponTemplateApi.CouponTemplateVO
+    // 折扣
+    let value = coupon.discountPercent + ''
+    let suffix = ' 折'
+    // 满减
+    if (coupon.discountType === PromotionDiscountTypeEnum.PRICE.type) {
+      value = floatToFixed2(coupon.discountPrice)
+      suffix = ' 元'
+    }
+    return () => (
+      <div>
+        <span class={'text-20px font-bold'}>{value}</span>
+        <span>{suffix}</span>
+      </div>
+    )
+  }
+})
+
+// 优惠描述
+export const CouponDiscountDesc = defineComponent({
+  name: 'CouponDiscountDesc',
+  props: {
+    coupon: object<CouponTemplateApi.CouponTemplateVO>()
+  },
+  setup(props) {
+    const coupon = props.coupon as CouponTemplateApi.CouponTemplateVO
+    // 使用条件
+    const useCondition = coupon.usePrice > 0 ? `满${floatToFixed2(coupon.usePrice)}元,` : ''
+    // 优惠描述
+    const discountDesc =
+      coupon.discountType === PromotionDiscountTypeEnum.PRICE.type
+        ? `减${floatToFixed2(coupon.discountPrice)}元`
+        : `打${coupon.discountPercent}折`
+    return () => (
+      <div>
+        <span>{useCondition}</span>
+        <span>{discountDesc}</span>
+      </div>
+    )
+  }
+})
+
+// 有效期
+export const CouponValidTerm = defineComponent({
+  name: 'CouponValidTerm',
+  props: {
+    coupon: object<CouponTemplateApi.CouponTemplateVO>()
+  },
+  setup(props) {
+    const coupon = props.coupon as CouponTemplateApi.CouponTemplateVO
+    const text =
+      coupon.validityType === CouponTemplateValidityTypeEnum.DATE.type
+        ? `有效期:${formatDate(coupon.validStartTime, 'YYYY-MM-DD')} 至 ${formatDate(
+            coupon.validEndTime,
+            'YYYY-MM-DD'
+          )}`
+        : `领取后第 ${coupon.fixedStartTerm} - ${coupon.fixedEndTerm} 天内可用`
+    return () => <div>{text}</div>
+  }
+})

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

@@ -0,0 +1,47 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 商品卡片属性 */
+export interface CouponCardProperty {
+  // 列数
+  columns: number
+  // 背景图
+  bgImg: string
+  // 文字颜色
+  textColor: string
+  // 按钮样式
+  button: {
+    // 颜色
+    color: string
+    // 背景颜色
+    bgColor: string
+  }
+  // 间距
+  space: number
+  // 优惠券编号列表
+  couponIds: number[]
+  // 组件样式
+  style: ComponentStyle
+}
+
+// 定义组件
+export const component = {
+  id: 'CouponCard',
+  name: '优惠券',
+  icon: 'ep:ticket',
+  property: {
+    columns: 1,
+    bgImg: '',
+    textColor: '#E9B461',
+    button: {
+      color: '#434343',
+      bgColor: ''
+    },
+    space: 0,
+    couponIds: [],
+    style: {
+      bgType: 'color',
+      bgColor: '',
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<CouponCardProperty>

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

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

+ 36 - 0
src/components/DiyEditor/components/mobile/FloatingActionButton/config.ts

@@ -0,0 +1,36 @@
+import { DiyComponent } from '@/components/DiyEditor/util'
+
+// 悬浮按钮属性
+export interface FloatingActionButtonProperty {
+  // 展开方向
+  direction: 'horizontal' | 'vertical'
+  // 是否显示文字
+  showText: boolean
+  // 按钮列表
+  list: FloatingActionButtonItemProperty[]
+}
+
+// 悬浮按钮项属性
+export interface FloatingActionButtonItemProperty {
+  // 图片地址
+  imgUrl: string
+  // 跳转连接
+  url: string
+  // 文字
+  text: string
+  // 文字颜色
+  textColor: string
+}
+
+// 定义组件
+export const component = {
+  id: 'FloatingActionButton',
+  name: '悬浮按钮',
+  icon: 'tabler:float-right',
+  position: 'fixed',
+  property: {
+    direction: 'vertical',
+    showText: true,
+    list: [{ textColor: '#fff' }]
+  }
+} as DiyComponent<FloatingActionButtonProperty>

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

@@ -0,0 +1,43 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 热区属性 */
+export interface HotZoneProperty {
+  // 图片地址
+  imgUrl: string
+  // 导航菜单列表
+  list: HotZoneItemProperty[]
+  // 组件样式
+  style: ComponentStyle
+}
+
+/** 热区项目属性 */
+export interface HotZoneItemProperty {
+  // 链接的名称
+  name: string
+  // 链接
+  url: string
+  // 宽
+  width: number
+  // 高
+  height: number
+  // 上
+  top: number
+  // 左
+  left: number
+}
+
+// 定义组件
+export const component = {
+  id: 'HotZone',
+  name: '热区',
+  icon: 'tabler:hand-click',
+  property: {
+    imgUrl: '',
+    list: [] as HotZoneItemProperty[],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<HotZoneProperty>

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,82 @@
+import { DiyComponent } from '@/components/DiyEditor/util'
+
+/** 顶部导航栏属性 */
+export interface NavigationBarProperty {
+  // 背景类型
+  bgType: 'color' | 'img'
+  // 背景颜色
+  bgColor: string
+  // 图片链接
+  bgImg: string
+  // 样式类型:默认 | 沉浸式
+  styleType: 'normal' | 'inner'
+  // 常驻显示
+  alwaysShow: boolean
+  // 小程序单元格列表
+  mpCells: NavigationBarCellProperty[]
+  // 其它平台单元格列表
+  otherCells: NavigationBarCellProperty[]
+  // 本地变量
+  _local: {
+    // 预览顶部导航(小程序)
+    previewMp: boolean
+    // 预览顶部导航(非小程序)
+    previewOther: boolean
+  }
+}
+
+/** 顶部导航栏 - 单元格 属性 */
+export interface NavigationBarCellProperty {
+  // 类型:文字 | 图片 | 搜索框
+  type: 'text' | 'image' | 'search'
+  // 宽度
+  width: number
+  // 高度
+  height: number
+  // 顶部位置
+  top: number
+  // 左侧位置
+  left: number
+  // 文字内容
+  text: string
+  // 文字颜色
+  textColor: string
+  // 图片地址
+  imgUrl: string
+  // 图片链接
+  url: string
+  // 搜索框:提示文字
+  placeholder: string
+  // 搜索框:边框圆角半径
+  borderRadius: number
+}
+
+// 定义组件
+export const component = {
+  id: 'NavigationBar',
+  name: '顶部导航栏',
+  icon: 'tabler:layout-navbar',
+  property: {
+    bgType: 'color',
+    bgColor: '#fff',
+    bgImg: '',
+    styleType: 'normal',
+    alwaysShow: true,
+    mpCells: [
+      {
+        type: 'text',
+        textColor: '#111111'
+      }
+    ],
+    otherCells: [
+      {
+        type: 'text',
+        textColor: '#111111'
+      }
+    ],
+    _local: {
+      previewMp: true,
+      previewOther: false
+    }
+  }
+} as DiyComponent<NavigationBarProperty>

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

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

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

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

+ 26 - 0
src/components/DiyEditor/components/mobile/Popover/config.ts

@@ -0,0 +1,26 @@
+import { DiyComponent } from '@/components/DiyEditor/util'
+
+/** 弹窗广告属性 */
+export interface PopoverProperty {
+  list: PopoverItemProperty[]
+}
+
+export interface PopoverItemProperty {
+  // 图片地址
+  imgUrl: string
+  // 跳转连接
+  url: string
+  // 显示类型:仅显示一次、每次启动都会显示
+  showType: 'once' | 'always'
+}
+
+// 定义组件
+export const component = {
+  id: 'Popover',
+  name: '弹窗广告',
+  icon: 'carbon:popup',
+  position: 'fixed',
+  property: {
+    list: [{ showType: 'once' }]
+  }
+} as DiyComponent<PopoverProperty>

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

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

+ 64 - 0
src/components/DiyEditor/components/mobile/ProductList/config.ts

@@ -0,0 +1,64 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 商品栏属性 */
+export interface ProductListProperty {
+  // 布局类型:双列 | 三列 | 水平滑动
+  layoutType: 'twoCol' | 'threeCol' | 'horizSwiper'
+  // 商品字段
+  fields: {
+    // 商品名称
+    name: ProductListFieldProperty
+    // 商品价格
+    price: ProductListFieldProperty
+  }
+  // 角标
+  badge: {
+    // 是否显示
+    show: boolean
+    // 角标图片
+    imgUrl: string
+  }
+  // 上圆角
+  borderRadiusTop: number
+  // 下圆角
+  borderRadiusBottom: number
+  // 间距
+  space: number
+  // 商品编号列表
+  spuIds: number[]
+  // 组件样式
+  style: ComponentStyle
+}
+// 商品字段
+export interface ProductListFieldProperty {
+  // 是否显示
+  show: boolean
+  // 颜色
+  color: string
+}
+
+// 定义组件
+export const component = {
+  id: 'ProductList',
+  name: '商品栏',
+  icon: 'fluent:text-column-two-24-filled',
+  property: {
+    layoutType: 'twoCol',
+    fields: {
+      name: { show: true, color: '#000' },
+      price: { show: true, color: '#ff3000' }
+    },
+    badge: { show: false, imgUrl: '' },
+    borderRadiusTop: 8,
+    borderRadiusBottom: 8,
+    space: 8,
+    spuIds: [],
+    style: {
+      bgType: 'color',
+      bgColor: '',
+      marginLeft: 8,
+      marginRight: 8,
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<ProductListProperty>

+ 25 - 0
src/components/DiyEditor/components/mobile/PromotionArticle/config.ts

@@ -0,0 +1,25 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 营销文章属性 */
+export interface PromotionArticleProperty {
+  // 文章编号
+  id: number
+  // 组件样式
+  style: ComponentStyle
+}
+
+// 定义组件
+export const component = {
+  id: 'PromotionArticle',
+  name: '营销文章',
+  icon: 'ph:article-medium',
+  property: {
+    style: {
+      bgType: 'color',
+      bgColor: '',
+      marginLeft: 8,
+      marginRight: 8,
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<PromotionArticleProperty>

+ 64 - 0
src/components/DiyEditor/components/mobile/PromotionCombination/config.ts

@@ -0,0 +1,64 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 拼团属性 */
+export interface PromotionCombinationProperty {
+  // 布局类型:单列 | 三列
+  layoutType: 'oneCol' | 'threeCol'
+  // 商品字段
+  fields: {
+    // 商品名称
+    name: PromotionCombinationFieldProperty
+    // 商品价格
+    price: PromotionCombinationFieldProperty
+  }
+  // 角标
+  badge: {
+    // 是否显示
+    show: boolean
+    // 角标图片
+    imgUrl: string
+  }
+  // 上圆角
+  borderRadiusTop: number
+  // 下圆角
+  borderRadiusBottom: number
+  // 间距
+  space: number
+  // 拼团活动编号
+  activityId: number
+  // 组件样式
+  style: ComponentStyle
+}
+
+// 商品字段
+export interface PromotionCombinationFieldProperty {
+  // 是否显示
+  show: boolean
+  // 颜色
+  color: string
+}
+
+// 定义组件
+export const component = {
+  id: 'PromotionCombination',
+  name: '拼团',
+  icon: 'mdi:account-group',
+  property: {
+    layoutType: 'oneCol',
+    fields: {
+      name: { show: true, color: '#000' },
+      price: { show: true, color: '#ff3000' }
+    },
+    badge: { show: false, imgUrl: '' },
+    borderRadiusTop: 8,
+    borderRadiusBottom: 8,
+    space: 8,
+    style: {
+      bgType: 'color',
+      bgColor: '',
+      marginLeft: 8,
+      marginRight: 8,
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<PromotionCombinationProperty>

+ 64 - 0
src/components/DiyEditor/components/mobile/PromotionSeckill/config.ts

@@ -0,0 +1,64 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 秒杀属性 */
+export interface PromotionSeckillProperty {
+  // 布局类型:单列 | 三列
+  layoutType: 'oneCol' | 'threeCol'
+  // 商品字段
+  fields: {
+    // 商品名称
+    name: PromotionSeckillFieldProperty
+    // 商品价格
+    price: PromotionSeckillFieldProperty
+  }
+  // 角标
+  badge: {
+    // 是否显示
+    show: boolean
+    // 角标图片
+    imgUrl: string
+  }
+  // 上圆角
+  borderRadiusTop: number
+  // 下圆角
+  borderRadiusBottom: number
+  // 间距
+  space: number
+  // 秒杀活动编号
+  activityId: number
+  // 组件样式
+  style: ComponentStyle
+}
+// 商品字段
+export interface PromotionSeckillFieldProperty {
+  // 是否显示
+  show: boolean
+  // 颜色
+  color: string
+}
+
+// 定义组件
+export const component = {
+  id: 'PromotionSeckill',
+  name: '秒杀',
+  icon: 'mdi:calendar-time',
+  property: {
+    activityId: undefined,
+    layoutType: 'oneCol',
+    fields: {
+      name: { show: true, color: '#000' },
+      price: { show: true, color: '#ff3000' }
+    },
+    badge: { show: false, imgUrl: '' },
+    borderRadiusTop: 8,
+    borderRadiusBottom: 8,
+    space: 8,
+    style: {
+      bgType: 'color',
+      bgColor: '',
+      marginLeft: 8,
+      marginRight: 8,
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<PromotionSeckillProperty>

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

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

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

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

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

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

+ 21 - 0
src/components/DiyEditor/components/mobile/UserCard/config.ts

@@ -0,0 +1,21 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 用户卡片属性 */
+export interface UserCardProperty {
+  // 组件样式
+  style: ComponentStyle
+}
+
+// 定义组件
+export const component = {
+  id: 'UserCard',
+  name: '用户卡片',
+  icon: 'mdi:user-card-details',
+  property: {
+    style: {
+      bgType: 'color',
+      bgColor: '',
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<UserCardProperty>

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

@@ -0,0 +1,23 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 用户卡券属性 */
+export interface UserCouponProperty {
+  // 组件样式
+  style: ComponentStyle
+}
+
+// 定义组件
+export const component = {
+  id: 'UserCoupon',
+  name: '用户卡券',
+  icon: 'ep:ticket',
+  property: {
+    style: {
+      bgType: 'color',
+      bgColor: '',
+      marginLeft: 8,
+      marginRight: 8,
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<UserCouponProperty>

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

@@ -0,0 +1,23 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 用户订单属性 */
+export interface UserOrderProperty {
+  // 组件样式
+  style: ComponentStyle
+}
+
+// 定义组件
+export const component = {
+  id: 'UserOrder',
+  name: '用户订单',
+  icon: 'ep:list',
+  property: {
+    style: {
+      bgType: 'color',
+      bgColor: '',
+      marginLeft: 8,
+      marginRight: 8,
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<UserOrderProperty>

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

@@ -0,0 +1,23 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 用户资产属性 */
+export interface UserWalletProperty {
+  // 组件样式
+  style: ComponentStyle
+}
+
+// 定义组件
+export const component = {
+  id: 'UserWallet',
+  name: '用户资产',
+  icon: 'ep:wallet-filled',
+  property: {
+    style: {
+      bgType: 'color',
+      bgColor: '',
+      marginLeft: 8,
+      marginRight: 8,
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<UserWalletProperty>

+ 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 VideoPlayerStyle
+  }
+} as DiyComponent<VideoPlayerProperty>

+ 55 - 0
src/components/Form/src/componentMap.ts

@@ -0,0 +1,55 @@
+import type { Component } from 'vue'
+import {
+  ElCascader,
+  ElCheckboxGroup,
+  ElColorPicker,
+  ElDatePicker,
+  ElInput,
+  ElInputNumber,
+  ElRadioGroup,
+  ElRate,
+  ElSelect,
+  ElSelectV2,
+  ElTreeSelect,
+  ElSlider,
+  ElSwitch,
+  ElTimePicker,
+  ElTimeSelect,
+  ElTransfer,
+  ElAutocomplete,
+  ElDivider
+} from 'element-plus'
+import { InputPassword } from '@/components/InputPassword'
+import { Editor } from '@/components/Editor'
+import { UploadImg, UploadImgs, UploadFile } from '@/components/UploadFile'
+import { ComponentName } from '@/types/components'
+
+const componentMap: Recordable<Component, ComponentName> = {
+  Radio: ElRadioGroup,
+  Checkbox: ElCheckboxGroup,
+  CheckboxButton: ElCheckboxGroup,
+  Input: ElInput,
+  Autocomplete: ElAutocomplete,
+  InputNumber: ElInputNumber,
+  Select: ElSelect,
+  Cascader: ElCascader,
+  Switch: ElSwitch,
+  Slider: ElSlider,
+  TimePicker: ElTimePicker,
+  DatePicker: ElDatePicker,
+  Rate: ElRate,
+  ColorPicker: ElColorPicker,
+  Transfer: ElTransfer,
+  Divider: ElDivider,
+  TimeSelect: ElTimeSelect,
+  SelectV2: ElSelectV2,
+  TreeSelect: ElTreeSelect,
+  RadioButton: ElRadioGroup,
+  InputPassword: InputPassword,
+  Editor: Editor,
+  UploadImg: UploadImg,
+  UploadImgs: UploadImgs,
+  UploadFile: UploadFile
+}
+
+export { componentMap }

+ 423 - 0
src/components/bpmnProcessDesigner/package/designer/plugins/content-pad/contentPadProvider.js

@@ -0,0 +1,423 @@
+import { assign, forEach, isArray } from 'min-dash'
+
+import { is } from 'bpmn-js/lib/util/ModelUtil'
+
+import { isExpanded, isEventSubProcess } from 'bpmn-js/lib/util/DiUtil'
+
+import { isAny } from 'bpmn-js/lib/features/modeling/util/ModelingUtil'
+
+import { getChildLanes } from 'bpmn-js/lib/features/modeling/util/LaneUtil'
+
+import { hasPrimaryModifier } from 'diagram-js/lib/util/Mouse'
+
+/**
+ * A provider for BPMN 2.0 elements context pad
+ */
+export default function ContextPadProvider(
+  config,
+  injector,
+  eventBus,
+  contextPad,
+  modeling,
+  elementFactory,
+  connect,
+  create,
+  popupMenu,
+  canvas,
+  rules,
+  translate
+) {
+  config = config || {}
+
+  contextPad.registerProvider(this)
+
+  this._contextPad = contextPad
+
+  this._modeling = modeling
+
+  this._elementFactory = elementFactory
+  this._connect = connect
+  this._create = create
+  this._popupMenu = popupMenu
+  this._canvas = canvas
+  this._rules = rules
+  this._translate = translate
+
+  if (config.autoPlace !== false) {
+    this._autoPlace = injector.get('autoPlace', false)
+  }
+
+  eventBus.on('create.end', 250, function (event) {
+    const context = event.context,
+      shape = context.shape
+
+    if (!hasPrimaryModifier(event) || !contextPad.isOpen(shape)) {
+      return
+    }
+
+    const entries = contextPad.getEntries(shape)
+
+    if (entries.replace) {
+      entries.replace.action.click(event, shape)
+    }
+  })
+}
+
+ContextPadProvider.$inject = [
+  'config.contextPad',
+  'injector',
+  'eventBus',
+  'contextPad',
+  'modeling',
+  'elementFactory',
+  'connect',
+  'create',
+  'popupMenu',
+  'canvas',
+  'rules',
+  'translate',
+  'elementRegistry'
+]
+
+ContextPadProvider.prototype.getContextPadEntries = function (element) {
+  const contextPad = this._contextPad,
+    modeling = this._modeling,
+    elementFactory = this._elementFactory,
+    connect = this._connect,
+    create = this._create,
+    popupMenu = this._popupMenu,
+    canvas = this._canvas,
+    rules = this._rules,
+    autoPlace = this._autoPlace,
+    translate = this._translate
+
+  const actions = {}
+
+  if (element.type === 'label') {
+    return actions
+  }
+
+  const businessObject = element.businessObject
+
+  function startConnect(event, element) {
+    connect.start(event, element)
+  }
+
+  function removeElement() {
+    modeling.removeElements([element])
+  }
+
+  function getReplaceMenuPosition(element) {
+    const Y_OFFSET = 5
+
+    const diagramContainer = canvas.getContainer(),
+      pad = contextPad.getPad(element).html
+
+    const diagramRect = diagramContainer.getBoundingClientRect(),
+      padRect = pad.getBoundingClientRect()
+
+    const top = padRect.top - diagramRect.top
+    const left = padRect.left - diagramRect.left
+
+    const pos = {
+      x: left,
+      y: top + padRect.height + Y_OFFSET
+    }
+
+    return pos
+  }
+
+  /**
+   * Create an append action
+   *
+   * @param {string} type
+   * @param {string} className
+   * @param {string} [title]
+   * @param {Object} [options]
+   *
+   * @return {Object} descriptor
+   */
+  function appendAction(type, className, title, options) {
+    if (typeof title !== 'string') {
+      options = title
+      title = translate('Append {type}', { type: type.replace(/^bpmn:/, '') })
+    }
+
+    function appendStart(event, element) {
+      const shape = elementFactory.createShape(assign({ type: type }, options))
+      create.start(event, shape, {
+        source: element
+      })
+    }
+
+    const append = autoPlace
+      ? function (event, element) {
+          const shape = elementFactory.createShape(assign({ type: type }, options))
+
+          autoPlace.append(element, shape)
+        }
+      : appendStart
+
+    return {
+      group: 'model',
+      className: className,
+      title: title,
+      action: {
+        dragstart: appendStart,
+        click: append
+      }
+    }
+  }
+
+  function splitLaneHandler(count) {
+    return function (event, element) {
+      // actual split
+      modeling.splitLane(element, count)
+
+      // refresh context pad after split to
+      // get rid of split icons
+      contextPad.open(element, true)
+    }
+  }
+
+  if (isAny(businessObject, ['bpmn:Lane', 'bpmn:Participant']) && isExpanded(businessObject)) {
+    const childLanes = getChildLanes(element)
+
+    assign(actions, {
+      'lane-insert-above': {
+        group: 'lane-insert-above',
+        className: 'bpmn-icon-lane-insert-above',
+        title: translate('Add Lane above'),
+        action: {
+          click: function (event, element) {
+            modeling.addLane(element, 'top')
+          }
+        }
+      }
+    })
+
+    if (childLanes.length < 2) {
+      if (element.height >= 120) {
+        assign(actions, {
+          'lane-divide-two': {
+            group: 'lane-divide',
+            className: 'bpmn-icon-lane-divide-two',
+            title: translate('Divide into two Lanes'),
+            action: {
+              click: splitLaneHandler(2)
+            }
+          }
+        })
+      }
+
+      if (element.height >= 180) {
+        assign(actions, {
+          'lane-divide-three': {
+            group: 'lane-divide',
+            className: 'bpmn-icon-lane-divide-three',
+            title: translate('Divide into three Lanes'),
+            action: {
+              click: splitLaneHandler(3)
+            }
+          }
+        })
+      }
+    }
+
+    assign(actions, {
+      'lane-insert-below': {
+        group: 'lane-insert-below',
+        className: 'bpmn-icon-lane-insert-below',
+        title: translate('Add Lane below'),
+        action: {
+          click: function (event, element) {
+            modeling.addLane(element, 'bottom')
+          }
+        }
+      }
+    })
+  }
+
+  if (is(businessObject, 'bpmn:FlowNode')) {
+    if (is(businessObject, 'bpmn:EventBasedGateway')) {
+      assign(actions, {
+        'append.receive-task': appendAction(
+          'bpmn:ReceiveTask',
+          'bpmn-icon-receive-task',
+          translate('Append ReceiveTask')
+        ),
+        'append.message-intermediate-event': appendAction(
+          'bpmn:IntermediateCatchEvent',
+          'bpmn-icon-intermediate-event-catch-message',
+          translate('Append MessageIntermediateCatchEvent'),
+          { eventDefinitionType: 'bpmn:MessageEventDefinition' }
+        ),
+        'append.timer-intermediate-event': appendAction(
+          'bpmn:IntermediateCatchEvent',
+          'bpmn-icon-intermediate-event-catch-timer',
+          translate('Append TimerIntermediateCatchEvent'),
+          { eventDefinitionType: 'bpmn:TimerEventDefinition' }
+        ),
+        'append.condition-intermediate-event': appendAction(
+          'bpmn:IntermediateCatchEvent',
+          'bpmn-icon-intermediate-event-catch-condition',
+          translate('Append ConditionIntermediateCatchEvent'),
+          { eventDefinitionType: 'bpmn:ConditionalEventDefinition' }
+        ),
+        'append.signal-intermediate-event': appendAction(
+          'bpmn:IntermediateCatchEvent',
+          'bpmn-icon-intermediate-event-catch-signal',
+          translate('Append SignalIntermediateCatchEvent'),
+          { eventDefinitionType: 'bpmn:SignalEventDefinition' }
+        )
+      })
+    } else if (
+      isEventType(businessObject, 'bpmn:BoundaryEvent', 'bpmn:CompensateEventDefinition')
+    ) {
+      assign(actions, {
+        'append.compensation-activity': appendAction(
+          'bpmn:Task',
+          'bpmn-icon-task',
+          translate('Append compensation activity'),
+          {
+            isForCompensation: true
+          }
+        )
+      })
+    } else if (
+      !is(businessObject, 'bpmn:EndEvent') &&
+      !businessObject.isForCompensation &&
+      !isEventType(businessObject, 'bpmn:IntermediateThrowEvent', 'bpmn:LinkEventDefinition') &&
+      !isEventSubProcess(businessObject)
+    ) {
+      assign(actions, {
+        'append.end-event': appendAction(
+          'bpmn:EndEvent',
+          'bpmn-icon-end-event-none',
+          translate('Append EndEvent')
+        ),
+        'append.gateway': appendAction(
+          'bpmn:ExclusiveGateway',
+          'bpmn-icon-gateway-none',
+          translate('Append Gateway')
+        ),
+        'append.append-task': appendAction(
+          'bpmn:UserTask',
+          'bpmn-icon-user-task',
+          translate('Append Task')
+        ),
+        'append.intermediate-event': appendAction(
+          'bpmn:IntermediateThrowEvent',
+          'bpmn-icon-intermediate-event-none',
+          translate('Append Intermediate/Boundary Event')
+        )
+      })
+    }
+  }
+
+  if (!popupMenu.isEmpty(element, 'bpmn-replace')) {
+    // Replace menu entry
+    assign(actions, {
+      replace: {
+        group: 'edit',
+        className: 'bpmn-icon-screw-wrench',
+        title: '修改类型',
+        action: {
+          click: function (event, element) {
+            const position = assign(getReplaceMenuPosition(element), {
+              cursor: { x: event.x, y: event.y }
+            })
+
+            popupMenu.open(element, 'bpmn-replace', position)
+          }
+        }
+      }
+    })
+  }
+
+  if (
+    isAny(businessObject, [
+      'bpmn:FlowNode',
+      'bpmn:InteractionNode',
+      'bpmn:DataObjectReference',
+      'bpmn:DataStoreReference'
+    ])
+  ) {
+    assign(actions, {
+      'append.text-annotation': appendAction('bpmn:TextAnnotation', 'bpmn-icon-text-annotation'),
+
+      connect: {
+        group: 'connect',
+        className: 'bpmn-icon-connection-multi',
+        title: translate(
+          'Connect using ' +
+            (businessObject.isForCompensation ? '' : 'Sequence/MessageFlow or ') +
+            'Association'
+        ),
+        action: {
+          click: startConnect,
+          dragstart: startConnect
+        }
+      }
+    })
+  }
+
+  if (isAny(businessObject, ['bpmn:DataObjectReference', 'bpmn:DataStoreReference'])) {
+    assign(actions, {
+      connect: {
+        group: 'connect',
+        className: 'bpmn-icon-connection-multi',
+        title: translate('Connect using DataInputAssociation'),
+        action: {
+          click: startConnect,
+          dragstart: startConnect
+        }
+      }
+    })
+  }
+
+  if (is(businessObject, 'bpmn:Group')) {
+    assign(actions, {
+      'append.text-annotation': appendAction('bpmn:TextAnnotation', 'bpmn-icon-text-annotation')
+    })
+  }
+
+  // delete element entry, only show if allowed by rules
+  let deleteAllowed = rules.allowed('elements.delete', { elements: [element] })
+
+  if (isArray(deleteAllowed)) {
+    // was the element returned as a deletion candidate?
+    deleteAllowed = deleteAllowed[0] === element
+  }
+
+  if (deleteAllowed) {
+    assign(actions, {
+      delete: {
+        group: 'edit',
+        className: 'bpmn-icon-trash',
+        title: translate('Remove'),
+        action: {
+          click: removeElement
+        }
+      }
+    })
+  }
+
+  return actions
+}
+
+// helpers /////////
+
+function isEventType(eventBo, type, definition) {
+  const isType = eventBo.$instanceOf(type)
+  let isDefinition = false
+
+  const definitions = eventBo.eventDefinitions || []
+  forEach(definitions, function (def) {
+    if (def.$type === definition) {
+      isDefinition = true
+    }
+  })
+
+  return isType && isDefinition
+}

+ 39 - 0
src/components/bpmnProcessDesigner/src/utils/directive/clickOutSide.js

@@ -0,0 +1,39 @@
+//outside.js
+
+const ctx = '@@clickoutsideContext'
+
+export default {
+  bind(el, binding, vnode) {
+    const ele = el
+    const documentHandler = (e) => {
+      if (!vnode.context || ele.contains(e.target)) {
+        return false
+      }
+      // 调用指令回调
+      if (binding.expression) {
+        vnode.context[el[ctx].methodName](e)
+      } else {
+        el[ctx].bindingFn(e)
+      }
+    }
+    // 将方法添加到ele
+    ele[ctx] = {
+      documentHandler,
+      methodName: binding.expression,
+      bindingFn: binding.value
+    }
+
+    setTimeout(() => {
+      document.addEventListener('touchstart', documentHandler) // 为document绑定事件
+    })
+  },
+  update(el, binding) {
+    const ele = el
+    ele[ctx].methodName = binding.expression
+    ele[ctx].bindingFn = binding.value
+  },
+  unbind(el) {
+    document.removeEventListener('touchstart', el[ctx].documentHandler) // 解绑
+    delete el[ctx]
+  }
+}

+ 28 - 0
src/config/axios/config.ts

@@ -0,0 +1,28 @@
+const config: {
+  base_url: string
+  result_code: number | string
+  default_headers: AxiosHeaders
+  request_timeout: number
+} = {
+  /**
+   * api请求基础路径
+   */
+  base_url: import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL,
+  /**
+   * 接口成功返回状态码
+   */
+  result_code: 200,
+
+  /**
+   * 接口请求超时时间
+   */
+  request_timeout: 30000,
+
+  /**
+   * 默认接口请求类型
+   * 可选值:application/x-www-form-urlencoded multipart/form-data
+   */
+  default_headers: 'application/json'
+}
+
+export { config }

+ 35 - 0
src/layout/components/Collapse/src/Collapse.vue

@@ -0,0 +1,35 @@
+<script lang="ts" setup>
+import { useAppStore } from '@/store/modules/app'
+import { propTypes } from '@/utils/propTypes'
+import { useDesign } from '@/hooks/web/useDesign'
+
+defineOptions({ name: 'Collapse' })
+
+const { getPrefixCls } = useDesign()
+
+const prefixCls = getPrefixCls('collapse')
+
+defineProps({
+  color: propTypes.string.def('')
+})
+
+const appStore = useAppStore()
+
+const collapse = computed(() => appStore.getCollapse)
+
+const toggleCollapse = () => {
+  const collapsed = unref(collapse)
+  appStore.setCollapse(!collapsed)
+}
+</script>
+
+<template>
+  <div :class="prefixCls" @click="toggleCollapse">
+    <Icon
+      :color="color"
+      :icon="collapse ? 'ep:expand' : 'ep:fold'"
+      :size="18"
+      class="cursor-pointer"
+    />
+  </div>
+</template>

+ 67 - 0
src/layout/components/Setting/src/components/ColorRadioPicker.vue

@@ -0,0 +1,67 @@
+<script lang="ts" setup>
+import { PropType } from 'vue'
+import { propTypes } from '@/utils/propTypes'
+import { useDesign } from '@/hooks/web/useDesign'
+
+defineOptions({ name: 'ColorRadioPicker' })
+
+const { getPrefixCls } = useDesign()
+
+const prefixCls = getPrefixCls('color-radio-picker')
+
+const props = defineProps({
+  schema: {
+    type: Array as PropType<string[]>,
+    default: () => []
+  },
+  modelValue: propTypes.string.def('')
+})
+
+const emit = defineEmits(['update:modelValue', 'change'])
+
+const colorVal = ref(props.modelValue)
+
+watch(
+  () => props.modelValue,
+  (val: string) => {
+    if (val === unref(colorVal)) return
+    colorVal.value = val
+  }
+)
+
+// 监听
+watch(
+  () => colorVal.value,
+  (val: string) => {
+    emit('update:modelValue', val)
+    emit('change', val)
+  }
+)
+</script>
+
+<template>
+  <div :class="prefixCls" class="flex flex-wrap space-x-14px">
+    <span
+      v-for="(item, i) in schema"
+      :key="`radio-${i}`"
+      :class="{ 'is-active': colorVal === item }"
+      :style="{
+        background: item
+      }"
+      class="mb-5px h-20px w-20px cursor-pointer border-2px border-gray-300 rounded-2px border-solid text-center leading-20px"
+      @click="colorVal = item"
+    >
+      <Icon v-if="colorVal === item" :size="16" color="#fff" icon="ep:check" />
+    </span>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+$prefix-cls: #{$namespace}-color-radio-picker;
+
+.#{$prefix-cls} {
+  .is-active {
+    border-color: var(--el-color-primary);
+  }
+}
+</style>

+ 56 - 0
src/types/components.d.ts

@@ -0,0 +1,56 @@
+export type ComponentName =
+  | 'Radio'
+  | 'RadioButton'
+  | 'Checkbox'
+  | 'CheckboxButton'
+  | 'Input'
+  | 'Autocomplete'
+  | 'InputNumber'
+  | 'Select'
+  | 'Cascader'
+  | 'Switch'
+  | 'Slider'
+  | 'TimePicker'
+  | 'DatePicker'
+  | 'Rate'
+  | 'ColorPicker'
+  | 'Transfer'
+  | 'Divider'
+  | 'TimeSelect'
+  | 'SelectV2'
+  | 'TreeSelect'
+  | 'InputPassword'
+  | 'Editor'
+  | 'UploadImg'
+  | 'UploadImgs'
+  | 'UploadFile'
+
+export type ColProps = {
+  span?: number
+  xs?: number
+  sm?: number
+  md?: number
+  lg?: number
+  xl?: number
+  tag?: string
+}
+
+export type ComponentOptions = {
+  label?: string
+  value?: FormValueType
+  disabled?: boolean
+  key?: string | number
+  children?: ComponentOptions[]
+  options?: ComponentOptions[]
+} & Recordable
+
+export type ComponentOptionsAlias = {
+  labelField?: string
+  valueField?: string
+}
+
+export type ComponentProps = {
+  optionsAlias?: ComponentOptionsAlias
+  options?: ComponentOptions[]
+  optionsSlot?: boolean
+} & Recordable

+ 4 - 0
src/types/configGlobal.d.ts

@@ -0,0 +1,4 @@
+import { ElementPlusSize } from './elementPlus'
+export interface ConfigGlobalTypes {
+  size?: ElementPlusSize
+}

+ 7 - 0
src/types/contextMenu.d.ts

@@ -0,0 +1,7 @@
+export type contextMenuSchema = {
+  disabled?: boolean
+  divided?: boolean
+  icon?: string
+  label: string
+  command?: (item: contextMenuSchema) => void
+}

+ 174 - 0
src/utils/color.ts

@@ -0,0 +1,174 @@
+/**
+ * 判断是否 十六进制颜色值.
+ * 输入形式可为 #fff000 #f00
+ *
+ * @param   String  color   十六进制颜色值
+ * @return  Boolean
+ */
+export const isHexColor = (color: string) => {
+  const reg = /^#([0-9a-fA-F]{3}|[0-9a-fA-f]{6})$/
+  return reg.test(color)
+}
+
+/**
+ * RGB 颜色值转换为 十六进制颜色值.
+ * r, g, 和 b 需要在 [0, 255] 范围内
+ *
+ * @return  String          类似#ff00ff
+ * @param r
+ * @param g
+ * @param b
+ */
+export const rgbToHex = (r: number, g: number, b: number) => {
+  // tslint:disable-next-line:no-bitwise
+  const hex = ((r << 16) | (g << 8) | b).toString(16)
+  return '#' + new Array(Math.abs(hex.length - 7)).join('0') + hex
+}
+
+/**
+ * Transform a HEX color to its RGB representation
+ * @param {string} hex The color to transform
+ * @returns The RGB representation of the passed color
+ */
+export const hexToRGB = (hex: string, opacity?: number) => {
+  let sHex = hex.toLowerCase()
+  if (isHexColor(hex)) {
+    if (sHex.length === 4) {
+      let sColorNew = '#'
+      for (let i = 1; i < 4; i += 1) {
+        sColorNew += sHex.slice(i, i + 1).concat(sHex.slice(i, i + 1))
+      }
+      sHex = sColorNew
+    }
+    const sColorChange: number[] = []
+    for (let i = 1; i < 7; i += 2) {
+      sColorChange.push(parseInt('0x' + sHex.slice(i, i + 2)))
+    }
+    return opacity
+      ? 'RGBA(' + sColorChange.join(',') + ',' + opacity + ')'
+      : 'RGB(' + sColorChange.join(',') + ')'
+  }
+  return sHex
+}
+
+export const colorIsDark = (color: string) => {
+  if (!isHexColor(color)) return
+  const [r, g, b] = hexToRGB(color)
+    .replace(/(?:\(|\)|rgb|RGB)*/g, '')
+    .split(',')
+    .map((item) => Number(item))
+  return r * 0.299 + g * 0.578 + b * 0.114 < 192
+}
+
+/**
+ * Darkens a HEX color given the passed percentage
+ * @param {string} color The color to process
+ * @param {number} amount The amount to change the color by
+ * @returns {string} The HEX representation of the processed color
+ */
+export const darken = (color: string, amount: number) => {
+  color = color.indexOf('#') >= 0 ? color.substring(1, color.length) : color
+  amount = Math.trunc((255 * amount) / 100)
+  return `#${subtractLight(color.substring(0, 2), amount)}${subtractLight(
+    color.substring(2, 4),
+    amount
+  )}${subtractLight(color.substring(4, 6), amount)}`
+}
+
+/**
+ * Lightens a 6 char HEX color according to the passed percentage
+ * @param {string} color The color to change
+ * @param {number} amount The amount to change the color by
+ * @returns {string} The processed color represented as HEX
+ */
+export const lighten = (color: string, amount: number) => {
+  color = color.indexOf('#') >= 0 ? color.substring(1, color.length) : color
+  amount = Math.trunc((255 * amount) / 100)
+  return `#${addLight(color.substring(0, 2), amount)}${addLight(
+    color.substring(2, 4),
+    amount
+  )}${addLight(color.substring(4, 6), amount)}`
+}
+
+/* Suma el porcentaje indicado a un color (RR, GG o BB) hexadecimal para aclararlo */
+/**
+ * Sums the passed percentage to the R, G or B of a HEX color
+ * @param {string} color The color to change
+ * @param {number} amount The amount to change the color by
+ * @returns {string} The processed part of the color
+ */
+const addLight = (color: string, amount: number) => {
+  const cc = parseInt(color, 16) + amount
+  const c = cc > 255 ? 255 : cc
+  return c.toString(16).length > 1 ? c.toString(16) : `0${c.toString(16)}`
+}
+
+/**
+ * Calculates luminance of an rgb color
+ * @param {number} r red
+ * @param {number} g green
+ * @param {number} b blue
+ */
+const luminanace = (r: number, g: number, b: number) => {
+  const a = [r, g, b].map((v) => {
+    v /= 255
+    return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
+  })
+  return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722
+}
+
+/**
+ * Calculates contrast between two rgb colors
+ * @param {string} rgb1 rgb color 1
+ * @param {string} rgb2 rgb color 2
+ */
+const contrast = (rgb1: string[], rgb2: number[]) => {
+  return (
+    (luminanace(~~rgb1[0], ~~rgb1[1], ~~rgb1[2]) + 0.05) /
+    (luminanace(rgb2[0], rgb2[1], rgb2[2]) + 0.05)
+  )
+}
+
+/**
+ * Determines what the best text color is (black or white) based con the contrast with the background
+ * @param hexColor - Last selected color by the user
+ */
+export const calculateBestTextColor = (hexColor: string) => {
+  const rgbColor = hexToRGB(hexColor.substring(1))
+  const contrastWithBlack = contrast(rgbColor.split(','), [0, 0, 0])
+
+  return contrastWithBlack >= 12 ? '#000000' : '#FFFFFF'
+}
+
+/**
+ * Subtracts the indicated percentage to the R, G or B of a HEX color
+ * @param {string} color The color to change
+ * @param {number} amount The amount to change the color by
+ * @returns {string} The processed part of the color
+ */
+const subtractLight = (color: string, amount: number) => {
+  const cc = parseInt(color, 16) - amount
+  const c = cc < 0 ? 0 : cc
+  return c.toString(16).length > 1 ? c.toString(16) : `0${c.toString(16)}`
+}
+
+// 预设颜色
+export const PREDEFINE_COLORS = [
+  '#ff4500',
+  '#ff8c00',
+  '#ffd700',
+  '#90ee90',
+  '#00ced1',
+  '#1e90ff',
+  '#c71585',
+  '#409EFF',
+  '#909399',
+  '#C0C4CC',
+  '#b7390b',
+  '#ff7800',
+  '#fad400',
+  '#5b8c5f',
+  '#00babd',
+  '#1f73c3',
+  '#711f57'
+]

+ 439 - 0
src/utils/constants.ts

@@ -0,0 +1,439 @@
+/**
+ * Created by 芋道源码
+ *
+ * 枚举类
+ */
+
+// ========== COMMON 模块 ==========
+// 全局通用状态枚举
+export const CommonStatusEnum = {
+  ENABLE: 0, // 开启
+  DISABLE: 1 // 禁用
+}
+
+// 全局用户类型枚举
+export const UserTypeEnum = {
+  MEMBER: 1, // 会员
+  ADMIN: 2 // 管理员
+}
+
+// ========== SYSTEM 模块 ==========
+/**
+ * 菜单的类型枚举
+ */
+export const SystemMenuTypeEnum = {
+  DIR: 1, // 目录
+  MENU: 2, // 菜单
+  BUTTON: 3 // 按钮
+}
+
+/**
+ * 角色的类型枚举
+ */
+export const SystemRoleTypeEnum = {
+  SYSTEM: 1, // 内置角色
+  CUSTOM: 2 // 自定义角色
+}
+
+/**
+ * 数据权限的范围枚举
+ */
+export const SystemDataScopeEnum = {
+  ALL: 1, // 全部数据权限
+  DEPT_CUSTOM: 2, // 指定部门数据权限
+  DEPT_ONLY: 3, // 部门数据权限
+  DEPT_AND_CHILD: 4, // 部门及以下数据权限
+  DEPT_SELF: 5 // 仅本人数据权限
+}
+
+/**
+ * 用户的社交平台的类型枚举
+ */
+export const SystemUserSocialTypeEnum = {
+  DINGTALK: {
+    title: '钉钉',
+    type: 20,
+    source: 'dingtalk',
+    img: 'https://s1.ax1x.com/2022/05/22/OzMDRs.png'
+  },
+  WECHAT_ENTERPRISE: {
+    title: '企业微信',
+    type: 30,
+    source: 'wechat_enterprise',
+    img: 'https://s1.ax1x.com/2022/05/22/OzMrzn.png'
+  }
+}
+
+// ========== INFRA 模块 ==========
+/**
+ * 代码生成模板类型
+ */
+export const InfraCodegenTemplateTypeEnum = {
+  CRUD: 1, // 基础 CRUD
+  TREE: 2, // 树形 CRUD
+  SUB: 3 // 主子表 CRUD
+}
+
+/**
+ * 任务状态的枚举
+ */
+export const InfraJobStatusEnum = {
+  INIT: 0, // 初始化中
+  NORMAL: 1, // 运行中
+  STOP: 2 // 暂停运行
+}
+
+/**
+ * API 异常数据的处理状态
+ */
+export const InfraApiErrorLogProcessStatusEnum = {
+  INIT: 0, // 未处理
+  DONE: 1, // 已处理
+  IGNORE: 2 // 已忽略
+}
+
+// ========== PAY 模块 ==========
+/**
+ * 支付渠道枚举
+ */
+export const PayChannelEnum = {
+  WX_PUB: {
+    code: 'wx_pub',
+    name: '微信 JSAPI 支付'
+  },
+  WX_LITE: {
+    code: 'wx_lite',
+    name: '微信小程序支付'
+  },
+  WX_APP: {
+    code: 'wx_app',
+    name: '微信 APP 支付'
+  },
+  WX_NATIVE: {
+    code: 'wx_native',
+    name: '微信 Native 支付'
+  },
+  WX_WAP: {
+    code: 'wx_wap',
+    name: '微信 WAP 网站支付'
+  },
+  WX_BAR: {
+    code: 'wx_bar',
+    name: '微信条码支付'
+  },
+  ALIPAY_PC: {
+    code: 'alipay_pc',
+    name: '支付宝 PC 网站支付'
+  },
+  ALIPAY_WAP: {
+    code: 'alipay_wap',
+    name: '支付宝 WAP 网站支付'
+  },
+  ALIPAY_APP: {
+    code: 'alipay_app',
+    name: '支付宝 APP 支付'
+  },
+  ALIPAY_QR: {
+    code: 'alipay_qr',
+    name: '支付宝扫码支付'
+  },
+  ALIPAY_BAR: {
+    code: 'alipay_bar',
+    name: '支付宝条码支付'
+  },
+  WALLET: {
+    code: 'wallet',
+    name: '钱包支付'
+  },
+  MOCK: {
+    code: 'mock',
+    name: '模拟支付'
+  }
+}
+
+/**
+ * 支付的展示模式每局
+ */
+export const PayDisplayModeEnum = {
+  URL: {
+    mode: 'url'
+  },
+  IFRAME: {
+    mode: 'iframe'
+  },
+  FORM: {
+    mode: 'form'
+  },
+  QR_CODE: {
+    mode: 'qr_code'
+  },
+  APP: {
+    mode: 'app'
+  }
+}
+
+/**
+ * 支付类型枚举
+ */
+export const PayType = {
+  WECHAT: 'WECHAT',
+  ALIPAY: 'ALIPAY',
+  MOCK: 'MOCK'
+}
+
+/**
+ * 支付订单状态枚举
+ */
+export const PayOrderStatusEnum = {
+  WAITING: {
+    status: 0,
+    name: '未支付'
+  },
+  SUCCESS: {
+    status: 10,
+    name: '已支付'
+  },
+  CLOSED: {
+    status: 20,
+    name: '未支付'
+  }
+}
+
+// ========== MALL - 商品模块 ==========
+/**
+ * 商品 SPU 状态
+ */
+export const ProductSpuStatusEnum = {
+  RECYCLE: {
+    status: -1,
+    name: '回收站'
+  },
+  DISABLE: {
+    status: 0,
+    name: '下架'
+  },
+  ENABLE: {
+    status: 1,
+    name: '上架'
+  }
+}
+
+// ========== MALL - 营销模块 ==========
+/**
+ * 优惠劵模板的有限期类型的枚举
+ */
+export const CouponTemplateValidityTypeEnum = {
+  DATE: {
+    type: 1,
+    name: '固定日期可用'
+  },
+  TERM: {
+    type: 2,
+    name: '领取之后可用'
+  }
+}
+
+/**
+ * 优惠劵模板的领取方式的枚举
+ */
+export const CouponTemplateTakeTypeEnum = {
+  USER: {
+    type: 1,
+    name: '直接领取'
+  },
+  ADMIN: {
+    type: 2,
+    name: '指定发放'
+  },
+  REGISTER: {
+    type: 3,
+    name: '新人券'
+  }
+}
+
+/**
+ * 营销的商品范围枚举
+ */
+export const PromotionProductScopeEnum = {
+  ALL: {
+    scope: 1,
+    name: '通用劵'
+  },
+  SPU: {
+    scope: 2,
+    name: '商品劵'
+  },
+  CATEGORY: {
+    scope: 3,
+    name: '品类劵'
+  }
+}
+
+/**
+ * 营销的条件类型枚举
+ */
+export const PromotionConditionTypeEnum = {
+  PRICE: {
+    type: 10,
+    name: '满 N 元'
+  },
+  COUNT: {
+    type: 20,
+    name: '满 N 件'
+  }
+}
+
+/**
+ * 优惠类型枚举
+ */
+export const PromotionDiscountTypeEnum = {
+  PRICE: {
+    type: 1,
+    name: '满减'
+  },
+  PERCENT: {
+    type: 2,
+    name: '折扣'
+  }
+}
+
+// ========== MALL - 交易模块 ==========
+/**
+ * 分销关系绑定模式枚举
+ */
+export const BrokerageBindModeEnum = {
+  ANYTIME: {
+    mode: 1,
+    name: '首次绑定'
+  },
+  REGISTER: {
+    mode: 2,
+    name: '注册绑定'
+  },
+  OVERRIDE: {
+    mode: 3,
+    name: '覆盖绑定'
+  }
+}
+/**
+ * 分佣模式枚举
+ */
+export const BrokerageEnabledConditionEnum = {
+  ALL: {
+    condition: 1,
+    name: '人人分销'
+  },
+  ADMIN: {
+    condition: 2,
+    name: '指定分销'
+  }
+}
+/**
+ * 佣金记录业务类型枚举
+ */
+export const BrokerageRecordBizTypeEnum = {
+  ORDER: {
+    type: 1,
+    name: '获得推广佣金'
+  },
+  WITHDRAW: {
+    type: 2,
+    name: '提现申请'
+  }
+}
+/**
+ * 佣金提现状态枚举
+ */
+export const BrokerageWithdrawStatusEnum = {
+  AUDITING: {
+    status: 0,
+    name: '审核中'
+  },
+  AUDIT_SUCCESS: {
+    status: 10,
+    name: '审核通过'
+  },
+  AUDIT_FAIL: {
+    status: 20,
+    name: '审核不通过'
+  },
+  WITHDRAW_SUCCESS: {
+    status: 11,
+    name: '提现成功'
+  },
+  WITHDRAW_FAIL: {
+    status: 21,
+    name: '提现失败'
+  }
+}
+/**
+ * 佣金提现类型枚举
+ */
+export const BrokerageWithdrawTypeEnum = {
+  WALLET: {
+    type: 1,
+    name: '钱包'
+  },
+  BANK: {
+    type: 2,
+    name: '银行卡'
+  },
+  WECHAT: {
+    type: 3,
+    name: '微信'
+  },
+  ALIPAY: {
+    type: 4,
+    name: '支付宝'
+  }
+}
+
+/**
+ * 配送方式枚举
+ */
+export const DeliveryTypeEnum = {
+  EXPRESS: {
+    type: 1,
+    name: '快递发货'
+  },
+  PICK_UP: {
+    type: 2,
+    name: '到店自提'
+  }
+}
+/**
+ * 交易订单 - 状态
+ */
+export const TradeOrderStatusEnum = {
+  UNPAID: {
+    status: 0,
+    name: '待支付'
+  },
+  UNDELIVERED: {
+    status: 10,
+    name: '待发货'
+  },
+  DELIVERED: {
+    status: 20,
+    name: '已发货'
+  },
+  COMPLETED: {
+    status: 30,
+    name: '已完成'
+  },
+  CANCELED: {
+    status: 40,
+    name: '已取消'
+  }
+}
+
+// ========== ERP - 企业资源计划 ==========
+
+export const ErpBizType = {
+  PURCHASE_ORDER: 10,
+  PURCHASE_IN: 11,
+  PURCHASE_RETURN: 12,
+  SALE_ORDER: 20,
+  SALE_OUT: 21,
+  SALE_RETURN: 22
+}

+ 175 - 0
src/views/ai/chat/manager/ChatMessageList.vue

@@ -0,0 +1,175 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="对话编号" prop="conversationId">
+        <el-input
+          v-model="queryParams.conversationId"
+          placeholder="请输入对话编号"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="用户编号" prop="userId">
+        <el-select
+          v-model="queryParams.userId"
+          clearable
+          placeholder="请输入用户编号"
+          class="!w-240px"
+        >
+          <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="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-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="消息编号" align="center" prop="id" width="180" fixed="left" />
+      <el-table-column
+        label="对话编号"
+        align="center"
+        prop="conversationId"
+        width="180"
+        fixed="left"
+      />
+      <el-table-column label="用户" align="center" prop="userId" width="180">
+        <template #default="scope">
+          <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="角色" align="center" prop="roleName" width="180" />
+      <el-table-column label="消息类型" align="center" prop="type" width="100" />
+      <el-table-column label="模型标识" align="center" prop="model" width="180" />
+      <el-table-column label="消息内容" align="center" prop="content" width="300" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="回复消息编号" align="center" prop="replyId" width="180" />
+      <el-table-column label="携带上下文" align="center" prop="useContext" width="100">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.useContext" />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" fixed="right">
+        <template #default="scope">
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['ai:chat-message: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>
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
+import * as UserApi from '@/api/system/user'
+import { DICT_TYPE } from '@/utils/dict'
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ChatMessageVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  conversationId: undefined,
+  userId: undefined,
+  content: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ChatMessageApi.getChatMessagePage(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 handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ChatMessageApi.deleteChatMessageByAdmin(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  getList()
+  // 获得用户列表
+  userList.value = await UserApi.getSimpleUserList()
+})
+</script>

+ 181 - 0
src/views/ai/model/chatModel/ChatModelForm.vue

@@ -0,0 +1,181 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="所属平台" prop="platform">
+        <el-select v-model="formData.platform" placeholder="请输入平台" clearable>
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="API 秘钥" prop="keyId">
+        <el-select v-model="formData.keyId" placeholder="请选择 API 秘钥" clearable>
+          <el-option
+            v-for="apiKey in apiKeyList"
+            :key="apiKey.id"
+            :label="apiKey.name"
+            :value="apiKey.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="模型名字" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入模型名字" />
+      </el-form-item>
+      <el-form-item label="模型标识" prop="model">
+        <el-input v-model="formData.model" placeholder="请输入模型标识" />
+      </el-form-item>
+      <el-form-item label="模型排序" prop="sort">
+        <el-input-number v-model="formData.sort" placeholder="请输入模型排序" class="!w-1/1" />
+      </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-item label="温度参数" prop="temperature">
+        <el-input-number
+          v-model="formData.temperature"
+          placeholder="请输入温度参数"
+          :min="0"
+          :max="2"
+          :precision="2"
+        />
+      </el-form-item>
+      <el-form-item label="回复数 Token 数" prop="maxTokens">
+        <el-input-number
+          v-model="formData.maxTokens"
+          placeholder="请输入回复数 Token 数"
+          :min="0"
+          :max="4096"
+        />
+      </el-form-item>
+      <el-form-item label="上下文数量" prop="maxContexts">
+        <el-input-number
+          v-model="formData.maxContexts"
+          placeholder="请输入上下文数量"
+          :min="0"
+          :max="20"
+        />
+      </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 { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel'
+import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey'
+import { CommonStatusEnum } from '@/utils/constants'
+import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
+
+/** API 聊天模型 表单 */
+defineOptions({ name: 'ChatModelForm' })
+
+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,
+  keyId: undefined,
+  name: undefined,
+  model: undefined,
+  platform: undefined,
+  sort: undefined,
+  status: CommonStatusEnum.ENABLE,
+  temperature: undefined,
+  maxTokens: undefined,
+  maxContexts: undefined
+})
+const formRules = reactive({
+  keyId: [{ required: true, message: 'API 秘钥不能为空', trigger: 'blur' }],
+  name: [{ required: true, message: '模型名字不能为空', trigger: 'blur' }],
+  model: [{ required: true, message: '模型标识不能为空', trigger: 'blur' }],
+  platform: [{ required: true, message: '所属平台不能为空', trigger: 'blur' }],
+  sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const apiKeyList = ref([] as ApiKeyVO[]) // API 密钥列表
+
+/** 打开弹窗 */
+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 ChatModelApi.getChatModel(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 获得下拉数据
+  apiKeyList.value = await ApiKeyApi.getApiKeySimpleList(CommonStatusEnum.ENABLE)
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as ChatModelVO
+    if (formType.value === 'create') {
+      await ChatModelApi.createChatModel(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ChatModelApi.updateChatModel(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    keyId: undefined,
+    name: undefined,
+    model: undefined,
+    platform: undefined,
+    sort: undefined,
+    status: CommonStatusEnum.ENABLE,
+    temperature: undefined,
+    maxTokens: undefined,
+    maxContexts: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 183 - 0
src/views/ai/model/chatRole/ChatRoleForm.vue

@@ -0,0 +1,183 @@
+<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="角色名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入角色名称" />
+      </el-form-item>
+      <el-form-item label="角色头像" prop="avatar">
+        <UploadImg v-model="formData.avatar" height="60px" width="60px" />
+      </el-form-item>
+      <el-form-item label="绑定模型" prop="modelId" v-if="!isUser">
+        <el-select v-model="formData.modelId" placeholder="请选择模型" clearable>
+          <el-option
+            v-for="chatModel in chatModelList"
+            :key="chatModel.id"
+            :label="chatModel.name"
+            :value="chatModel.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="角色类别" prop="category" v-if="!isUser">
+        <el-input v-model="formData.category" placeholder="请输入角色类别" />
+      </el-form-item>
+      <el-form-item label="角色描述" prop="description">
+        <el-input type="textarea" v-model="formData.description" placeholder="请输入角色描述" />
+      </el-form-item>
+      <el-form-item label="角色设定" prop="systemMessage">
+        <el-input type="textarea" v-model="formData.systemMessage" placeholder="请输入角色设定" />
+      </el-form-item>
+      <el-form-item label="是否公开" prop="publicStatus" v-if="!isUser">
+        <el-radio-group v-model="formData.publicStatus">
+          <el-radio
+            v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="角色排序" prop="sort" v-if="!isUser">
+        <el-input-number v-model="formData.sort" placeholder="请输入角色排序" class="!w-1/1" />
+      </el-form-item>
+      <el-form-item label="开启状态" prop="status" v-if="!isUser">
+        <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 @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { getIntDictOptions, getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
+import { ChatRoleApi, ChatRoleVO } from '@/api/ai/model/chatRole'
+import { CommonStatusEnum } from '@/utils/constants'
+import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel'
+import {FormRules} from "element-plus";
+
+/** AI 聊天角色 表单 */
+defineOptions({ name: 'ChatRoleForm' })
+
+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,
+  modelId: undefined,
+  name: undefined,
+  avatar: undefined,
+  category: undefined,
+  sort: undefined,
+  description: undefined,
+  systemMessage: undefined,
+  publicStatus: true,
+  status: CommonStatusEnum.ENABLE
+})
+const formRef = ref() // 表单 Ref
+const chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表
+
+/** 是否【我】自己创建,私有角色 */
+const isUser = computed(() => {
+  return formType.value === 'my-create' || formType.value === 'my-update'
+})
+
+const formRules = reactive<FormRules>({
+  name: [{ required: true, message: '角色名称不能为空', trigger: 'blur' }],
+  avatar: [{ required: true, message: '角色头像不能为空', trigger: 'blur' }],
+  category: [{ required: true, message: '角色类别不能为空', trigger: 'blur' }],
+  sort: [{ required: true, message: '角色排序不能为空', trigger: 'blur' }],
+  description: [{ required: true, message: '角色描述不能为空', trigger: 'blur' }],
+  systemMessage: [{ required: true, message: '角色设定不能为空', trigger: 'blur' }],
+  publicStatus: [{ required: true, message: '是否公开不能为空', trigger: 'blur' }]
+})
+
+/** 打开弹窗 */
+// TODO @fan:title 是不是收敛到 type 判断生成 title,会更合理
+const open = async (type: string, id?: number, title?: string) => {
+  dialogVisible.value = true
+  dialogTitle.value = title || t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ChatRoleApi.getChatRole(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 获得下拉数据
+  chatModelList.value = await ChatModelApi.getChatModelSimpleList(CommonStatusEnum.ENABLE)
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as ChatRoleVO
+    // tip: my-create、my-update 是 chat 角色仓库调用
+    // tip: create、else 是后台管理调用
+    if (formType.value === 'my-create') {
+      await ChatRoleApi.createMy(data)
+      message.success(t('common.createSuccess'))
+    } else if (formType.value === 'my-update') {
+      await ChatRoleApi.updateMy(data)
+      message.success(t('common.updateSuccess'))
+    } else if (formType.value === 'create') {
+      await ChatRoleApi.createChatRole(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ChatRoleApi.updateChatRole(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    modelId: undefined,
+    name: undefined,
+    avatar: undefined,
+    category: undefined,
+    sort: undefined,
+    description: undefined,
+    systemMessage: undefined,
+    publicStatus: true,
+    status: CommonStatusEnum.ENABLE
+  }
+  formRef.value?.resetFields()
+}
+</script>

File diff suppressed because it is too large
+ 432 - 0
src/views/ai/utils/constants.ts


+ 153 - 0
src/views/crm/backlog/components/ClueFollowList.vue

@@ -0,0 +1,153 @@
+<template>
+  <ContentWrap>
+    <div class="pb-5 text-xl">分配给我的线索</div>
+    <!-- 搜索工作栏 -->
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <el-form-item label="状态" prop="followUpStatus">
+        <el-select
+          v-model="queryParams.followUpStatus"
+          class="!w-240px"
+          placeholder="状态"
+          @change="handleQuery"
+        >
+          <el-option
+            v-for="(option, index) in FOLLOWUP_STATUS"
+            :label="option.label"
+            :value="option.value"
+            :key="index"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="线索名称" align="center" prop="name" fixed="left" width="160">
+        <template #default="scope">
+          <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)">
+            {{ scope.row.name }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column label="线索来源" align="center" prop="source" width="100">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="scope.row.source" />
+        </template>
+      </el-table-column>
+      <el-table-column label="手机" align="center" prop="mobile" width="120" />
+      <el-table-column label="电话" align="center" prop="telephone" width="130" />
+      <el-table-column label="邮箱" align="center" prop="email" width="180" />
+      <el-table-column label="地址" align="center" prop="detailAddress" width="180" />
+      <el-table-column align="center" label="客户行业" prop="industryId" width="100">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="scope.row.industryId" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="客户级别" prop="level" width="135">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="scope.row.level" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="下次联系时间"
+        prop="contactNextTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="备注" prop="remark" width="200" />
+      <el-table-column
+        label="最后跟进时间"
+        align="center"
+        prop="contactLastTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column align="center" label="最后跟进记录" prop="contactLastContent" width="200" />
+      <el-table-column align="center" label="负责人" prop="ownerUserName" width="100px" />
+      <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100" />
+      <el-table-column
+        label="更新时间"
+        align="center"
+        prop="updateTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column align="center" label="创建人" prop="creatorName" width="100px" />
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import * as ClueApi from '@/api/crm/clue'
+import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { FOLLOWUP_STATUS } from './common'
+
+defineOptions({ name: 'CrmClueFollowList' })
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  followUpStatus: false,
+  transformStatus: false
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ClueApi.getCluePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 打开线索详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'CrmClueDetail', params: { id } })
+}
+
+/** 激活时 */
+onActivated(async () => {
+  await getList()
+})
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 39 - 0
src/views/crm/backlog/components/common.ts

@@ -0,0 +1,39 @@
+/** 跟进状态 */
+export const FOLLOWUP_STATUS = [
+  { label: '待跟进', value: false },
+  { label: '已跟进', value: true }
+]
+
+/** 归属范围 */
+export const SCENE_TYPES = [
+  { label: '我负责的', value: 1 },
+  { label: '我参与的', value: 2 },
+  { label: '下属负责的', value: 3 }
+]
+
+/** 联系状态 */
+export const CONTACT_STATUS = [
+  { label: '今日需联系', value: 1 },
+  { label: '已逾期', value: 2 },
+  { label: '已联系', value: 3 }
+]
+
+/** 审批状态 */
+export const AUDIT_STATUS = [
+  { label: '待审批', value: 10 },
+  { label: '审核通过', value: 20 },
+  { label: '审核不通过', value: 30 }
+]
+
+/** 回款提醒类型 */
+export const RECEIVABLE_REMIND_TYPE = [
+  { label: '待回款', value: 1 },
+  { label: '已逾期', value: 2 },
+  { label: '已回款', value: 3 }
+]
+
+/** 合同过期状态 */
+export const CONTRACT_EXPIRY_TYPE = [
+  { label: '即将过期', value: 1 },
+  { label: '已过期', value: 2 }
+]

+ 259 - 0
src/views/crm/clue/ClueForm.vue

@@ -0,0 +1,259 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+    >
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="线索名称" prop="name">
+            <el-input v-model="formData.name" placeholder="请输入线索名称" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="客户来源" prop="source">
+            <el-select v-model="formData.source" placeholder="请选择客户来源" class="w-1/1">
+              <el-option
+                v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_SOURCE)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="手机" prop="mobile">
+            <el-input v-model="formData.mobile" placeholder="请输入手机" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="负责人" prop="ownerUserId">
+            <el-select
+              v-model="formData.ownerUserId"
+              :disabled="formType !== 'create'"
+              class="w-1/1"
+            >
+              <el-option
+                v-for="item in userOptions"
+                :key="item.id"
+                :label="item.nickname"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="电话" prop="telephone">
+            <el-input v-model="formData.telephone" placeholder="请输入电话" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="邮箱" prop="email">
+            <el-input v-model="formData.email" placeholder="请输入邮箱" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="微信" prop="wechat">
+            <el-input v-model="formData.wechat" placeholder="请输入微信" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="QQ" prop="qq">
+            <el-input v-model="formData.qq" placeholder="请输入 QQ" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="客户行业" prop="industryId">
+            <el-select v-model="formData.industryId" placeholder="请选择客户行业" class="w-1/1">
+              <el-option
+                v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_INDUSTRY)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="客户级别" prop="level">
+            <el-select v-model="formData.level" placeholder="请选择客户级别" class="w-1/1">
+              <el-option
+                v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_LEVEL)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="地址" prop="areaId">
+            <el-cascader
+              v-model="formData.areaId"
+              :options="areaList"
+              :props="defaultProps"
+              class="w-1/1"
+              clearable
+              filterable
+              placeholder="请选择城市"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="详细地址" prop="detailAddress">
+            <el-input v-model="formData.detailAddress" placeholder="请输入详细地址" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="下次联系时间" prop="contactNextTime">
+            <el-date-picker
+              v-model="formData.contactNextTime"
+              placeholder="选择下次联系时间"
+              type="datetime"
+              value-format="x"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="备注" prop="remark">
+            <el-input type="textarea" v-model="formData.remark" placeholder="请输入备注" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </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 ClueApi from '@/api/crm/clue'
+import * as AreaApi from '@/api/system/area'
+import { defaultProps } from '@/utils/tree'
+import * as UserApi from '@/api/system/user'
+import { useUserStore } from '@/store/modules/user'
+
+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 areaList = ref([]) // 地区列表
+const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  contactNextTime: undefined,
+  ownerUserId: 0,
+  mobile: undefined,
+  telephone: undefined,
+  qq: undefined,
+  wechat: undefined,
+  email: undefined,
+  areaId: undefined,
+  detailAddress: undefined,
+  industryId: undefined,
+  level: undefined,
+  source: undefined,
+  remark: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '线索名称不能为空', trigger: 'blur' }],
+  ownerUserId: [{ 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 ClueApi.getClue(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 获得地区列表
+  areaList.value = await AreaApi.getAreaTree()
+  // 获得用户列表
+  userOptions.value = await UserApi.getSimpleUserList()
+  // 默认新建时选中自己
+  if (formType.value === 'create') {
+    formData.value.ownerUserId = useUserStore().getUser.id
+  }
+}
+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 ClueApi.ClueVO
+    if (formType.value === 'create') {
+      await ClueApi.createClue(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ClueApi.updateClue(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    contactNextTime: undefined,
+    ownerUserId: 0,
+    mobile: undefined,
+    telephone: undefined,
+    qq: undefined,
+    wechat: undefined,
+    email: undefined,
+    areaId: undefined,
+    detailAddress: undefined,
+    industryId: undefined,
+    level: undefined,
+    source: undefined,
+    remark: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 43 - 0
src/views/crm/clue/detail/ClueDetailsHeader.vue

@@ -0,0 +1,43 @@
+<template>
+  <div v-loading="loading">
+    <div class="flex items-start justify-between">
+      <div>
+        <!-- 左上:线索基本信息 -->
+        <el-col>
+          <el-row>
+            <span class="text-xl font-bold">{{ clue.name }}</span>
+          </el-row>
+        </el-col>
+      </div>
+      <div>
+        <!-- 右上:按钮 -->
+        <slot></slot>
+      </div>
+    </div>
+  </div>
+  <ContentWrap class="mt-10px">
+    <el-descriptions :column="5" direction="vertical">
+      <el-descriptions-item label="线索来源">
+        <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="clue.source" />
+      </el-descriptions-item>
+      <el-descriptions-item label="手机"> {{ clue.mobile }} </el-descriptions-item>
+      <el-descriptions-item label="负责人">
+        {{ clue.ownerUserName }}
+      </el-descriptions-item>
+      <el-descriptions-item label="创建时间">
+        {{ formatDate(clue.createTime) }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </ContentWrap>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE } from '@/utils/dict'
+import * as ClueApi from '@/api/crm/clue'
+import { formatDate } from '@/utils/formatTime'
+
+defineOptions({ name: 'CrmClueDetailsHeader' })
+defineProps<{
+  clue: ClueApi.ClueVO // 线索信息
+  loading: boolean // 加载中
+}>()
+</script>

+ 72 - 0
src/views/crm/clue/detail/ClueDetailsInfo.vue

@@ -0,0 +1,72 @@
+<template>
+  <ContentWrap>
+    <el-collapse v-model="activeNames" class="">
+      <el-collapse-item name="basicInfo">
+        <template #title>
+          <span class="text-base font-bold">基本信息</span>
+        </template>
+        <el-descriptions :column="4">
+          <el-descriptions-item label="线索名称">
+            {{ clue.name }}
+          </el-descriptions-item>
+          <el-descriptions-item label="客户来源">
+            <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="clue.source" />
+          </el-descriptions-item>
+          <el-descriptions-item label="手机">{{ clue.mobile }}</el-descriptions-item>
+          <el-descriptions-item label="电话">{{ clue.telephone }}</el-descriptions-item>
+          <el-descriptions-item label="邮箱">{{ clue.email }}</el-descriptions-item>
+          <el-descriptions-item label="地址">
+            {{ clue.areaName }} {{ clue.detailAddress }}
+          </el-descriptions-item>
+          <el-descriptions-item label="QQ">{{ clue.qq }}</el-descriptions-item>
+          <el-descriptions-item label="微信">{{ clue.wechat }}</el-descriptions-item>
+          <el-descriptions-item label="客户行业">
+            <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="clue.industryId" />
+          </el-descriptions-item>
+          <el-descriptions-item label="客户级别">
+            <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="clue.level" />
+          </el-descriptions-item>
+          <el-descriptions-item label="下次联系时间">
+            {{ formatDate(clue.contactNextTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="备注">{{ clue.remark }}</el-descriptions-item>
+        </el-descriptions>
+      </el-collapse-item>
+      <el-collapse-item name="systemInfo">
+        <template #title>
+          <span class="text-base font-bold">系统信息</span>
+        </template>
+        <el-descriptions :column="4">
+          <el-descriptions-item label="负责人">{{ clue.ownerUserName }}</el-descriptions-item>
+          <el-descriptions-item label="最后跟进记录">
+            {{ clue.contactLastContent }}
+          </el-descriptions-item>
+          <el-descriptions-item label="最后跟进时间">
+            {{ formatDate(clue.contactLastTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="">&nbsp;</el-descriptions-item>
+          <el-descriptions-item label="创建人">{{ clue.creatorName }}</el-descriptions-item>
+          <el-descriptions-item label="创建时间">
+            {{ formatDate(clue.createTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="更新时间">
+            {{ formatDate(clue.updateTime) }}
+          </el-descriptions-item>
+        </el-descriptions>
+      </el-collapse-item>
+    </el-collapse>
+  </ContentWrap>
+</template>
+<script lang="ts" setup>
+import * as ClueApi from '@/api/crm/clue'
+import { DICT_TYPE } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+
+defineOptions({ name: 'CrmClueDetailsInfo' })
+const { clue } = defineProps<{
+  clue: ClueApi.ClueVO // 线索明细
+}>()
+
+const activeNames = ref(['basicInfo', 'systemInfo']) // 展示的折叠面板
+</script>
+<style lang="scss" scoped></style>

+ 310 - 0
src/views/crm/contact/ContactForm.vue

@@ -0,0 +1,310 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+    >
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="联系人姓名" prop="name">
+            <el-input v-model="formData.name" placeholder="请输入姓名" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="负责人" prop="ownerUserId">
+            <el-select
+              v-model="formData.ownerUserId"
+              :disabled="formType !== 'create'"
+              class="w-1/1"
+            >
+              <el-option
+                v-for="item in userOptions"
+                :key="item.id"
+                :label="item.nickname"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="客户名称" prop="customerId">
+            <el-select
+              :disabled="formData.customerDefault"
+              v-model="formData.customerId"
+              placeholder="请选择客户"
+              class="w-1/1"
+            >
+              <el-option
+                v-for="item in customerList"
+                :key="item.id"
+                :label="item.name"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="手机" prop="mobile">
+            <el-input v-model="formData.mobile" placeholder="请输入手机" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="电话" prop="telephone">
+            <el-input v-model="formData.telephone" placeholder="请输入电话" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="邮箱" prop="email">
+            <el-input v-model="formData.email" placeholder="请输入邮箱" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="微信" prop="wechat">
+            <el-input v-model="formData.wechat" placeholder="请输入微信" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="QQ" prop="qq">
+            <el-input v-model="formData.qq" placeholder="请输入 QQ" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="职位" prop="post">
+            <el-input v-model="formData.post" placeholder="请输入职位" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="关键决策人" prop="master" style="width: 400px">
+            <el-radio-group v-model="formData.master">
+              <el-radio
+                v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+                :key="dict.value"
+                :label="dict.value"
+              >
+                {{ dict.label }}
+              </el-radio>
+            </el-radio-group>
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="性别" prop="sex">
+            <el-select v-model="formData.sex" placeholder="请选择" class="w-1/1">
+              <el-option
+                v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="直属上级" prop="parentId">
+            <el-select v-model="formData.parentId" placeholder="请选择直属上级" class="w-1/1">
+              <el-option
+                v-for="item in contactList"
+                :key="item.id"
+                :disabled="item.id == formData.id"
+                :label="item.name"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="地址" prop="areaId">
+            <el-cascader
+              v-model="formData.areaId"
+              :options="areaList"
+              :props="defaultProps"
+              class="w-1/1"
+              clearable
+              filterable
+              placeholder="请选择城市"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="详细地址" prop="detailAddress">
+            <el-input v-model="formData.detailAddress" placeholder="请输入详细地址" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="下次联系时间" prop="contactNextTime">
+            <el-date-picker
+              v-model="formData.contactNextTime"
+              placeholder="选择下次联系时间"
+              type="datetime"
+              value-format="x"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="备注" prop="remark">
+            <el-input type="textarea" v-model="formData.remark" placeholder="请输入备注" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as ContactApi from '@/api/crm/contact'
+import { DICT_TYPE, getBoolDictOptions, getIntDictOptions } from '@/utils/dict'
+import * as UserApi from '@/api/system/user'
+import * as CustomerApi from '@/api/crm/customer'
+import * as AreaApi from '@/api/system/area'
+import { defaultProps } from '@/utils/tree'
+import { useUserStore } from '@/store/modules/user'
+
+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 areaList = ref([]) // 地区列表
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  customerId: undefined,
+  contactNextTime: undefined,
+  ownerUserId: 0,
+  mobile: undefined,
+  telephone: undefined,
+  qq: undefined,
+  wechat: undefined,
+  email: undefined,
+  areaId: undefined,
+  detailAddress: undefined,
+  sex: undefined,
+  master: false,
+  post: undefined,
+  parentId: undefined,
+  remark: undefined,
+  businessId: undefined,
+  customerDefault: false
+})
+const formRules = reactive({
+  name: [{ required: true, message: '姓名不能为空', trigger: 'blur' }],
+  customerId: [{ required: true, message: '客户不能为空', trigger: 'blur' }],
+  ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
+const customerList = ref<CustomerApi.CustomerVO[]>([]) // 客户列表
+const contactList = ref<ContactApi.ContactVO[]>([]) // 联系人列表
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number, customerId?: number, businessId?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ContactApi.getContact(id)
+    } finally {
+      formLoading.value = false
+    }
+  } else {
+    if (customerId) {
+      formData.value.customerId = customerId
+      formData.value.customerDefault = true // 默认客户的选择,不允许变
+    }
+    // 自动关联 businessId 商机编号
+    if (businessId) {
+      formData.value.businessId = businessId
+    }
+  }
+  // 获得联系人列表
+  contactList.value = await ContactApi.getSimpleContactList()
+  // 获得客户列表
+  customerList.value = await CustomerApi.getCustomerSimpleList()
+  // 获得地区列表
+  areaList.value = await AreaApi.getAreaTree()
+  // 获得用户列表
+  userOptions.value = await UserApi.getSimpleUserList()
+  // 默认新建时选中自己
+  if (formType.value === 'create') {
+    formData.value.ownerUserId = useUserStore().getUser.id
+  }
+}
+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 ContactApi.ContactVO
+    if (formType.value === 'create') {
+      await ContactApi.createContact(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ContactApi.updateContact(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    customerId: undefined,
+    contactNextTime: undefined,
+    ownerUserId: 0,
+    mobile: undefined,
+    telephone: undefined,
+    qq: undefined,
+    wechat: undefined,
+    email: undefined,
+    areaId: undefined,
+    detailAddress: undefined,
+    sex: undefined,
+    master: false,
+    post: undefined,
+    parentId: undefined,
+    remark: undefined,
+    businessId: undefined,
+    customerDefault: false
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 185 - 0
src/views/crm/contact/components/ContactList.vue

@@ -0,0 +1,185 @@
+<template>
+  <!-- 操作栏 -->
+  <el-row justify="end">
+    <el-button @click="openForm">
+      <Icon class="mr-5px" icon="system-uicons:contacts" />
+      创建联系人
+    </el-button>
+    <el-button
+      v-if="queryParams.businessId"
+      v-hasPermi="['crm:contact:create-business']"
+      @click="openBusinessModal"
+    >
+      <Icon class="mr-5px" icon="ep:circle-plus" />
+      关联
+    </el-button>
+    <el-button
+      v-if="queryParams.businessId"
+      v-hasPermi="['crm:contact:delete-business']"
+      @click="deleteContactBusinessList"
+    >
+      <Icon class="mr-5px" icon="ep:remove" />
+      解除关联
+    </el-button>
+  </el-row>
+
+  <!-- 列表 -->
+  <ContentWrap class="mt-10px">
+    <el-table
+      ref="contactRef"
+      v-loading="loading"
+      :data="list"
+      :show-overflow-tooltip="true"
+      :stripe="true"
+    >
+      <el-table-column v-if="queryParams.businessId" type="selection" width="55" />
+      <el-table-column align="center" fixed="left" label="姓名" prop="name">
+        <template #default="scope">
+          <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)">
+            {{ scope.row.name }}
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="手机号" prop="mobile" />
+      <el-table-column align="center" label="职位" prop="post" />
+      <el-table-column align="center" label="直属上级" prop="parentName" />
+      <el-table-column align="center" label="是否关键决策人" min-width="100" prop="master">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.master" />
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加 -->
+  <ContactForm ref="formRef" @success="getList" />
+  <!-- 关联商机选择弹框 -->
+  <ContactListModal
+    v-if="customerId"
+    ref="contactModalRef"
+    :customer-id="customerId"
+    @success="createContactBusinessList"
+  />
+</template>
+<script lang="ts" setup>
+import * as ContactApi from '@/api/crm/contact'
+import ContactForm from './../ContactForm.vue'
+import { DICT_TYPE } from '@/utils/dict'
+import { BizTypeEnum } from '@/api/crm/permission'
+import ContactListModal from './ContactListModal.vue'
+
+defineOptions({ name: 'CrmContactList' })
+const props = defineProps<{
+  bizType: number // 业务类型
+  bizId: number // 业务编号
+  customerId?: number // 特殊:客户编号;在【商机】详情中,可以传递客户编号,默认新建的联系人关联到该客户
+  businessId?: number // 特殊:商机编号;在【商机】详情中,可以传递商机编号,默认新建的联系人关联到该商机
+}>()
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  customerId: undefined as unknown, // 允许 undefined + number
+  businessId: undefined as unknown // 允许 undefined + number
+})
+const message = useMessage()
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    // 置空参数
+    queryParams.customerId = undefined
+    // 执行查询
+    let data = { list: [], total: 0 }
+    switch (props.bizType) {
+      case BizTypeEnum.CRM_CUSTOMER:
+        queryParams.customerId = props.bizId
+        data = await ContactApi.getContactPageByCustomer(queryParams)
+        break
+      case BizTypeEnum.CRM_BUSINESS:
+        queryParams.businessId = props.bizId
+        data = await ContactApi.getContactPageByBusiness(queryParams)
+        break
+      default:
+        return
+    }
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 添加操作 */
+const formRef = ref()
+const openForm = () => {
+  formRef.value.open('create', undefined, props.customerId, props.businessId)
+}
+
+/** 打开联系人详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'CrmContactDetail', params: { id } })
+}
+
+/** 打开联系人与商机的关联弹窗 */
+const contactModalRef = ref()
+const openBusinessModal = () => {
+  contactModalRef.value.open()
+}
+const createContactBusinessList = async (contactIds: number[]) => {
+  const data = {
+    businessId: props.bizId,
+    contactIds: contactIds
+  } as ContactApi.ContactBusiness2ReqVO
+  contactRef.value.getSelectionRows().forEach((row: ContactApi.ContactVO) => {
+    data.contactIds.push(row.id)
+  })
+  await ContactApi.createContactBusinessList2(data)
+  // 刷新列表
+  message.success('关联联系人成功')
+  handleQuery()
+}
+
+/** 解除联系人与商机的关联 */
+const contactRef = ref()
+const deleteContactBusinessList = async () => {
+  const data = {
+    businessId: props.bizId,
+    contactIds: contactRef.value.getSelectionRows().map((row: ContactApi.ContactVO) => row.id)
+  } as ContactApi.ContactBusiness2ReqVO
+  if (data.contactIds.length === 0) {
+    return message.error('未选择联系人')
+  }
+  await ContactApi.deleteContactBusinessList2(data)
+  // 刷新列表
+  message.success('取关联系人成功')
+  handleQuery()
+}
+
+/** 监听打开的 bizId + bizType,从而加载最新的列表 */
+watch(
+  () => [props.bizId, props.bizType],
+  () => {
+    handleQuery()
+  },
+  { immediate: true, deep: true }
+)
+</script>

+ 160 - 0
src/views/crm/contact/components/ContactListModal.vue

@@ -0,0 +1,160 @@
+<template>
+  <Dialog v-model="dialogVisible" title="关联联系人">
+    <!-- 搜索工作栏 -->
+    <ContentWrap>
+      <el-form
+        ref="queryFormRef"
+        :inline="true"
+        :model="queryParams"
+        class="-mb-15px"
+        label-width="90px"
+      >
+        <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>
+          <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="['crm:business:create']" type="primary" @click="openForm()">
+            <Icon class="mr-5px" icon="ep:plus" />
+            新增
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </ContentWrap>
+
+    <!-- 列表 -->
+    <ContentWrap class="mt-10px">
+      <el-table
+        ref="contactRef"
+        v-loading="loading"
+        :data="list"
+        :show-overflow-tooltip="true"
+        :stripe="true"
+      >
+        <el-table-column type="selection" width="55" />
+        <el-table-column align="center" fixed="left" label="姓名" prop="name">
+          <template #default="scope">
+            <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)">
+              {{ scope.row.name }}
+            </el-link>
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="手机号" prop="mobile" />
+        <el-table-column align="center" label="职位" prop="post" />
+        <el-table-column align="center" label="直属上级" prop="parentName" />
+        <el-table-column align="center" label="是否关键决策人" min-width="100" prop="master">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.master" />
+          </template>
+        </el-table-column>
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
+        @pagination="getList"
+      />
+    </ContentWrap>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+
+    <!-- 表单弹窗:添加 -->
+    <ContactForm ref="formRef" @success="getList" />
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as ContactApi from '@/api/crm/contact'
+import ContactForm from '../ContactForm.vue'
+import { DICT_TYPE } from '@/utils/dict'
+
+const message = useMessage() // 消息弹窗
+const props = defineProps<{
+  customerId: number
+}>()
+defineOptions({ name: 'ContactListModal' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryFormRef = ref() // 搜索的表单
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  customerId: props.customerId
+})
+
+/** 打开弹窗 */
+const open = async () => {
+  dialogVisible.value = true
+  queryParams.customerId = props.customerId // 解决 props.customerId 没更新到 queryParams 上的问题
+  await getList()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ContactApi.getContactPageByCustomer(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 = () => {
+  formRef.value.open('create')
+}
+
+/** 关联联系人提交 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const contactRef = ref()
+const submitForm = async () => {
+  const contactIds = contactRef.value.getSelectionRows().map((row: ContactApi.ContactVO) => row.id)
+  if (contactIds.length === 0) {
+    return message.error('未选择联系人')
+  }
+  dialogVisible.value = false
+  emit('success', contactIds, contactRef.value.getSelectionRows())
+}
+
+/** 打开联系人详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'CrmContactDetail', params: { id } })
+}
+</script>

+ 33 - 0
src/views/crm/contact/detail/ContactDetailsHeader.vue

@@ -0,0 +1,33 @@
+<template>
+  <div>
+    <div class="flex items-start justify-between">
+      <div>
+        <el-col>
+          <el-row>
+            <span class="text-xl font-bold">{{ contact.name }}</span>
+          </el-row>
+        </el-col>
+      </div>
+      <div>
+        <!-- 右上:按钮 -->
+        <slot></slot>
+      </div>
+    </div>
+  </div>
+  <ContentWrap class="mt-10px">
+    <el-descriptions :column="5" direction="vertical">
+      <el-descriptions-item label="客户名称">{{ contact.customerName }}</el-descriptions-item>
+      <el-descriptions-item label="职务">{{ contact.post }}</el-descriptions-item>
+      <el-descriptions-item label="手机">{{ contact.mobile }}</el-descriptions-item>
+      <el-descriptions-item label="创建时间">
+        {{ formatDate(contact.createTime) }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </ContentWrap>
+</template>
+<script lang="ts" setup>
+import * as ContactApi from '@/api/crm/contact'
+import { formatDate } from '@/utils/formatTime'
+
+const { contact } = defineProps<{ contact: ContactApi.ContactVO }>()
+</script>

+ 69 - 0
src/views/crm/contact/detail/ContactDetailsInfo.vue

@@ -0,0 +1,69 @@
+<template>
+  <ContentWrap>
+    <el-collapse v-model="activeNames">
+      <el-collapse-item name="basicInfo">
+        <template #title>
+          <span class="text-base font-bold">基本信息</span>
+        </template>
+        <el-descriptions :column="4">
+          <el-descriptions-item label="姓名">{{ contact.name }}</el-descriptions-item>
+          <el-descriptions-item label="客户名称">{{ contact.customerName }}</el-descriptions-item>
+          <el-descriptions-item label="手机">{{ contact.mobile }}</el-descriptions-item>
+          <el-descriptions-item label="电话">{{ contact.telephone }}</el-descriptions-item>
+          <el-descriptions-item label="邮箱">{{ contact.email }}</el-descriptions-item>
+          <el-descriptions-item label="QQ">{{ contact.qq }}</el-descriptions-item>
+          <el-descriptions-item label="微信">{{ contact.wechat }}</el-descriptions-item>
+          <el-descriptions-item label="地址">
+            {{ contact.areaName }} {{ contact.detailAddress }}
+          </el-descriptions-item>
+          <el-descriptions-item label="职务">{{ contact.post }}</el-descriptions-item>
+          <el-descriptions-item label="直属上级">{{ contact.parentName }}</el-descriptions-item>
+          <el-descriptions-item label="关键决策人">
+            <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="contact.master" />
+          </el-descriptions-item>
+          <el-descriptions-item label="性别">
+            <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="contact.sex" />
+          </el-descriptions-item>
+          <el-descriptions-item label="下次联系时间">
+            {{ formatDate(contact.contactNextTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="备注">{{ contact.remark }}</el-descriptions-item>
+        </el-descriptions>
+      </el-collapse-item>
+      <el-collapse-item name="systemInfo">
+        <template #title>
+          <span class="text-base font-bold">系统信息</span>
+        </template>
+        <el-descriptions :column="4">
+          <el-descriptions-item label="负责人">{{ contact.ownerUserName }}</el-descriptions-item>
+          <el-descriptions-item label="最后跟进记录">
+            {{ contact.contactLastContent }}
+          </el-descriptions-item>
+          <el-descriptions-item label="最后跟进时间">
+            {{ formatDate(contact.contactLastTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="">&nbsp;</el-descriptions-item>
+          <el-descriptions-item label="创建人">{{ contact.creatorName }}</el-descriptions-item>
+          <el-descriptions-item label="创建时间">
+            {{ formatDate(contact.createTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="更新时间">
+            {{ formatDate(contact.updateTime) }}
+          </el-descriptions-item>
+        </el-descriptions>
+      </el-collapse-item>
+    </el-collapse>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import * as ContactApi from '@/api/crm/contact'
+import { DICT_TYPE } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+
+const { contact } = defineProps<{
+  contact: ContactApi.ContactVO
+}>()
+
+// 展示的折叠面板
+const activeNames = ref(['basicInfo', 'systemInfo'])
+</script>

+ 98 - 0
src/views/crm/statistics/rank/components/ContactCountRank.vue

@@ -0,0 +1,98 @@
+<!-- 新增联系人数排行 -->
+<template>
+  <!-- 柱状图 -->
+  <el-card shadow="never">
+    <el-skeleton :loading="loading" animated>
+      <Echart :height="500" :options="echartsOption" />
+    </el-skeleton>
+  </el-card>
+
+  <!-- 排行列表 -->
+  <el-card shadow="never" class="mt-16px">
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="公司排名" align="center" type="index" width="80" />
+      <el-table-column label="创建人" align="center" prop="nickname" min-width="200" />
+      <el-table-column label="部门" align="center" prop="deptName" min-width="200" />
+      <el-table-column label="新增联系人数(个)" align="center" prop="count" min-width="200" />
+    </el-table>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/rank'
+import { EChartsOption } from 'echarts'
+import { clone } from 'lodash-es'
+
+defineOptions({ name: 'ContactCountRank' })
+const props = defineProps<{ queryParams: any }>() // 搜索参数
+
+const loading = ref(false) // 加载中
+const list = ref<StatisticsRankRespVO[]>([]) // 列表的数据
+
+/** 柱状图配置:横向 */
+const echartsOption = reactive<EChartsOption>({
+  dataset: {
+    dimensions: ['nickname', 'count'],
+    source: []
+  },
+  grid: {
+    left: 20,
+    right: 20,
+    bottom: 20,
+    containLabel: true
+  },
+  legend: {
+    top: 50
+  },
+  series: [
+    {
+      name: '新增联系人数排行',
+      type: 'bar'
+    }
+  ],
+  toolbox: {
+    feature: {
+      dataZoom: {
+        yAxisIndex: false // 数据区域缩放:Y 轴不缩放
+      },
+      brush: {
+        type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮
+      },
+      saveAsImage: { show: true, name: '新增联系人数排行' } // 保存为图片
+    }
+  },
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: {
+      type: 'shadow'
+    }
+  },
+  xAxis: {
+    type: 'value',
+    name: '新增联系人数(个)'
+  },
+  yAxis: {
+    type: 'category',
+    name: '创建人'
+  }
+}) as EChartsOption
+
+/** 获取新增联系人数排行 */
+const loadData = async () => {
+  // 1. 加载排行数据
+  loading.value = true
+  const rankingList = await StatisticsRankApi.getContactsCountRank(props.queryParams)
+  // 2.1 更新 Echarts 数据
+  if (echartsOption.dataset && echartsOption.dataset['source']) {
+    echartsOption.dataset['source'] = clone(rankingList).reverse()
+  }
+  // 2.2 更新列表数据
+  list.value = rankingList
+  loading.value = false
+}
+defineExpose({ loadData })
+
+/** 初始化 */
+onMounted(() => {
+  loadData()
+})
+</script>

+ 153 - 0
src/views/infra/codegen/components/ColumInfoForm.vue

@@ -0,0 +1,153 @@
+<template>
+  <el-table ref="dragTable" :data="formData" :max-height="tableHeight" row-key="columnId">
+    <el-table-column
+      :show-overflow-tooltip="true"
+      label="字段列名"
+      min-width="10%"
+      prop="columnName"
+    />
+    <el-table-column label="字段描述" min-width="10%">
+      <template #default="scope">
+        <el-input v-model="scope.row.columnComment" />
+      </template>
+    </el-table-column>
+    <el-table-column
+      :show-overflow-tooltip="true"
+      label="物理类型"
+      min-width="10%"
+      prop="dataType"
+    />
+    <el-table-column label="Java类型" min-width="11%">
+      <template #default="scope">
+        <el-select v-model="scope.row.javaType">
+          <el-option label="Long" value="Long" />
+          <el-option label="String" value="String" />
+          <el-option label="Integer" value="Integer" />
+          <el-option label="Double" value="Double" />
+          <el-option label="BigDecimal" value="BigDecimal" />
+          <el-option label="LocalDateTime" value="LocalDateTime" />
+          <el-option label="Boolean" value="Boolean" />
+        </el-select>
+      </template>
+    </el-table-column>
+    <el-table-column label="java属性" min-width="10%">
+      <template #default="scope">
+        <el-input v-model="scope.row.javaField" />
+      </template>
+    </el-table-column>
+    <el-table-column label="插入" min-width="4%">
+      <template #default="scope">
+        <el-checkbox v-model="scope.row.createOperation" false-label="false" true-label="true" />
+      </template>
+    </el-table-column>
+    <el-table-column label="编辑" min-width="4%">
+      <template #default="scope">
+        <el-checkbox v-model="scope.row.updateOperation" false-label="false" true-label="true" />
+      </template>
+    </el-table-column>
+    <el-table-column label="列表" min-width="4%">
+      <template #default="scope">
+        <el-checkbox
+          v-model="scope.row.listOperationResult"
+          false-label="false"
+          true-label="true"
+        />
+      </template>
+    </el-table-column>
+    <el-table-column label="查询" min-width="4%">
+      <template #default="scope">
+        <el-checkbox v-model="scope.row.listOperation" false-label="false" true-label="true" />
+      </template>
+    </el-table-column>
+    <el-table-column label="查询方式" min-width="10%">
+      <template #default="scope">
+        <el-select v-model="scope.row.listOperationCondition">
+          <el-option label="=" value="=" />
+          <el-option label="!=" value="!=" />
+          <el-option label=">" value=">" />
+          <el-option label=">=" value=">=" />
+          <el-option label="<" value="<>" />
+          <el-option label="<=" value="<=" />
+          <el-option label="LIKE" value="LIKE" />
+          <el-option label="BETWEEN" value="BETWEEN" />
+        </el-select>
+      </template>
+    </el-table-column>
+    <el-table-column label="允许空" min-width="5%">
+      <template #default="scope">
+        <el-checkbox v-model="scope.row.nullable" false-label="false" true-label="true" />
+      </template>
+    </el-table-column>
+    <el-table-column label="显示类型" min-width="12%">
+      <template #default="scope">
+        <el-select v-model="scope.row.htmlType">
+          <el-option label="文本框" value="input" />
+          <el-option label="文本域" value="textarea" />
+          <el-option label="下拉框" value="select" />
+          <el-option label="单选框" value="radio" />
+          <el-option label="复选框" value="checkbox" />
+          <el-option label="日期控件" value="datetime" />
+          <el-option label="图片上传" value="imageUpload" />
+          <el-option label="文件上传" value="fileUpload" />
+          <el-option label="富文本控件" value="editor" />
+        </el-select>
+      </template>
+    </el-table-column>
+    <el-table-column label="字典类型" min-width="12%">
+      <template #default="scope">
+        <el-select v-model="scope.row.dictType" clearable filterable placeholder="请选择">
+          <el-option
+            v-for="dict in dictOptions"
+            :key="dict.id"
+            :label="dict.name"
+            :value="dict.type"
+          />
+        </el-select>
+      </template>
+    </el-table-column>
+    <el-table-column label="示例" min-width="10%">
+      <template #default="scope">
+        <el-input v-model="scope.row.example" />
+      </template>
+    </el-table-column>
+  </el-table>
+</template>
+<script lang="ts" setup>
+import { PropType } from 'vue'
+import * as CodegenApi from '@/api/infra/codegen'
+import * as DictDataApi from '@/api/system/dict/dict.type'
+
+defineOptions({ name: 'InfraCodegenColumInfoForm' })
+
+const props = defineProps({
+  columns: {
+    type: Array as unknown as PropType<CodegenApi.CodegenColumnVO[]>,
+    default: () => null
+  }
+})
+
+const formData = ref<CodegenApi.CodegenColumnVO[]>([])
+const tableHeight = document.documentElement.scrollHeight - 350 + 'px'
+
+/** 查询字典下拉列表 */
+const dictOptions = ref<DictDataApi.DictTypeVO[]>()
+const getDictOptions = async () => {
+  dictOptions.value = await DictDataApi.getSimpleDictTypeList()
+}
+
+watch(
+  () => props.columns,
+  (columns) => {
+    if (!columns) return
+    formData.value = columns
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+onMounted(async () => {
+  await getDictOptions()
+})
+</script>

+ 131 - 0
src/views/infra/config/ConfigForm.vue

@@ -0,0 +1,131 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="80px"
+    >
+      <el-form-item label="参数分类" prop="category">
+        <el-input v-model="formData.category" placeholder="请输入参数分类" />
+      </el-form-item>
+      <el-form-item label="参数名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入参数名称" />
+      </el-form-item>
+      <el-form-item label="参数键名" prop="key">
+        <el-input v-model="formData.key" placeholder="请输入参数键名" />
+      </el-form-item>
+      <el-form-item label="参数键值" prop="value">
+        <el-input v-model="formData.value" placeholder="请输入参数键值" />
+      </el-form-item>
+      <el-form-item label="是否可见" prop="visible">
+        <el-radio-group v-model="formData.visible">
+          <el-radio
+            v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+            :key="dict.value as string"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="备注" prop="remark">
+        <el-input v-model="formData.remark" placeholder="请输入内容" type="textarea" />
+      </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, getBoolDictOptions } from '@/utils/dict'
+import * as ConfigApi from '@/api/infra/config'
+
+defineOptions({ name: 'InfraConfigForm' })
+
+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,
+  category: '',
+  name: '',
+  key: '',
+  value: '',
+  visible: true,
+  remark: ''
+})
+const formRules = reactive({
+  category: [{ required: true, message: '参数分类不能为空', trigger: 'blur' }],
+  name: [{ required: true, message: '参数名称不能为空', trigger: 'blur' }],
+  key: [{ required: true, message: '参数键名不能为空', trigger: 'blur' }],
+  value: [{ required: true, message: '参数键值不能为空', trigger: 'blur' }],
+  visible: [{ 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 ConfigApi.getConfig(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 ConfigApi.ConfigVO
+    if (formType.value === 'create') {
+      await ConfigApi.createConfig(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ConfigApi.updateConfig(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    category: '',
+    name: '',
+    key: '',
+    value: '',
+    visible: true,
+    remark: ''
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 42 - 0
src/views/mall/home/components/ComparisonCard.vue

@@ -0,0 +1,42 @@
+<template>
+  <div class="flex flex-col gap-2 bg-[var(--el-bg-color-overlay)] p-6">
+    <div class="flex items-center justify-between text-gray-500">
+      <span>{{ title }}</span>
+      <el-tag>{{ tag }}</el-tag>
+    </div>
+    <div class="flex flex-row items-baseline justify-between">
+      <CountTo :prefix="prefix" :end-val="value" :decimals="decimals" class="text-3xl" />
+      <span :class="toNumber(percent) > 0 ? 'text-red-500' : 'text-green-500'">
+        {{ Math.abs(toNumber(percent)) }}%
+        <Icon :icon="toNumber(percent) > 0 ? 'ep:caret-top' : 'ep:caret-bottom'" class="!text-sm" />
+      </span>
+    </div>
+    <el-divider class="mb-1! mt-2!" />
+    <div class="flex flex-row items-center justify-between text-sm">
+      <span class="text-gray-500">昨日数据</span>
+      <span>{{ prefix || '' }}{{ reference }}</span>
+    </div>
+  </div>
+</template>
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import { toNumber } from 'lodash-es'
+import { calculateRelativeRate } from '@/utils'
+
+/** 交易对照卡片 */
+defineOptions({ name: 'ComparisonCard' })
+
+const props = defineProps({
+  title: propTypes.string.def('').isRequired,
+  tag: propTypes.string.def(''),
+  prefix: propTypes.string.def(''),
+  value: propTypes.number.def(0).isRequired,
+  reference: propTypes.number.def(0).isRequired,
+  decimals: propTypes.number.def(0)
+})
+
+// 计算环比
+const percent = computed(() =>
+  calculateRelativeRate(props.value as number, props.reference as number)
+)
+</script>

+ 167 - 0
src/views/mall/product/comment/CommentForm.vue

@@ -0,0 +1,167 @@
+<template>
+  <Dialog v-model="dialogVisible" title="添加虚拟评论">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+    >
+      <el-form-item label="商品" prop="spuId">
+        <SpuShowcase v-model="formData.spuId" :limit="1" />
+      </el-form-item>
+      <el-form-item v-if="formData.spuId" label="商品规格" prop="skuId">
+        <div class="h-60px w-60px" @click="handleSelectSku">
+          <div v-if="skuData && skuData.picUrl">
+            <el-image :src="skuData.picUrl" />
+          </div>
+          <div v-else class="select-box">
+            <Icon icon="ep:plus" />
+          </div>
+        </div>
+      </el-form-item>
+      <el-form-item label="用户头像" prop="userAvatar">
+        <UploadImg v-model="formData.userAvatar" height="60px" width="60px" />
+      </el-form-item>
+      <el-form-item label="用户名称" prop="userNickname">
+        <el-input v-model="formData.userNickname" placeholder="请输入用户名称" />
+      </el-form-item>
+      <el-form-item label="评论内容" prop="content">
+        <el-input v-model="formData.content" type="textarea" />
+      </el-form-item>
+      <el-form-item label="描述星级" prop="descriptionScores">
+        <el-rate v-model="formData.descriptionScores" />
+      </el-form-item>
+      <el-form-item label="服务星级" prop="benefitScores">
+        <el-rate v-model="formData.benefitScores" />
+      </el-form-item>
+      <el-form-item label="评论图片" prop="picUrls">
+        <UploadImgs v-model="formData.picUrls" :limit="9" height="60px" width="60px" />
+      </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>
+  <SkuTableSelect ref="skuTableSelectRef" :spu-id="formData.spuId" @change="handleSkuChange" />
+</template>
+<script lang="ts" setup>
+import * as CommentApi from '@/api/mall/product/comment'
+import SpuShowcase from '@/views/mall/product/spu/components/SpuShowcase.vue'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import SkuTableSelect from '@/views/mall/product/spu/components/SkuTableSelect.vue'
+
+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,
+  userId: undefined,
+  userNickname: undefined,
+  userAvatar: undefined,
+  spuId: 0,
+  skuId: undefined,
+  descriptionScores: 5,
+  benefitScores: 5,
+  content: undefined,
+  picUrls: []
+})
+const formRules = reactive({
+  spuId: [{ required: true, message: '商品不能为空', trigger: 'blur' }],
+  skuId: [{ required: true, message: '规格不能为空', trigger: 'blur' }],
+  userAvatar: [{ required: true, message: '用户头像不能为空', trigger: 'blur' }],
+  userNickname: [{ required: true, message: '用户名称不能为空', trigger: 'blur' }],
+  content: [{ required: true, message: '评论内容不能为空', trigger: 'blur' }],
+  descriptionScores: [{ required: true, message: '描述星级不能为空', trigger: 'blur' }],
+  benefitScores: [{ required: true, message: '服务星级不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const skuData = ref({
+  id: -1,
+  name: '',
+  picUrl: ''
+})
+
+/** 打开弹窗 */
+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 CommentApi.getComment(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 {
+    if (formType.value === 'create') {
+      await CommentApi.createComment(unref(formData.value) as any)
+      message.success(t('common.createSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    userId: undefined,
+    userNickname: undefined,
+    userAvatar: undefined,
+    spuId: 0,
+    skuId: undefined,
+    descriptionScores: 5,
+    benefitScores: 5,
+    content: undefined,
+    picUrls: []
+  }
+  formRef.value?.resetFields()
+}
+
+/** SKU 表格选择 */
+const skuTableSelectRef = ref()
+const handleSelectSku = () => {
+  skuTableSelectRef.value.open()
+}
+const handleSkuChange = (sku: ProductSpuApi.Sku) => {
+  skuData.value = sku
+  formData.value.skuId = sku.id
+}
+</script>
+<style>
+.select-box {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  border: 1px dashed var(--el-border-color-darker);
+  border-radius: 8px;
+  align-items: center;
+  justify-content: center;
+}
+</style>

+ 187 - 0
src/views/mall/promotion/combination/activity/CombinationActivityForm.vue

@@ -0,0 +1,187 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle" width="65%">
+    <Form
+      ref="formRef"
+      v-loading="formLoading"
+      :is-col="true"
+      :rules="rules"
+      :schema="allSchemas.formSchema"
+      class="mt-10px"
+    >
+      <template #spuId>
+        <el-button @click="spuSelectRef.open()">选择商品</el-button>
+        <SpuAndSkuList
+          ref="spuAndSkuListRef"
+          :rule-config="ruleConfig"
+          :spu-list="spuList"
+          :spu-property-list-p="spuPropertyList"
+        >
+          <el-table-column align="center" label="拼团价格(元)" min-width="168">
+            <template #default="{ row: sku }">
+              <el-input-number
+                v-model="sku.productConfig.combinationPrice"
+                :min="0"
+                :precision="2"
+                :step="0.1"
+                class="w-100%"
+              />
+            </template>
+          </el-table-column>
+        </SpuAndSkuList>
+      </template>
+    </Form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+  <SpuSelect ref="spuSelectRef" :isSelectSku="true" @confirm="selectSpu" />
+</template>
+<script lang="ts" setup>
+import * as CombinationActivityApi from '@/api/mall/promotion/combination/combinationActivity'
+import { CombinationProductVO } from '@/api/mall/promotion/combination/combinationActivity'
+import { allSchemas, rules } from './combinationActivity.data'
+import { SpuAndSkuList, SpuProperty, SpuSelect } from '@/views/mall/promotion/components'
+import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import { convertToInteger, formatToFraction } from '@/utils'
+import { cloneDeep } from 'lodash-es'
+
+defineOptions({ name: 'PromotionCombinationActivityForm' })
+
+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 formRef = ref() // 表单 Ref
+
+// ================= 商品选择相关 =================
+
+const spuSelectRef = ref() // 商品和属性选择 Ref
+const spuAndSkuListRef = ref() // sku 秒杀配置组件Ref
+const spuList = ref<CombinationActivityApi.SpuExtension[]>([]) // 选择的 spu
+const spuPropertyList = ref<SpuProperty<CombinationActivityApi.SpuExtension>[]>([])
+const ruleConfig: RuleConfig[] = [
+  {
+    name: 'productConfig.combinationPrice',
+    rule: (arg) => arg >= 0.01,
+    message: '商品拼团价格不能小于0.01 !!!'
+  }
+]
+const selectSpu = (spuId: number, skuIds: number[]) => {
+  formRef.value.setValues({ spuId })
+  getSpuDetails(spuId, skuIds)
+}
+/**
+ * 获取 SPU 详情
+ */
+const getSpuDetails = async (
+  spuId: number,
+  skuIds: number[] | undefined,
+  products?: CombinationProductVO[]
+) => {
+  const spuProperties: SpuProperty<CombinationActivityApi.SpuExtension>[] = []
+  const res = (await ProductSpuApi.getSpuDetailList([
+    spuId
+  ])) as CombinationActivityApi.SpuExtension[]
+  if (res.length == 0) {
+    return
+  }
+  spuList.value = []
+  // 因为只能选择一个
+  const spu = res[0]
+  const selectSkus =
+    typeof skuIds === 'undefined' ? spu?.skus : spu?.skus?.filter((sku) => skuIds.includes(sku.id!))
+  selectSkus?.forEach((sku) => {
+    let config: CombinationProductVO = {
+      spuId: spu.id!,
+      skuId: sku.id!,
+      combinationPrice: 0
+    }
+    if (typeof products !== 'undefined') {
+      const product = products.find((item) => item.skuId === sku.id)
+      if (product) {
+        product.combinationPrice = formatToFraction(product.combinationPrice)
+      }
+      config = product || config
+    }
+    sku.productConfig = config
+  })
+  spu.skus = selectSkus as CombinationActivityApi.SkuExtension[]
+  spuProperties.push({
+    spuId: spu.id!,
+    spuDetail: spu,
+    propertyList: getPropertyList(spu)
+  })
+  spuList.value.push(spu)
+  spuPropertyList.value = spuProperties
+}
+
+// ================= end =================
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  await resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      const data = (await CombinationActivityApi.getCombinationActivity(
+        id
+      )) as CombinationActivityApi.CombinationActivityVO
+      await getSpuDetails(data.spuId!, data.products?.map((sku) => sku.skuId), data.products)
+      formRef.value.setValues(data)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 重置表单 */
+const resetForm = async () => {
+  spuList.value = []
+  spuPropertyList.value = []
+  await nextTick()
+  formRef.value.getElFormRef().resetFields()
+}
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.getElFormRef().validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    // 获得拼团商品配置
+    const products = cloneDeep(spuAndSkuListRef.value.getSkuConfigs('productConfig'))
+    products.forEach((item: CombinationActivityApi.CombinationProductVO) => {
+      item.combinationPrice = convertToInteger(item.combinationPrice)
+    })
+    const data = cloneDeep(formRef.value.formModel) as CombinationActivityApi.CombinationActivityVO
+    data.products = products
+    // 真正提交
+    if (formType.value === 'create') {
+      await CombinationActivityApi.createCombinationActivity(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await CombinationActivityApi.updateCombinationActivity(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+</script>

+ 140 - 0
src/views/mall/promotion/combination/activity/combinationActivity.data.ts

@@ -0,0 +1,140 @@
+import type { CrudSchema } from '@/hooks/web/useCrudSchemas'
+import { dateFormatter2 } from '@/utils/formatTime'
+
+// 表单校验
+export const rules = reactive({
+  name: [required],
+  totalLimitCount: [required],
+  singleLimitCount: [required],
+  startTime: [required],
+  endTime: [required],
+  userSize: [required],
+  limitDuration: [required],
+  virtualGroup: [required]
+})
+
+// CrudSchema https://doc.iocoder.cn/vue3/crud-schema/
+const crudSchemas = reactive<CrudSchema[]>([
+  {
+    label: '拼团名称',
+    field: 'name',
+    isSearch: true,
+    isTable: false,
+    form: {
+      colProps: {
+        span: 24
+      }
+    }
+  },
+  {
+    label: '活动开始时间',
+    field: 'startTime',
+    formatter: dateFormatter2,
+    isSearch: true,
+    search: {
+      component: 'DatePicker',
+      componentProps: {
+        valueFormat: 'YYYY-MM-DD',
+        type: 'daterange'
+      }
+    },
+    form: {
+      component: 'DatePicker',
+      componentProps: {
+        type: 'date',
+        valueFormat: 'x'
+      }
+    },
+    table: {
+      width: 120
+    }
+  },
+  {
+    label: '活动结束时间',
+    field: 'endTime',
+    formatter: dateFormatter2,
+    isSearch: true,
+    search: {
+      component: 'DatePicker',
+      componentProps: {
+        valueFormat: 'YYYY-MM-DD',
+        type: 'daterange'
+      }
+    },
+    form: {
+      component: 'DatePicker',
+      componentProps: {
+        type: 'date',
+        valueFormat: 'x'
+      }
+    },
+    table: {
+      width: 120
+    }
+  },
+  {
+    label: '参与人数',
+    field: 'userSize',
+    isSearch: false,
+    form: {
+      component: 'InputNumber',
+      labelMessage: '参与人数不能少于两人',
+      value: 2
+    }
+  },
+  {
+    label: '限制时长',
+    field: 'limitDuration',
+    isSearch: false,
+    isTable: false,
+    form: {
+      component: 'InputNumber',
+      labelMessage: '限制时长(小时)',
+      componentProps: {
+        placeholder: '请输入限制时长(小时)'
+      }
+    }
+  },
+  {
+    label: '总限购数量',
+    field: 'totalLimitCount',
+    isSearch: false,
+    isTable: false,
+    form: {
+      component: 'InputNumber',
+      value: 0
+    }
+  },
+  {
+    label: '单次限购数量',
+    field: 'singleLimitCount',
+    isSearch: false,
+    isTable: false,
+    form: {
+      component: 'InputNumber',
+      value: 0
+    }
+  },
+  {
+    label: '虚拟成团',
+    field: 'virtualGroup',
+    dictType: DICT_TYPE.INFRA_BOOLEAN_STRING,
+    dictClass: 'boolean',
+    isSearch: true,
+    form: {
+      component: 'Radio',
+      value: false
+    }
+  },
+  {
+    label: '拼团商品',
+    field: 'spuId',
+    isSearch: false,
+    form: {
+      colProps: {
+        span: 24
+      }
+    }
+  }
+])
+export const { allSchemas } = useCrudSchemas(crudSchemas)

+ 89 - 0
src/views/mall/promotion/combination/record/CombinationRecordListDialog.vue

@@ -0,0 +1,89 @@
+<template>
+  <Dialog v-model="dialogVisible" title="拼团列表" width="950">
+    <!-- 列表 -->
+    <ContentWrap>
+      <el-table v-loading="loading" :data="list">
+        <el-table-column align="center" label="编号" prop="id" min-width="50" />
+        <el-table-column align="center" label="头像" prop="avatar" min-width="80">
+          <template #default="scope">
+            <el-avatar :src="scope.row.avatar" />
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="昵称" prop="nickname" min-width="100" />
+        <el-table-column align="center" label="开团团长" prop="headId" min-width="100">
+          <template #default="{ row }: { row: CombinationRecordApi.CombinationRecordVO }">
+            <el-tag> {{ row.headId === 0 ? '团长' : '团员' }} </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column
+          :formatter="dateFormatter"
+          align="center"
+          label="参团时间"
+          prop="createTime"
+          width="180"
+        />
+        <el-table-column
+          :formatter="dateFormatter"
+          align="center"
+          label="结束时间"
+          prop="endTime"
+          width="180"
+        />
+        <el-table-column align="center" label="拼团状态" prop="status" min-width="150">
+          <template #default="scope">
+            <dict-tag
+              :type="DICT_TYPE.PROMOTION_COMBINATION_RECORD_STATUS"
+              :value="scope.row.status"
+            />
+          </template>
+        </el-table-column>
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
+        @pagination="getList"
+      />
+    </ContentWrap>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { dateFormatter } from '@/utils/formatTime'
+import * as CombinationRecordApi from '@/api/mall/promotion/combination/combinationRecord'
+import { DICT_TYPE } from '@/utils/dict'
+
+/** 助力列表 */
+defineOptions({ name: 'CombinationRecordListDialog' })
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  headId: undefined
+})
+
+/** 打开弹窗 */
+const dialogVisible = ref(false) // 弹窗的是否展示
+const open = async (headId: any) => {
+  dialogVisible.value = true
+  queryParams.headId = headId
+  await getList()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await CombinationRecordApi.getCombinationRecordPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+</script>

+ 17 - 0
src/views/mall/promotion/kefu/components/tools/constants.ts

@@ -0,0 +1,17 @@
+// 客服消息类型枚举类
+export const KeFuMessageContentTypeEnum = {
+  TEXT: 1, // 文本消息
+  IMAGE: 2, // 图片消息
+  VOICE: 3, // 语音消息
+  VIDEO: 4, // 视频消息
+  SYSTEM: 5, // 系统消息
+  // ========== 商城特殊消息 ==========
+  PRODUCT: 10, //  商品消息
+  ORDER: 11 //  订单消息"
+}
+
+// Promotion 的 WebSocket 消息类型枚举类
+export const WebSocketMessageTypeConstants = {
+  KEFU_MESSAGE_TYPE: 'kefu_message_type', // 客服消息类型
+  KEFU_MESSAGE_ADMIN_READ: 'kefu_message_read_status_change' // 客服消息管理员已读
+}

+ 126 - 0
src/views/mp/components/wx-msg/comment.scss

@@ -0,0 +1,126 @@
+/* 来自 https://github.com/nmxiaowei/avue/blob/master/styles/src/element-ui/comment.scss  */
+.avue-comment {
+  margin-bottom: 30px;
+  display: flex;
+  align-items: flex-start;
+
+  &--reverse {
+    flex-direction: row-reverse;
+
+    .avue-comment__main {
+      &:before,
+      &:after {
+        left: auto;
+        right: -8px;
+        border-width: 8px 0 8px 8px;
+      }
+
+      &:before {
+        border-left-color: #dedede;
+      }
+
+      &:after {
+        border-left-color: #f8f8f8;
+        margin-right: 1px;
+        margin-left: auto;
+      }
+    }
+  }
+
+  &__avatar {
+    width: 48px;
+    height: 48px;
+    border-radius: 50%;
+    border: 1px solid transparent;
+    box-sizing: border-box;
+    vertical-align: middle;
+  }
+
+  &__header {
+    padding: 5px 15px;
+    background: #f8f8f8;
+    border-bottom: 1px solid #eee;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+  }
+
+  &__author {
+    font-weight: 700;
+    font-size: 14px;
+    color: #999;
+  }
+
+  &__main {
+    flex: 1;
+    margin: 0 20px;
+    position: relative;
+    border: 1px solid #dedede;
+    border-radius: 2px;
+
+    &:before,
+    &:after {
+      position: absolute;
+      top: 10px;
+      left: -8px;
+      right: 100%;
+      width: 0;
+      height: 0;
+      display: block;
+      content: ' ';
+      border-color: transparent;
+      border-style: solid solid outset;
+      border-width: 8px 8px 8px 0;
+      pointer-events: none;
+    }
+
+    &:before {
+      border-right-color: #dedede;
+      z-index: 1;
+    }
+
+    &:after {
+      border-right-color: #f8f8f8;
+      margin-left: 1px;
+      z-index: 2;
+    }
+  }
+
+  &__body {
+    padding: 15px;
+    overflow: hidden;
+    background: #fff;
+    font-family:
+      Segoe UI,
+      Lucida Grande,
+      Helvetica,
+      Arial,
+      Microsoft YaHei,
+      FreeSans,
+      Arimo,
+      Droid Sans,
+      wenquanyi micro hei,
+      Hiragino Sans GB,
+      Hiragino Sans GB W3,
+      FontAwesome,
+      sans-serif;
+    color: #333;
+    font-size: 14px;
+  }
+
+  blockquote {
+    margin: 0;
+    font-family:
+      Georgia,
+      Times New Roman,
+      Times,
+      Kai,
+      Kaiti SC,
+      KaiTi,
+      BiauKai,
+      FontAwesome,
+      serif;
+    padding: 1px 0 1px 15px;
+    border-left: 4px solid #ddd;
+  }
+}

+ 261 - 0
src/views/system/oauth2/client/ClientForm.vue

@@ -0,0 +1,261 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle" max-height="500px" scroll>
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="160px"
+    >
+      <el-form-item label="客户端编号" prop="secret">
+        <el-input v-model="formData.clientId" placeholder="请输入客户端编号" />
+      </el-form-item>
+      <el-form-item label="客户端密钥" prop="secret">
+        <el-input v-model="formData.secret" placeholder="请输入客户端密钥" />
+      </el-form-item>
+      <el-form-item label="应用名" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入应用名" />
+      </el-form-item>
+      <el-form-item label="应用图标">
+        <UploadImg v-model="formData.logo" :limit="1" />
+      </el-form-item>
+      <el-form-item label="应用描述">
+        <el-input v-model="formData.description" placeholder="请输入应用名" type="textarea" />
+      </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-item label="访问令牌的有效期" prop="accessTokenValiditySeconds">
+        <el-input-number v-model="formData.accessTokenValiditySeconds" placeholder="单位:秒" />
+      </el-form-item>
+      <el-form-item label="刷新令牌的有效期" prop="refreshTokenValiditySeconds">
+        <el-input-number v-model="formData.refreshTokenValiditySeconds" placeholder="单位:秒" />
+      </el-form-item>
+      <el-form-item label="授权类型" prop="authorizedGrantTypes">
+        <el-select
+          v-model="formData.authorizedGrantTypes"
+          filterable
+          multiple
+          placeholder="请输入授权类型"
+          style="width: 500px"
+        >
+          <el-option
+            v-for="dict in getDictOptions(DICT_TYPE.SYSTEM_OAUTH2_GRANT_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="授权范围" prop="scopes">
+        <el-select
+          v-model="formData.scopes"
+          filterable
+          multiple
+          allow-create
+          placeholder="请输入授权范围"
+          style="width: 500px"
+        >
+          <el-option v-for="scope in formData.scopes" :key="scope" :label="scope" :value="scope" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="自动授权范围" prop="autoApproveScopes">
+        <el-select
+          v-model="formData.autoApproveScopes"
+          filterable
+          multiple
+          placeholder="请输入授权范围"
+          style="width: 500px"
+        >
+          <el-option v-for="scope in formData.scopes" :key="scope" :label="scope" :value="scope" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="可重定向的 URI 地址" prop="redirectUris">
+        <el-select
+          v-model="formData.redirectUris"
+          allow-create
+          filterable
+          multiple
+          placeholder="请输入可重定向的 URI 地址"
+          style="width: 500px"
+        >
+          <el-option
+            v-for="redirectUri in formData.redirectUris"
+            :key="redirectUri"
+            :label="redirectUri"
+            :value="redirectUri"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="权限" prop="authorities">
+        <el-select
+          v-model="formData.authorities"
+          allow-create
+          filterable
+          multiple
+          placeholder="请输入权限"
+          style="width: 500px"
+        >
+          <el-option
+            v-for="authority in formData.authorities"
+            :key="authority"
+            :label="authority"
+            :value="authority"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="资源" prop="resourceIds">
+        <el-select
+          v-model="formData.resourceIds"
+          allow-create
+          filterable
+          multiple
+          placeholder="请输入资源"
+          style="width: 500px"
+        >
+          <el-option
+            v-for="resourceId in formData.resourceIds"
+            :key="resourceId"
+            :label="resourceId"
+            :value="resourceId"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="附加信息" prop="additionalInformation">
+        <el-input
+          v-model="formData.additionalInformation"
+          placeholder="请输入附加信息,JSON 格式数据"
+          type="textarea"
+        />
+      </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, getDictOptions, getIntDictOptions } from '@/utils/dict'
+import { CommonStatusEnum } from '@/utils/constants'
+import * as ClientApi from '@/api/system/oauth2/client'
+
+defineOptions({ name: 'SystemOAuth2ClientForm' })
+
+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,
+  clientId: undefined,
+  secret: undefined,
+  name: undefined,
+  logo: undefined,
+  description: undefined,
+  status: CommonStatusEnum.ENABLE,
+  accessTokenValiditySeconds: 30 * 60,
+  refreshTokenValiditySeconds: 30 * 24 * 60,
+  redirectUris: [],
+  authorizedGrantTypes: [],
+  scopes: [],
+  autoApproveScopes: [],
+  authorities: [],
+  resourceIds: [],
+  additionalInformation: undefined
+})
+const formRules = reactive({
+  clientId: [{ required: true, message: '客户端编号不能为空', trigger: 'blur' }],
+  secret: [{ required: true, message: '客户端密钥不能为空', trigger: 'blur' }],
+  name: [{ required: true, message: '应用名不能为空', trigger: 'blur' }],
+  logo: [{ required: true, message: '应用图标不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'blur' }],
+  accessTokenValiditySeconds: [
+    { required: true, message: '访问令牌的有效期不能为空', trigger: 'blur' }
+  ],
+  refreshTokenValiditySeconds: [
+    { required: true, message: '刷新令牌的有效期不能为空', trigger: 'blur' }
+  ],
+  redirectUris: [{ required: true, message: '可重定向的 URI 地址不能为空', trigger: 'blur' }],
+  authorizedGrantTypes: [{ 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 ClientApi.getOAuth2Client(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 ClientApi.OAuth2ClientVO
+    if (formType.value === 'create') {
+      await ClientApi.createOAuth2Client(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ClientApi.updateOAuth2Client(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    clientId: undefined,
+    secret: undefined,
+    name: undefined,
+    logo: undefined,
+    description: undefined,
+    status: CommonStatusEnum.ENABLE,
+    accessTokenValiditySeconds: 30 * 60,
+    refreshTokenValiditySeconds: 30 * 24 * 60,
+    redirectUris: [],
+    authorizedGrantTypes: [],
+    scopes: [],
+    autoApproveScopes: [],
+    authorities: [],
+    resourceIds: [],
+    additionalInformation: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 8 - 0
types/components.d.ts

@@ -0,0 +1,8 @@
+declare module 'vue' {
+  export interface GlobalComponents {
+    Icon: typeof import('@/components/Icon')['Icon']
+    DictTag: typeof import('@/components/DictTag')['DictTag']
+  }
+}
+
+export {}

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