ydmyzx 5 ماه پیش
والد
کامیت
4a20e95eaa
100فایلهای تغییر یافته به همراه10532 افزوده شده و 0 حذف شده
  1. 176 0
      src/api/infra/redis/types.ts
  2. 31 0
      src/api/login/types.ts
  3. 8 0
      src/components/Cropper/src/types.ts
  4. 154 0
      src/components/DiyEditor/util.ts
  5. 26 0
      src/components/Form/src/components/useRenderCheckbox.tsx
  6. 26 0
      src/components/Form/src/components/useRenderRadio.tsx
  7. 57 0
      src/components/Form/src/components/useRenderSelect.tsx
  8. 17 0
      src/components/Form/src/types.ts
  9. 248 0
      src/components/FormCreate/src/components/useApiSelect.tsx
  10. 64 0
      src/components/FormCreate/src/config/useDictSelectRule.ts
  11. 32 0
      src/components/FormCreate/src/config/useEditorRule.ts
  12. 36 0
      src/components/FormCreate/src/config/useSelectRule.ts
  13. 80 0
      src/components/FormCreate/src/config/useUploadFileRule.ts
  14. 89 0
      src/components/FormCreate/src/config/useUploadImgRule.ts
  15. 84 0
      src/components/FormCreate/src/config/useUploadImgsRule.ts
  16. 100 0
      src/components/FormCreate/src/useFormCreateDesigner.ts
  17. 51 0
      src/components/FormCreate/src/useTwinsFormCreateDesigner.ts
  18. 4 0
      src/components/FormCreate/twins.ts
  19. 9 0
      src/components/ImageViewer/src/types.ts
  20. 72 0
      src/components/MagicCubeEditor/util.ts
  21. 165 0
      src/components/SimpleProcessDesigner/src/util.ts
  22. 26 0
      src/components/Table/src/types.ts
  23. 213 0
      src/components/UploadFile/src/UploadFile.vue
  24. 271 0
      src/components/UploadFile/src/UploadImg.vue
  25. 323 0
      src/components/UploadFile/src/UploadImgs.vue
  26. 271 0
      src/components/UploadFile/src/UploadThumbnail.vue
  27. 97 0
      src/components/UploadFile/src/useUpload.ts
  28. 99 0
      src/components/UploadFile/src/useUploadThumbnail.ts
  29. 373 0
      src/components/Verifition/src/Verify.vue
  30. 250 0
      src/components/Verifition/src/Verify/VerifyPoints.vue
  31. 376 0
      src/components/Verifition/src/Verify/VerifySlide.vue
  32. 97 0
      src/components/Verifition/src/utils/util.ts
  33. 491 0
      src/components/bpmnProcessDesigner/package/penal/listeners/UserTaskListeners.vue
  34. 89 0
      src/components/bpmnProcessDesigner/package/penal/listeners/utilSelf.ts
  35. 232 0
      src/components/bpmnProcessDesigner/package/penal/task/task-components/UserTask.vue
  36. 78 0
      src/components/bpmnProcessDesigner/package/utils.ts
  37. 60 0
      src/hooks/event/useScrollTo.ts
  38. 39 0
      src/hooks/web/useCache.ts
  39. 9 0
      src/hooks/web/useConfigGlobal.ts
  40. 326 0
      src/hooks/web/useCrudSchemas.ts
  41. 18 0
      src/hooks/web/useDesign.ts
  42. 22 0
      src/hooks/web/useEmitt.ts
  43. 94 0
      src/hooks/web/useForm.ts
  44. 49 0
      src/hooks/web/useGuide.ts
  45. 53 0
      src/hooks/web/useI18n.ts
  46. 8 0
      src/hooks/web/useIcon.ts
  47. 35 0
      src/hooks/web/useLocale.ts
  48. 95 0
      src/hooks/web/useMessage.ts
  49. 33 0
      src/hooks/web/useNProgress.ts
  50. 21 0
      src/hooks/web/useNetwork.ts
  51. 60 0
      src/hooks/web/useNow.ts
  52. 18 0
      src/hooks/web/usePageLoading.ts
  53. 223 0
      src/hooks/web/useTable.ts
  54. 63 0
      src/hooks/web/useTagsView.ts
  55. 49 0
      src/hooks/web/useTimeAgo.ts
  56. 24 0
      src/hooks/web/useTitle.ts
  57. 60 0
      src/hooks/web/useValidator.ts
  58. 55 0
      src/hooks/web/useWatermark.ts
  59. 50 0
      src/layout/components/Menu/src/components/useRenderMenuItem.tsx
  60. 27 0
      src/layout/components/Menu/src/components/useRenderMenuTitle.tsx
  61. 113 0
      src/layout/components/UserInfo/src/UserInfo.vue
  62. 306 0
      src/layout/components/useRenderLayout.tsx
  63. 103 0
      src/store/modules/user.ts
  64. 66 0
      src/styles/var.css
  65. 4 0
      src/styles/variables.scss
  66. 16 0
      src/utils/tsxHelper.ts
  67. 55 0
      src/views/Home/types.ts
  68. 42 0
      src/views/Login/components/useLogin.ts
  69. 45 0
      src/views/Profile/components/UserAvatar.vue
  70. 107 0
      src/views/Profile/components/UserSocial.vue
  71. 13 0
      src/views/ai/utils/utils.ts
  72. 132 0
      src/views/bpm/group/UserGroupForm.vue
  73. 157 0
      src/views/erp/stock/warehouse/WarehouseForm.vue
  74. 138 0
      src/views/gismodel/twinmodelIcon/TwinModelsForm.vue
  75. 146 0
      src/views/gismodel/twinmodels/TwinModelsForm.vue
  76. 338 0
      src/views/layer/visualizemessage/VisualizeMessageForm.vue
  77. 105 0
      src/views/mall/product/property/value/ValueForm.vue
  78. 127 0
      src/views/mall/trade/brokerage/user/UpdateBindUserForm.vue
  79. 179 0
      src/views/member/user/UserForm.vue
  80. 101 0
      src/views/member/user/UserLevelUpdateForm.vue
  81. 128 0
      src/views/member/user/UserPointUpdateForm.vue
  82. 87 0
      src/views/member/user/detail/UserAccountInfo.vue
  83. 54 0
      src/views/member/user/detail/UserAddressList.vue
  84. 85 0
      src/views/member/user/detail/UserBasicInfo.vue
  85. 125 0
      src/views/member/user/detail/UserBrokerageList.vue
  86. 190 0
      src/views/member/user/detail/UserCouponList.vue
  87. 158 0
      src/views/member/user/detail/UserExperienceRecordList.vue
  88. 96 0
      src/views/member/user/detail/UserFavoriteList.vue
  89. 279 0
      src/views/member/user/detail/UserOrderList.vue
  90. 152 0
      src/views/member/user/detail/UserPointList.vue
  91. 135 0
      src/views/member/user/detail/UserSignList.vue
  92. 7 0
      src/views/mp/autoReply/components/types.ts
  93. 11 0
      src/views/mp/components/wx-material-select/types.ts
  94. 17 0
      src/views/mp/components/wx-msg/types.ts
  95. 54 0
      src/views/mp/components/wx-reply/components/types.ts
  96. 40 0
      src/views/mp/draft/components/types.ts
  97. 50 0
      src/views/mp/hooks/useUpload.ts
  98. 77 0
      src/views/mp/material/components/UploadFile.vue
  99. 129 0
      src/views/mp/material/components/UploadVideo.vue
  100. 59 0
      src/views/mp/material/components/VideoTable.vue

+ 176 - 0
src/api/infra/redis/types.ts

@@ -0,0 +1,176 @@
+export interface RedisMonitorInfoVO {
+  info: RedisInfoVO
+  dbSize: number
+  commandStats: RedisCommandStatsVO[]
+}
+
+export interface RedisInfoVO {
+  io_threaded_reads_processed: string
+  tracking_clients: string
+  uptime_in_seconds: string
+  cluster_connections: string
+  current_cow_size: string
+  maxmemory_human: string
+  aof_last_cow_size: string
+  master_replid2: string
+  mem_replication_backlog: string
+  aof_rewrite_scheduled: string
+  total_net_input_bytes: string
+  rss_overhead_ratio: string
+  hz: string
+  current_cow_size_age: string
+  redis_build_id: string
+  errorstat_BUSYGROUP: string
+  aof_last_bgrewrite_status: string
+  multiplexing_api: string
+  client_recent_max_output_buffer: string
+  allocator_resident: string
+  mem_fragmentation_bytes: string
+  aof_current_size: string
+  repl_backlog_first_byte_offset: string
+  tracking_total_prefixes: string
+  redis_mode: string
+  redis_git_dirty: string
+  aof_delayed_fsync: string
+  allocator_rss_bytes: string
+  repl_backlog_histlen: string
+  io_threads_active: string
+  rss_overhead_bytes: string
+  total_system_memory: string
+  loading: string
+  evicted_keys: string
+  maxclients: string
+  cluster_enabled: string
+  redis_version: string
+  repl_backlog_active: string
+  mem_aof_buffer: string
+  allocator_frag_bytes: string
+  io_threaded_writes_processed: string
+  instantaneous_ops_per_sec: string
+  used_memory_human: string
+  total_error_replies: string
+  role: string
+  maxmemory: string
+  used_memory_lua: string
+  rdb_current_bgsave_time_sec: string
+  used_memory_startup: string
+  used_cpu_sys_main_thread: string
+  lazyfree_pending_objects: string
+  aof_pending_bio_fsync: string
+  used_memory_dataset_perc: string
+  allocator_frag_ratio: string
+  arch_bits: string
+  used_cpu_user_main_thread: string
+  mem_clients_normal: string
+  expired_time_cap_reached_count: string
+  unexpected_error_replies: string
+  mem_fragmentation_ratio: string
+  aof_last_rewrite_time_sec: string
+  master_replid: string
+  aof_rewrite_in_progress: string
+  lru_clock: string
+  maxmemory_policy: string
+  run_id: string
+  latest_fork_usec: string
+  tracking_total_items: string
+  total_commands_processed: string
+  expired_keys: string
+  errorstat_ERR: string
+  used_memory: string
+  module_fork_in_progress: string
+  errorstat_WRONGPASS: string
+  aof_buffer_length: string
+  dump_payload_sanitizations: string
+  mem_clients_slaves: string
+  keyspace_misses: string
+  server_time_usec: string
+  executable: string
+  lazyfreed_objects: string
+  db0: string
+  used_memory_peak_human: string
+  keyspace_hits: string
+  rdb_last_cow_size: string
+  aof_pending_rewrite: string
+  used_memory_overhead: string
+  active_defrag_hits: string
+  tcp_port: string
+  uptime_in_days: string
+  used_memory_peak_perc: string
+  current_save_keys_processed: string
+  blocked_clients: string
+  total_reads_processed: string
+  expire_cycle_cpu_milliseconds: string
+  sync_partial_err: string
+  used_memory_scripts_human: string
+  aof_current_rewrite_time_sec: string
+  aof_enabled: string
+  process_supervised: string
+  master_repl_offset: string
+  used_memory_dataset: string
+  used_cpu_user: string
+  rdb_last_bgsave_status: string
+  tracking_total_keys: string
+  atomicvar_api: string
+  allocator_rss_ratio: string
+  client_recent_max_input_buffer: string
+  clients_in_timeout_table: string
+  aof_last_write_status: string
+  mem_allocator: string
+  used_memory_scripts: string
+  used_memory_peak: string
+  process_id: string
+  master_failover_state: string
+  errorstat_NOAUTH: string
+  used_cpu_sys: string
+  repl_backlog_size: string
+  connected_slaves: string
+  current_save_keys_total: string
+  gcc_version: string
+  total_system_memory_human: string
+  sync_full: string
+  connected_clients: string
+  module_fork_last_cow_size: string
+  total_writes_processed: string
+  allocator_active: string
+  total_net_output_bytes: string
+  pubsub_channels: string
+  current_fork_perc: string
+  active_defrag_key_hits: string
+  rdb_changes_since_last_save: string
+  instantaneous_input_kbps: string
+  used_memory_rss_human: string
+  configured_hz: string
+  expired_stale_perc: string
+  active_defrag_misses: string
+  used_cpu_sys_children: string
+  number_of_cached_scripts: string
+  sync_partial_ok: string
+  used_memory_lua_human: string
+  rdb_last_save_time: string
+  pubsub_patterns: string
+  slave_expires_tracked_keys: string
+  redis_git_sha1: string
+  used_memory_rss: string
+  rdb_last_bgsave_time_sec: string
+  os: string
+  mem_not_counted_for_evict: string
+  active_defrag_running: string
+  rejected_connections: string
+  aof_rewrite_buffer_length: string
+  total_forks: string
+  active_defrag_key_misses: string
+  allocator_allocated: string
+  aof_base_size: string
+  instantaneous_output_kbps: string
+  second_repl_offset: string
+  rdb_bgsave_in_progress: string
+  used_cpu_user_children: string
+  total_connections_received: string
+  migrate_cached_sockets: string
+}
+
+export interface RedisCommandStatsVO {
+  command: string
+  calls: number
+  usec: number
+}

+ 31 - 0
src/api/login/types.ts

@@ -0,0 +1,31 @@
+export type UserLoginVO = {
+  username: string
+  password: string
+  captchaVerification: string
+  socialType?: string
+  socialCode?: string
+  socialState?: string
+}
+
+export type TokenType = {
+  id: number // 编号
+  accessToken: string // 访问令牌
+  refreshToken: string // 刷新令牌
+  userId: number // 用户编号
+  userType: number //用户类型
+  clientId: string //客户端编号
+  expiresTime: number //过期时间
+}
+
+export type UserVO = {
+  id: number
+  username: string
+  nickname: string
+  deptId: number
+  email: string
+  mobile: string
+  sex: number
+  avatar: string
+  loginIp: string
+  loginDate: string
+}

+ 8 - 0
src/components/Cropper/src/types.ts

@@ -0,0 +1,8 @@
+import type Cropper from 'cropperjs'
+
+export interface CropendResult {
+  imgBase64: string
+  imgInfo: Cropper.Data
+}
+
+export type { Cropper }

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

@@ -0,0 +1,154 @@
+import { ref, Ref } from 'vue'
+import { PageConfigProperty } from '@/components/DiyEditor/components/mobile/PageConfig/config'
+import { NavigationBarProperty } from '@/components/DiyEditor/components/mobile/NavigationBar/config'
+import { TabBarProperty } from '@/components/DiyEditor/components/mobile/TabBar/config'
+
+// 页面装修组件
+export interface DiyComponent<T> {
+  // 用于区分同一种组件的不同实例
+  uid?: number
+  // 组件唯一标识
+  id: string
+  // 组件名称
+  name: string
+  // 组件图标
+  icon: string
+  /*
+   组件位置:
+   top: 固定于手机顶部,例如 顶部的导航栏
+   bottom: 固定于手机底部,例如 底部的菜单导航栏
+   center: 位于手机中心,每个组件占一行,顺序向下排列
+   空:同center
+   fixed: 由组件自己决定位置,如弹窗位于手机中心、浮动按钮一般位于手机右下角
+  */
+  position?: 'top' | 'bottom' | 'center' | '' | 'fixed'
+  // 组件属性
+  property: T
+}
+
+// 页面装修组件库
+export interface DiyComponentLibrary {
+  // 组件库名称
+  name: string
+  // 是否展开
+  extended: boolean
+  // 组件列表
+  components: string[]
+}
+
+// 组件样式
+export interface ComponentStyle {
+  // 背景类型
+  bgType: 'color' | 'img'
+  // 背景颜色
+  bgColor: string
+  // 背景图片
+  bgImg: string
+  // 外边距
+  margin: number
+  marginTop: number
+  marginRight: number
+  marginBottom: number
+  marginLeft: number
+  // 内边距
+  padding: number
+  paddingTop: number
+  paddingRight: number
+  paddingBottom: number
+  paddingLeft: number
+  // 边框圆角
+  borderRadius: number
+  borderTopLeftRadius: number
+  borderTopRightRadius: number
+  borderBottomRightRadius: number
+  borderBottomLeftRadius: number
+}
+
+// 页面配置
+export interface PageConfig {
+  // 页面属性
+  page: PageConfigProperty
+  // 顶部导航栏属性
+  navigationBar: NavigationBarProperty
+  // 底部导航菜单属性
+  tabBar?: TabBarProperty
+  // 页面组件列表
+  components: PageComponent[]
+}
+// 页面组件,只保留组件ID,组件属性
+export interface PageComponent extends Pick<DiyComponent<any>, 'id' | 'property'> {}
+
+// 属性表单监听
+export function usePropertyForm<T>(modelValue: T, emit: Function): { formData: Ref<T> } {
+  const formData = ref<T>()
+  // 监听属性数据变动
+  watch(
+    () => modelValue,
+    () => {
+      formData.value = modelValue
+    },
+    {
+      deep: true,
+      immediate: true
+    }
+  )
+  // 监听表单数据变动
+  watch(
+    () => formData.value,
+    () => {
+      emit('update:modelValue', formData.value)
+    },
+    {
+      deep: true
+    }
+  )
+
+  return { formData } as { formData: Ref<T> }
+}
+
+// 页面组件库
+export const PAGE_LIBS = [
+  {
+    name: '基础组件',
+    extended: true,
+    components: [
+      'SearchBar',
+      'NoticeBar',
+      'MenuSwiper',
+      'MenuGrid',
+      'MenuList',
+      'Popover',
+      'FloatingActionButton'
+    ]
+  },
+  {
+    name: '图文组件',
+    extended: true,
+    components: [
+      'ImageBar',
+      'Carousel',
+      'TitleBar',
+      'VideoPlayer',
+      'Divider',
+      'MagicCube',
+      'HotZone'
+    ]
+  },
+  { name: '商品组件', extended: true, components: ['ProductCard', 'ProductList'] },
+  {
+    name: '用户组件',
+    extended: true,
+    components: ['UserCard', 'UserOrder', 'UserWallet', 'UserCoupon']
+  },
+  {
+    name: '营销组件',
+    extended: true,
+    components: [
+      'PromotionCombination',
+      'PromotionSeckill',
+      'PromotionPoint',
+      'CouponCard',
+      'PromotionArticle'
+    ]
+  }
+] as DiyComponentLibrary[]

+ 26 - 0
src/components/Form/src/components/useRenderCheckbox.tsx

@@ -0,0 +1,26 @@
+import { FormSchema } from '@/types/form'
+import { ElCheckbox, ElCheckboxButton } from 'element-plus'
+import { defineComponent } from 'vue'
+
+export const useRenderCheckbox = () => {
+  const renderCheckboxOptions = (item: FormSchema) => {
+    // 如果有别名,就取别名
+    const labelAlias = item?.componentProps?.optionsAlias?.labelField
+    const valueAlias = item?.componentProps?.optionsAlias?.valueField
+    const Com = (item.component === 'Checkbox' ? ElCheckbox : ElCheckboxButton) as ReturnType<
+      typeof defineComponent
+    >
+    return item?.componentProps?.options?.map((option) => {
+      const { ...other } = option
+      return (
+        <Com {...other} label={option[valueAlias || 'value']}>
+          {option[labelAlias || 'label']}
+        </Com>
+      )
+    })
+  }
+
+  return {
+    renderCheckboxOptions
+  }
+}

+ 26 - 0
src/components/Form/src/components/useRenderRadio.tsx

@@ -0,0 +1,26 @@
+import { FormSchema } from '@/types/form'
+import { ElRadio, ElRadioButton } from 'element-plus'
+import { defineComponent } from 'vue'
+
+export const useRenderRadio = () => {
+  const renderRadioOptions = (item: FormSchema) => {
+    // 如果有别名,就取别名
+    const labelAlias = item?.componentProps?.optionsAlias?.labelField
+    const valueAlias = item?.componentProps?.optionsAlias?.valueField
+    const Com = (item.component === 'Radio' ? ElRadio : ElRadioButton) as ReturnType<
+      typeof defineComponent
+    >
+    return item?.componentProps?.options?.map((option) => {
+      const { ...other } = option
+      return (
+        <Com {...other} label={option[valueAlias || 'value']}>
+          {option[labelAlias || 'label']}
+        </Com>
+      )
+    })
+  }
+
+  return {
+    renderRadioOptions
+  }
+}

+ 57 - 0
src/components/Form/src/components/useRenderSelect.tsx

@@ -0,0 +1,57 @@
+import { FormSchema } from '@/types/form'
+import { ComponentOptions } from '@/types/components'
+import { ElOption, ElOptionGroup } from 'element-plus'
+import { getSlot } from '@/utils/tsxHelper'
+import { Slots } from 'vue'
+
+export const useRenderSelect = (slots: Slots) => {
+  // 渲染 select options
+  const renderSelectOptions = (item: FormSchema) => {
+    // 如果有别名,就取别名
+    const labelAlias = item?.componentProps?.optionsAlias?.labelField
+    return item?.componentProps?.options?.map((option) => {
+      if (option?.options?.length) {
+        return (
+          <ElOptionGroup label={option[labelAlias || 'label']}>
+            {() => {
+              return option?.options?.map((v) => {
+                return renderSelectOptionItem(item, v)
+              })
+            }}
+          </ElOptionGroup>
+        )
+      } else {
+        return renderSelectOptionItem(item, option)
+      }
+    })
+  }
+
+  // 渲染 select option item
+  const renderSelectOptionItem = (item: FormSchema, option: ComponentOptions) => {
+    // 如果有别名,就取别名
+    const labelAlias = item?.componentProps?.optionsAlias?.labelField
+    const valueAlias = item?.componentProps?.optionsAlias?.valueField
+
+    const { label, value, ...other } = option
+
+    return (
+      <ElOption
+        {...other}
+        label={labelAlias ? option[labelAlias] : label}
+        value={valueAlias ? option[valueAlias] : value}
+      >
+        {{
+          default: () =>
+            // option 插槽名规则,{field}-option
+            item?.componentProps?.optionsSlot
+              ? getSlot(slots, `${item.field}-option`, { item: option })
+              : undefined
+        }}
+      </ElOption>
+    )
+  }
+
+  return {
+    renderSelectOptions
+  }
+}

+ 17 - 0
src/components/Form/src/types.ts

@@ -0,0 +1,17 @@
+import { FormSchema } from '@/types/form'
+
+export interface PlaceholderModel {
+  placeholder?: string
+  startPlaceholder?: string
+  endPlaceholder?: string
+  rangeSeparator?: string
+}
+
+export type FormProps = {
+  schema?: FormSchema[]
+  isCol?: boolean
+  model?: Recordable
+  autoSetPlaceholder?: boolean
+  isCustom?: boolean
+  labelWidth?: string | number
+} & Recordable

+ 248 - 0
src/components/FormCreate/src/components/useApiSelect.tsx

@@ -0,0 +1,248 @@
+import request from '@/config/axios'
+import { isEmpty } from '@/utils/is'
+import { ApiSelectProps } from '@/components/FormCreate/src/type'
+import { jsonParse } from '@/utils'
+
+export const useApiSelect = (option: ApiSelectProps) => {
+  return defineComponent({
+    name: option.name,
+    props: {
+      // 选项标签
+      labelField: {
+        type: String,
+        default: () => option.labelField ?? 'label'
+      },
+      // 选项的值
+      valueField: {
+        type: String,
+        default: () => option.valueField ?? 'value'
+      },
+      // api 接口
+      url: {
+        type: String,
+        default: () => option.url ?? ''
+      },
+      // 请求类型
+      method: {
+        type: String,
+        default: 'GET'
+      },
+      // 选项解析函数
+      parseFunc: {
+        type: String,
+        default: ''
+      },
+      // 请求参数
+      data: {
+        type: String,
+        default: ''
+      },
+      // 选择器类型,下拉框 select、多选框 checkbox、单选框 radio
+      selectType: {
+        type: String,
+        default: 'select'
+      },
+      // 是否多选
+      multiple: {
+        type: Boolean,
+        default: false
+      },
+      // 是否远程搜索
+      remote: {
+        type: Boolean,
+        default: false
+      },
+      // 远程搜索时携带的参数
+      remoteField: {
+        type: String,
+        default: 'label'
+      }
+    },
+    setup(props) {
+      const attrs = useAttrs()
+      const options = ref<any[]>([]) // 下拉数据
+      const loading = ref(false) // 是否正在从远程获取数据
+      const queryParam = ref<any>() // 当前输入的值
+      const getOptions = async () => {
+        options.value = []
+        // 接口选择器
+        if (isEmpty(props.url)) {
+          return
+        }
+        switch (props.method) {
+          case 'GET':
+            let url: string = props.url
+            if (props.remote) {
+              url = `${url}?${props.remoteField}=${queryParam.value}`
+            }
+            parseOptions(await request.get({ url: url }))
+            break
+          case 'POST':
+            const data: any = jsonParse(props.data)
+            if (props.remote) {
+              data[props.remoteField] = queryParam.value
+            }
+            parseOptions(await request.post({ url: props.url, data: data }))
+            break
+        }
+      }
+
+      function parseOptions(data: any) {
+        //  情况一:如果有自定义解析函数优先使用自定义解析
+        if (!isEmpty(props.parseFunc)) {
+          options.value = parseFunc()?.(data)
+          return
+        }
+        // 情况二:返回的直接是一个列表
+        if (Array.isArray(data)) {
+          parseOptions0(data)
+          return
+        }
+        // 情况二:返回的是分页数据,尝试读取 list
+        data = data.list
+        if (!!data && Array.isArray(data)) {
+          parseOptions0(data)
+          return
+        }
+        // 情况三:不是 yudao-vue-pro 标准返回
+        console.warn(
+          `接口[${props.url}] 返回结果不是 yudao-vue-pro 标准返回建议采用自定义解析函数处理`
+        )
+      }
+
+      function parseOptions0(data: any[]) {
+        if (Array.isArray(data)) {
+          options.value = data.map((item: any) => ({
+            label: parseExpression(item, props.labelField),
+            value: parseExpression(item, props.valueField)
+          }))
+          return
+        }
+        console.warn(`接口[${props.url}] 返回结果不是一个数组`)
+      }
+
+      function parseFunc() {
+        let parse: any = null
+        if (!!props.parseFunc) {
+          // 解析字符串函数
+          parse = new Function(`return ${props.parseFunc}`)()
+        }
+        return parse
+      }
+
+      function parseExpression(data: any, template: string) {
+        // 检测是否使用了表达式
+        if (template.indexOf('${') === -1) {
+          return data[template]
+        }
+        // 正则表达式匹配模板字符串中的 ${...}
+        const pattern = /\$\{([^}]*)}/g
+        // 使用replace函数配合正则表达式和回调函数来进行替换
+        return template.replace(pattern, (_, expr) => {
+          // expr 是匹配到的 ${} 内的表达式(这里是属性名),从 data 中获取对应的值
+          const result = data[expr.trim()] // 去除前后空白,以防用户输入带空格的属性名
+          if (!result) {
+            console.warn(
+              `接口选择器选项模版[${template}][${expr.trim()}] 解析值失败结果为[${result}], 请检查属性名称是否存在于接口返回值中,存在则忽略此条!!!`
+            )
+          }
+          return result
+        })
+      }
+
+      const remoteMethod = async (query: any) => {
+        if (!query) {
+          return
+        }
+        loading.value = true
+        try {
+          queryParam.value = query
+          await getOptions()
+        } finally {
+          loading.value = false
+        }
+      }
+
+      onMounted(async () => {
+        await getOptions()
+      })
+
+      const buildSelect = () => {
+        if (props.multiple) {
+          // fix:多写此步是为了解决 multiple 属性问题
+          return (
+            <el-select
+              class="w-1/1"
+              multiple
+              loading={loading.value}
+              {...attrs}
+              remote={props.remote}
+              {...(props.remote && { remoteMethod: remoteMethod })}
+            >
+              {options.value.map((item, index) => (
+                <el-option key={index} label={item.label} value={item.value} />
+              ))}
+            </el-select>
+          )
+        }
+        debugger
+        return (
+          <el-select
+            class="w-1/1"
+            loading={loading.value}
+            {...attrs}
+            remote={props.remote}
+            {...(props.remote && { remoteMethod: remoteMethod })}
+          >
+            {options.value.map((item, index) => (
+              <el-option key={index} label={item.label} value={item.value} />
+            ))}
+          </el-select>
+        )
+      }
+      const buildCheckbox = () => {
+        if (isEmpty(options.value)) {
+          options.value = [
+            { label: '选项1', value: '选项1' },
+            { label: '选项2', value: '选项2' }
+          ]
+        }
+        return (
+          <el-checkbox-group class="w-1/1" {...attrs}>
+            {options.value.map((item, index) => (
+              <el-checkbox key={index} label={item.label} value={item.value} />
+            ))}
+          </el-checkbox-group>
+        )
+      }
+      const buildRadio = () => {
+        if (isEmpty(options.value)) {
+          options.value = [
+            { label: '选项1', value: '选项1' },
+            { label: '选项2', value: '选项2' }
+          ]
+        }
+        return (
+          <el-radio-group class="w-1/1" {...attrs}>
+            {options.value.map((item, index) => (
+              <el-radio key={index} value={item.value}>
+                {item.label}
+              </el-radio>
+            ))}
+          </el-radio-group>
+        )
+      }
+      return () => (
+        <>
+          {props.selectType === 'select'
+            ? buildSelect()
+            : props.selectType === 'radio'
+              ? buildRadio()
+              : props.selectType === 'checkbox'
+                ? buildCheckbox()
+                : buildSelect()}
+        </>
+      )
+    }
+  })
+}

+ 64 - 0
src/components/FormCreate/src/config/useDictSelectRule.ts

@@ -0,0 +1,64 @@
+import { generateUUID } from '@/utils'
+import * as DictDataApi from '@/api/system/dict/dict.type'
+import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
+import { selectRule } from '@/components/FormCreate/src/config/selectRule'
+import { cloneDeep } from 'lodash-es'
+
+/**
+ * 字典选择器规则,如果规则使用到动态数据则需要单独配置不能使用 useSelectRule
+ */
+export const useDictSelectRule = () => {
+  const label = '字典选择器'
+  const name = 'DictSelect'
+  const rules = cloneDeep(selectRule)
+  const dictOptions = ref<{ label: string; value: string }[]>([]) // 字典类型下拉数据
+  onMounted(async () => {
+    const data = await DictDataApi.getSimpleDictTypeList()
+    if (!data || data.length === 0) {
+      return
+    }
+    dictOptions.value =
+      data?.map((item: DictDataApi.DictTypeVO) => ({
+        label: item.name,
+        value: item.type
+      })) ?? []
+  })
+  return {
+    icon: 'icon-doc-text',
+    label,
+    name,
+    rule() {
+      return {
+        type: name,
+        field: generateUUID(),
+        title: label,
+        info: '',
+        $required: false
+      }
+    },
+    props(_, { t }) {
+      return localeProps(t, name + '.props', [
+        makeRequiredRule(),
+        {
+          type: 'select',
+          field: 'dictType',
+          title: '字典类型',
+          value: '',
+          options: dictOptions.value
+        },
+        {
+          type: 'select',
+          field: 'dictValueType',
+          title: '字典值类型',
+          value: 'str',
+          options: [
+            { label: '数字', value: 'int' },
+            { label: '字符串', value: 'str' },
+            { label: '布尔值', value: 'bool' }
+          ]
+        },
+        ...rules
+      ])
+    }
+  }
+}

+ 32 - 0
src/components/FormCreate/src/config/useEditorRule.ts

@@ -0,0 +1,32 @@
+import { generateUUID } from '@/utils'
+import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
+
+export const useEditorRule = () => {
+  const label = '富文本'
+  const name = 'Editor'
+  return {
+    icon: 'icon-editor',
+    label,
+    name,
+    rule() {
+      return {
+        type: name,
+        field: generateUUID(),
+        title: label,
+        info: '',
+        $required: false
+      }
+    },
+    props(_, { t }) {
+      return localeProps(t, name + '.props', [
+        makeRequiredRule(),
+        {
+          type: 'input',
+          field: 'height',
+          title: '高度'
+        },
+        { type: 'switch', field: 'readonly', title: '是否只读' }
+      ])
+    }
+  }
+}

+ 36 - 0
src/components/FormCreate/src/config/useSelectRule.ts

@@ -0,0 +1,36 @@
+import { generateUUID } from '@/utils'
+import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
+import { selectRule } from '@/components/FormCreate/src/config/selectRule'
+import { SelectRuleOption } from '@/components/FormCreate/src/type'
+import { cloneDeep } from 'lodash-es'
+
+/**
+ * 通用选择器规则 hook
+ *
+ * @param option 规则配置
+ */
+export const useSelectRule = (option: SelectRuleOption) => {
+  const label = option.label
+  const name = option.name
+  const rules = cloneDeep(selectRule)
+  return {
+    icon: option.icon,
+    label,
+    name,
+    rule() {
+      return {
+        type: name,
+        field: generateUUID(),
+        title: label,
+        info: '',
+        $required: false
+      }
+    },
+    props(_, { t }) {
+      if (!option.props) {
+        option.props = []
+      }
+      return localeProps(t, name + '.props', [makeRequiredRule(), ...option.props, ...rules])
+    }
+  }
+}

+ 80 - 0
src/components/FormCreate/src/config/useUploadFileRule.ts

@@ -0,0 +1,80 @@
+import { generateUUID } from '@/utils'
+import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
+
+export const useUploadFileRule = () => {
+  const label = '文件上传'
+  const name = 'UploadFile'
+  return {
+    icon: 'icon-upload',
+    label,
+    name,
+    rule() {
+      return {
+        type: name,
+        field: generateUUID(),
+        title: label,
+        info: '',
+        $required: false
+      }
+    },
+    props(_, { t }) {
+      return localeProps(t, name + '.props', [
+        makeRequiredRule(),
+        {
+          type: 'select',
+          field: 'fileType',
+          title: '文件类型',
+          value: ['doc', 'xls', 'ppt', 'txt', 'pdf'],
+          options: [
+            { label: 'doc', value: 'doc' },
+            { label: 'xls', value: 'xls' },
+            { label: 'ppt', value: 'ppt' },
+            { label: 'txt', value: 'txt' },
+            { label: 'pdf', value: 'pdf' }
+          ],
+          props: {
+            multiple: true
+          }
+        },
+        {
+          type: 'switch',
+          field: 'autoUpload',
+          title: '是否在选取文件后立即进行上传',
+          value: true
+        },
+        {
+          type: 'switch',
+          field: 'drag',
+          title: '拖拽上传',
+          value: false
+        },
+        {
+          type: 'switch',
+          field: 'isShowTip',
+          title: '是否显示提示',
+          value: true
+        },
+        {
+          type: 'inputNumber',
+          field: 'fileSize',
+          title: '大小限制(MB)',
+          value: 5,
+          props: { min: 0 }
+        },
+        {
+          type: 'inputNumber',
+          field: 'limit',
+          title: '数量限制',
+          value: 5,
+          props: { min: 0 }
+        },
+        {
+          type: 'switch',
+          field: 'disabled',
+          title: '是否禁用',
+          value: false
+        }
+      ])
+    }
+  }
+}

+ 89 - 0
src/components/FormCreate/src/config/useUploadImgRule.ts

@@ -0,0 +1,89 @@
+import { generateUUID } from '@/utils'
+import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
+
+export const useUploadImgRule = () => {
+  const label = '单图上传'
+  const name = 'UploadImg'
+  return {
+    icon: 'icon-upload',
+    label,
+    name,
+    rule() {
+      return {
+        type: name,
+        field: generateUUID(),
+        title: label,
+        info: '',
+        $required: false
+      }
+    },
+    props(_, { t }) {
+      return localeProps(t, name + '.props', [
+        makeRequiredRule(),
+        {
+          type: 'switch',
+          field: 'drag',
+          title: '拖拽上传',
+          value: false
+        },
+        {
+          type: 'select',
+          field: 'fileType',
+          title: '图片类型限制',
+          value: ['image/jpeg', 'image/png', 'image/gif'],
+          options: [
+            { label: 'image/apng', value: 'image/apng' },
+            { label: 'image/bmp', value: 'image/bmp' },
+            { label: 'image/gif', value: 'image/gif' },
+            { label: 'image/jpeg', value: 'image/jpeg' },
+            { label: 'image/pjpeg', value: 'image/pjpeg' },
+            { label: 'image/svg+xml', value: 'image/svg+xml' },
+            { label: 'image/tiff', value: 'image/tiff' },
+            { label: 'image/webp', value: 'image/webp' },
+            { label: 'image/x-icon', value: 'image/x-icon' }
+          ],
+          props: {
+            multiple: true
+          }
+        },
+        {
+          type: 'inputNumber',
+          field: 'fileSize',
+          title: '大小限制(MB)',
+          value: 5,
+          props: { min: 0 }
+        },
+        {
+          type: 'input',
+          field: 'height',
+          title: '组件高度',
+          value: '150px'
+        },
+        {
+          type: 'input',
+          field: 'width',
+          title: '组件宽度',
+          value: '150px'
+        },
+        {
+          type: 'input',
+          field: 'borderradius',
+          title: '组件边框圆角',
+          value: '8px'
+        },
+        {
+          type: 'switch',
+          field: 'disabled',
+          title: '是否显示删除按钮',
+          value: true
+        },
+        {
+          type: 'switch',
+          field: 'showBtnText',
+          title: '是否显示按钮文字',
+          value: true
+        }
+      ])
+    }
+  }
+}

+ 84 - 0
src/components/FormCreate/src/config/useUploadImgsRule.ts

@@ -0,0 +1,84 @@
+import { generateUUID } from '@/utils'
+import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
+
+export const useUploadImgsRule = () => {
+  const label = '多图上传'
+  const name = 'UploadImgs'
+  return {
+    icon: 'icon-upload',
+    label,
+    name,
+    rule() {
+      return {
+        type: name,
+        field: generateUUID(),
+        title: label,
+        info: '',
+        $required: false
+      }
+    },
+    props(_, { t }) {
+      return localeProps(t, name + '.props', [
+        makeRequiredRule(),
+        {
+          type: 'switch',
+          field: 'drag',
+          title: '拖拽上传',
+          value: false
+        },
+        {
+          type: 'select',
+          field: 'fileType',
+          title: '图片类型限制',
+          value: ['image/jpeg', 'image/png', 'image/gif'],
+          options: [
+            { label: 'image/apng', value: 'image/apng' },
+            { label: 'image/bmp', value: 'image/bmp' },
+            { label: 'image/gif', value: 'image/gif' },
+            { label: 'image/jpeg', value: 'image/jpeg' },
+            { label: 'image/pjpeg', value: 'image/pjpeg' },
+            { label: 'image/svg+xml', value: 'image/svg+xml' },
+            { label: 'image/tiff', value: 'image/tiff' },
+            { label: 'image/webp', value: 'image/webp' },
+            { label: 'image/x-icon', value: 'image/x-icon' }
+          ],
+          props: {
+            multiple: true
+          }
+        },
+        {
+          type: 'inputNumber',
+          field: 'fileSize',
+          title: '大小限制(MB)',
+          value: 5,
+          props: { min: 0 }
+        },
+        {
+          type: 'inputNumber',
+          field: 'limit',
+          title: '数量限制',
+          value: 5,
+          props: { min: 0 }
+        },
+        {
+          type: 'input',
+          field: 'height',
+          title: '组件高度',
+          value: '150px'
+        },
+        {
+          type: 'input',
+          field: 'width',
+          title: '组件宽度',
+          value: '150px'
+        },
+        {
+          type: 'input',
+          field: 'borderradius',
+          title: '组件边框圆角',
+          value: '8px'
+        }
+      ])
+    }
+  }
+}

+ 100 - 0
src/components/FormCreate/src/useFormCreateDesigner.ts

@@ -0,0 +1,100 @@
+import {
+  useDictSelectRule,
+  useEditorRule,
+  useSelectRule,
+  useUploadFileRule,
+  useUploadImgRule,
+  useUploadImgsRule
+} from './config'
+import { Ref } from 'vue'
+import { Menu } from '@/components/FormCreate/src/type'
+import { apiSelectRule } from '@/components/FormCreate/src/config/selectRule'
+
+/**
+ * 表单设计器增强 hook
+ * 新增
+ * - 文件上传
+ * - 单图上传
+ * - 多图上传
+ * - 字典选择器
+ * - 用户选择器
+ * - 部门选择器
+ * - 富文本
+ */
+export const useFormCreateDesigner = async (designer: Ref) => {
+  const editorRule = useEditorRule()
+  const uploadFileRule = useUploadFileRule()
+  const uploadImgRule = useUploadImgRule()
+  const uploadImgsRule = useUploadImgsRule()
+
+  /**
+   * 构建表单组件
+   */
+  const buildFormComponents = () => {
+    // 移除自带的上传组件规则,使用 uploadFileRule、uploadImgRule、uploadImgsRule 替代
+    designer.value?.removeMenuItem('upload')
+    // 移除自带的富文本组件规则,使用 editorRule 替代
+    designer.value?.removeMenuItem('fc-editor')
+    const components = [editorRule, uploadFileRule, uploadImgRule, uploadImgsRule]
+    components.forEach((component) => {
+      // 插入组件规则
+      designer.value?.addComponent(component)
+      // 插入拖拽按钮到 `main` 分类下
+      designer.value?.appendMenuItem('main', {
+        icon: component.icon,
+        name: component.name,
+        label: component.label
+      })
+    })
+  }
+
+  const userSelectRule = useSelectRule({
+    name: 'UserSelect',
+    label: '用户选择器',
+    icon: 'icon-user-o'
+  })
+  const deptSelectRule = useSelectRule({
+    name: 'DeptSelect',
+    label: '部门选择器',
+    icon: 'icon-address-card-o'
+  })
+  const dictSelectRule = useDictSelectRule()
+  const apiSelectRule0 = useSelectRule({
+    name: 'ApiSelect',
+    label: '接口选择器',
+    icon: 'icon-server',
+    props: [...apiSelectRule]
+  })
+
+  /**
+   * 构建系统字段菜单
+   */
+  const buildSystemMenu = () => {
+    // 移除自带的下拉选择器组件,使用 currencySelectRule 替代
+    // designer.value?.removeMenuItem('select')
+    // designer.value?.removeMenuItem('radio')
+    // designer.value?.removeMenuItem('checkbox')
+    const components = [userSelectRule, deptSelectRule, dictSelectRule, apiSelectRule0]
+    const menu: Menu = {
+      name: 'system',
+      title: '系统字段',
+      list: components.map((component) => {
+        // 插入组件规则
+        designer.value?.addComponent(component)
+        // 插入拖拽按钮到 `system` 分类下
+        return {
+          icon: component.icon,
+          name: component.name,
+          label: component.label
+        }
+      })
+    }
+    designer.value?.addMenu(menu)
+  }
+
+  onMounted(async () => {
+    await nextTick()
+    buildFormComponents()
+    buildSystemMenu()
+  })
+}

+ 51 - 0
src/components/FormCreate/src/useTwinsFormCreateDesigner.ts

@@ -0,0 +1,51 @@
+import { Ref , nextTick } from "vue";
+
+/**
+ * 表单设计器增强 hook
+ * 新增
+ * - 文件上传
+ * - 单图上传
+ * - 多图上传
+ * - 字典选择器
+ * - 用户选择器
+ * - 部门选择器
+ * - 富文本
+ */
+export const useTwinsFormCreateDesigner = async (designer: Ref) => {
+   /**
+   * 构建表单组件
+   */
+  const buildFormComponents = () => {
+    // 移除自带的上传组件规则,使用 uploadFileRule、uploadImgRule、uploadImgsRule 替代
+    designer.value?.removeMenuItem('upload')
+    // 移除自带的富文本组件规则,使用 editorRule 替代
+    designer.value?.removeMenuItem('fc-editor')
+    designer.value?.removeMenuItem('fcRow')
+    designer.value?.removeMenuItem('elTreeSelect')
+    designer.value?.removeMenuItem('tree')
+    designer.value?.removeMenuItem('colorPicker')
+    designer.value?.removeMenuItem('group')
+    designer.value?.removeMenuItem('subForm')
+    designer.value?.removeMenuItem('elButton')
+    designer.value?.removeMenuItem('text')
+    designer.value?.removeMenuItem('elAlert')
+    designer.value?.removeMenuItem('subform')
+    designer.value?.removeMenuItem('text')
+    designer.value?.removeMenuItem('slider')
+    designer.value?.removeMenuItem('rate')
+    designer.value?.removeMenuItem('inputNumber')
+    designer.value?.removeMenuItem('cascader')
+    designer.value?.removeMenuItem('datePicker')
+    designer.value?.removeMenuItem('el-transfer')
+    designer.value?.removeMenuItem('dataTable')
+    designer.value?.removeMenu('layout')
+    designer.value?.removeMenu('aide')
+
+  }
+
+  
+  onMounted(async () => {
+    await nextTick()
+    buildFormComponents()
+   })
+}

+ 4 - 0
src/components/FormCreate/twins.ts

@@ -0,0 +1,4 @@
+import { useTwinsFormCreateDesigner } from './src/useTwinsFormCreateDesigner'
+import { useApiSelect } from './src/components/useApiSelect'
+
+export { useTwinsFormCreateDesigner, useApiSelect }

+ 9 - 0
src/components/ImageViewer/src/types.ts

@@ -0,0 +1,9 @@
+export interface ImageViewerProps {
+  urlList?: string[]
+  zIndex?: number
+  initialIndex?: number
+  infinite?: boolean
+  hideOnClickModal?: boolean
+  teleported?: boolean
+  show?: boolean
+}

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

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

+ 165 - 0
src/components/SimpleProcessDesigner/src/util.ts

@@ -0,0 +1,165 @@
+/**
+ * todo
+ */
+export const arrToStr = (arr?: [{ name: string }]) => {
+  if (arr) {
+    return arr
+      .map((item) => {
+        return item.name
+      })
+      .toString()
+  }
+}
+
+export const setApproverStr = (nodeConfig: any) => {
+  if (nodeConfig.settype == 1) {
+    if (nodeConfig.nodeUserList.length == 1) {
+      return nodeConfig.nodeUserList[0].name
+    } else if (nodeConfig.nodeUserList.length > 1) {
+      if (nodeConfig.examineMode == 1) {
+        return arrToStr(nodeConfig.nodeUserList)
+      } else if (nodeConfig.examineMode == 2) {
+        return nodeConfig.nodeUserList.length + '人会签'
+      }
+    }
+  } else if (nodeConfig.settype == 2) {
+    const level =
+      nodeConfig.directorLevel == 1 ? '直接主管' : '第' + nodeConfig.directorLevel + '级主管'
+    if (nodeConfig.examineMode == 1) {
+      return level
+    } else if (nodeConfig.examineMode == 2) {
+      return level + '会签'
+    }
+  } else if (nodeConfig.settype == 4) {
+    if (nodeConfig.selectRange == 1) {
+      return '发起人自选'
+    } else {
+      if (nodeConfig.nodeUserList.length > 0) {
+        if (nodeConfig.selectRange == 2) {
+          return '发起人自选'
+        } else {
+          return '发起人从' + nodeConfig.nodeUserList[0].name + '中自选'
+        }
+      } else {
+        return ''
+      }
+    }
+  } else if (nodeConfig.settype == 5) {
+    return '发起人自己'
+  } else if (nodeConfig.settype == 7) {
+    return '从直接主管到通讯录中级别最高的第' + nodeConfig.examineEndDirectorLevel + '个层级主管'
+  }
+}
+
+export const copyerStr = (nodeConfig: any) => {
+  if (nodeConfig.nodeUserList.length != 0) {
+    return arrToStr(nodeConfig.nodeUserList)
+  } else {
+    if (nodeConfig.ccSelfSelectFlag == 1) {
+      return '发起人自选'
+    }
+  }
+}
+export const conditionStr = (nodeConfig, index) => {
+  const { conditionList, nodeUserList } = nodeConfig.conditionNodes[index]
+  if (conditionList.length == 0) {
+    return index == nodeConfig.conditionNodes.length - 1 &&
+      nodeConfig.conditionNodes[0].conditionList.length != 0
+      ? '其他条件进入此流程'
+      : '请设置条件'
+  } else {
+    let str = ''
+    for (let i = 0; i < conditionList.length; i++) {
+      const {
+        columnId,
+        columnType,
+        showType,
+        showName,
+        optType,
+        zdy1,
+        opt1,
+        zdy2,
+        opt2,
+        fixedDownBoxValue
+      } = conditionList[i]
+      if (columnId == 0) {
+        if (nodeUserList.length != 0) {
+          str += '发起人属于:'
+          str +=
+            nodeUserList
+              .map((item) => {
+                return item.name
+              })
+              .join('或') + ' 并且 '
+        }
+      }
+      if (columnType == 'String' && showType == '3') {
+        if (zdy1) {
+          str += showName + '属于:' + dealStr(zdy1, JSON.parse(fixedDownBoxValue)) + ' 并且 '
+        }
+      }
+      if (columnType == 'Double') {
+        if (optType != 6 && zdy1) {
+          const optTypeStr = ['', '<', '>', '≤', '=', '≥'][optType]
+          str += `${showName} ${optTypeStr} ${zdy1} 并且 `
+        } else if (optType == 6 && zdy1 && zdy2) {
+          str += `${zdy1} ${opt1} ${showName} ${opt2} ${zdy2} 并且 `
+        }
+      }
+    }
+    return str ? str.substring(0, str.length - 4) : '请设置条件'
+  }
+}
+
+export const dealStr = (str: string, obj) => {
+  const arr = []
+  const list = str.split(',')
+  for (const elem in obj) {
+    list.map((item) => {
+      if (item == elem) {
+        arr.push(obj[elem].value)
+      }
+    })
+  }
+  return arr.join('或')
+}
+
+export const removeEle = (arr, elem, key = 'id') => {
+  let includesIndex
+  arr.map((item, index) => {
+    if (item[key] == elem[key]) {
+      includesIndex = index
+    }
+  })
+  arr.splice(includesIndex, 1)
+}
+
+export const bgColors = ['87, 106, 149', '255, 148, 62', '50, 150, 250']
+export const placeholderList = ['发起人', '审核人', '抄送人']
+export const setTypes = [
+  { value: 1, label: '指定成员' },
+  { value: 2, label: '主管' },
+  { value: 4, label: '发起人自选' },
+  { value: 5, label: '发起人自己' },
+  { value: 7, label: '连续多级主管' }
+]
+
+export const selectModes = [
+  { value: 1, label: '选一个人' },
+  { value: 2, label: '选多个人' }
+]
+
+export const selectRanges = [
+  { value: 1, label: '全公司' },
+  { value: 2, label: '指定成员' },
+  { value: 3, label: '指定角色' }
+]
+
+export const optTypes = [
+  { value: '1', label: '小于' },
+  { value: '2', label: '大于' },
+  { value: '3', label: '小于等于' },
+  { value: '4', label: '等于' },
+  { value: '5', label: '大于等于' },
+  { value: '6', label: '介于两个数之间' }
+]

+ 26 - 0
src/components/Table/src/types.ts

@@ -0,0 +1,26 @@
+import { Pagination, TableColumn } from '@/types/table'
+
+export type TableProps = {
+  pageSize?: number
+  currentPage?: number
+  // 是否多选
+  selection?: boolean
+  // 是否所有的超出隐藏,优先级低于schema中的showOverflowTooltip,
+  showOverflowTooltip?: boolean
+  // 表头
+  columns?: TableColumn[]
+  // 是否展示分页
+  pagination?: Pagination | undefined
+  // 仅对 type=selection 的列有效,类型为 Boolean,为 true 则会在数据更新之后保留之前选中的数据(需指定 row-key)
+  reserveSelection?: boolean
+  // 加载状态
+  loading?: boolean
+  // 是否叠加索引
+  reserveIndex?: boolean
+  // 对齐方式
+  align?: 'left' | 'center' | 'right'
+  // 表头对齐方式
+  headerAlign?: 'left' | 'center' | 'right'
+  data?: Recordable
+  expand?: boolean
+} & Recordable

+ 213 - 0
src/components/UploadFile/src/UploadFile.vue

@@ -0,0 +1,213 @@
+<template>
+  <div class="upload-file">
+    <el-upload
+      ref="uploadRef"
+      v-model:file-list="fileList"
+      :action="uploadUrl"
+      :auto-upload="autoUpload"
+      :before-upload="beforeUpload"
+      :disabled="disabled"
+      :drag="drag"
+      :http-request="httpRequest"
+      :limit="props.limit"
+      :multiple="props.limit > 1"
+      :on-error="excelUploadError"
+      :on-exceed="handleExceed"
+      :on-preview="handlePreview"
+      :on-remove="handleRemove"
+      :on-success="handleFileSuccess"
+      :show-file-list="true"
+      class="upload-file-uploader"
+      name="file"
+    >
+      <el-button v-if="!disabled" type="primary">
+        <Icon icon="ep:upload-filled" />
+        选取文件
+      </el-button>
+      <template v-if="isShowTip && !disabled" #tip>
+        <div style="font-size: 8px">
+          大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
+        </div>
+        <div style="font-size: 8px">
+          格式为 <b style="color: #f56c6c">{{ fileType.join('/') }}</b> 的文件
+        </div>
+      </template>
+      <template #file="row">
+        <div class="flex items-center">
+          <span>{{ row.file.name }}</span>
+          <div class="ml-10px">
+            <el-link
+              :href="row.file.url"
+              :underline="false"
+              download
+              target="_blank"
+              type="primary"
+            >
+              下载
+            </el-link>
+          </div>
+          <div class="ml-10px">
+            <el-button link type="danger" @click="handleRemove(row.file)"> 删除</el-button>
+          </div>
+        </div>
+      </template>
+    </el-upload>
+  </div>
+</template>
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import type { UploadInstance, UploadProps, UploadRawFile, UploadUserFile } from 'element-plus'
+import { isString } from '@/utils/is'
+import { useUpload } from '@/components/UploadFile/src/useUpload'
+import { UploadFile } from 'element-plus/es/components/upload/src/upload'
+
+defineOptions({ name: 'UploadFile' })
+
+const message = useMessage() // 消息弹窗
+const emit = defineEmits(['update:modelValue'])
+
+const props = defineProps({
+  modelValue: propTypes.oneOfType<string | string[]>([String, Array<String>]).isRequired,
+  fileType: propTypes.array.def(['doc', 'xls', 'ppt', 'txt', 'pdf']), // 文件类型, 例如['png', 'jpg', 'jpeg']
+  fileSize: propTypes.number.def(5), // 大小限制(MB)
+  limit: propTypes.number.def(5), // 数量限制
+  autoUpload: propTypes.bool.def(true), // 自动上传
+  drag: propTypes.bool.def(false), // 拖拽上传
+  isShowTip: propTypes.bool.def(true), // 是否显示提示
+  disabled: propTypes.bool.def(false) // 是否禁用上传组件 ==> 非必传(默认为 false)
+})
+
+// ========== 上传相关 ==========
+const uploadRef = ref<UploadInstance>()
+const uploadList = ref<UploadUserFile[]>([])
+const fileList = ref<UploadUserFile[]>([])
+const uploadNumber = ref<number>(0)
+
+const { uploadUrl, httpRequest } = useUpload()
+
+// 文件上传之前判断
+const beforeUpload: UploadProps['beforeUpload'] = (file: UploadRawFile) => {
+  if (fileList.value.length >= props.limit) {
+    message.error(`上传文件数量不能超过${props.limit}个!`)
+    return false
+  }
+  let fileExtension = ''
+  if (file.name.lastIndexOf('.') > -1) {
+    fileExtension = file.name.slice(file.name.lastIndexOf('.') + 1)
+  }
+  const isImg = props.fileType.some((type: string) => {
+    if (file.type.indexOf(type) > -1) return true
+    return !!(fileExtension && fileExtension.indexOf(type) > -1)
+  })
+  const isLimit = file.size < props.fileSize * 1024 * 1024
+  if (!isImg) {
+    message.error(`文件格式不正确, 请上传${props.fileType.join('/')}格式!`)
+    return false
+  }
+  if (!isLimit) {
+    message.error(`上传文件大小不能超过${props.fileSize}MB!`)
+    return false
+  }
+  message.success('正在上传文件,请稍候...')
+  uploadNumber.value++
+}
+// 处理上传的文件发生变化
+// const handleFileChange = (uploadFile: UploadFile): void => {
+//   uploadRef.value.data.path = uploadFile.name
+// }
+// 文件上传成功
+const handleFileSuccess: UploadProps['onSuccess'] = (res: any): void => {
+  message.success('上传成功')
+  // 删除自身
+  const index = fileList.value.findIndex((item) => item.response?.data === res.data)
+  fileList.value.splice(index, 1)
+  uploadList.value.push({ name: res.data, url: res.data })
+  if (uploadList.value.length == uploadNumber.value) {
+    fileList.value.push(...uploadList.value)
+    uploadList.value = []
+    uploadNumber.value = 0
+    emitUpdateModelValue()
+  }
+}
+// 文件数超出提示
+const handleExceed: UploadProps['onExceed'] = (): void => {
+  message.error(`上传文件数量不能超过${props.limit}个!`)
+}
+// 上传错误提示
+const excelUploadError: UploadProps['onError'] = (): void => {
+  message.error('导入数据失败,请您重新上传!')
+}
+// 删除上传文件
+const handleRemove = (file: UploadFile) => {
+  const index = fileList.value.map((f) => f.name).indexOf(file.name)
+  if (index > -1) {
+    fileList.value.splice(index, 1)
+    emitUpdateModelValue()
+  }
+}
+const handlePreview: UploadProps['onPreview'] = (uploadFile) => {
+  console.log(uploadFile)
+}
+
+// 监听模型绑定值变动
+watch(
+  () => props.modelValue,
+  (val: string | string[]) => {
+    if (!val) {
+      fileList.value = [] // fix:处理掉缓存,表单重置后上传组件的内容并没有重置
+      return
+    }
+
+    fileList.value = [] // 保障数据为空
+    // 情况1:字符串
+    if (isString(val)) {
+      fileList.value.push(
+        ...val.split(',').map((url) => ({ name: url.substring(url.lastIndexOf('/') + 1), url }))
+      )
+      return
+    }
+    // 情况2:数组
+    fileList.value.push(
+      ...(val as string[]).map((url) => ({ name: url.substring(url.lastIndexOf('/') + 1), url }))
+    )
+  },
+  { immediate: true, deep: true }
+)
+// 发送文件链接列表更新
+const emitUpdateModelValue = () => {
+  // 情况1:数组结果
+  let result: string | string[] = fileList.value.map((file) => file.url!)
+  // 情况2:逗号分隔的字符串
+  if (props.limit === 1 || isString(props.modelValue)) {
+    result = result.join(',')
+  }
+  emit('update:modelValue', result)
+}
+</script>
+<style lang="scss" scoped>
+.upload-file-uploader {
+  margin-bottom: 5px;
+}
+
+:deep(.upload-file-list .el-upload-list__item) {
+  position: relative;
+  margin-bottom: 10px;
+  line-height: 2;
+  border: 1px solid #e4e7ed;
+}
+
+:deep(.el-upload-list__item-file-name) {
+  max-width: 250px;
+}
+
+:deep(.upload-file-list .ele-upload-list__item-content) {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  color: inherit;
+}
+
+:deep(.ele-upload-list__item-content-action .el-link) {
+  margin-right: 10px;
+}
+</style>

+ 271 - 0
src/components/UploadFile/src/UploadImg.vue

@@ -0,0 +1,271 @@
+<template>
+  <div class="upload-box">
+    <el-upload
+      :id="uuid"
+      :accept="fileType.join(',')"
+      :action="uploadUrl"
+      :before-upload="beforeUpload"
+      :class="['upload', drag ? 'no-border' : '']"
+      :disabled="disabled"
+      :drag="drag"
+      :http-request="httpRequest"
+      :multiple="false"
+      :on-error="uploadError"
+      :on-success="uploadSuccess"
+      :show-file-list="false"
+    >
+      <template v-if="modelValue">
+        <img :src="modelValue" class="upload-image" />
+        <div class="upload-handle" @click.stop>
+          <div v-if="!disabled" class="handle-icon" @click="editImg">
+            <Icon icon="ep:edit" />
+            <span v-if="showBtnText">{{ t('action.edit') }}</span>
+          </div>
+          <div class="handle-icon" @click="imagePreview(modelValue)">
+            <Icon icon="ep:zoom-in" />
+            <span v-if="showBtnText">{{ t('action.detail') }}</span>
+          </div>
+          <div v-if="showDelete && !disabled" class="handle-icon" @click="deleteImg">
+            <Icon icon="ep:delete" />
+            <span v-if="showBtnText">{{ t('action.del') }}</span>
+          </div>
+        </div>
+      </template>
+      <template v-else>
+        <div class="upload-empty">
+          <slot name="empty">
+            <Icon icon="ep:plus" />
+            <!-- <span>请上传图片</span> -->
+          </slot>
+        </div>
+      </template>
+    </el-upload>
+    <div class="el-upload__tip">
+      <slot name="tip"></slot>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import type { UploadProps } from 'element-plus'
+
+import { generateUUID } from '@/utils'
+import { propTypes } from '@/utils/propTypes'
+import { createImageViewer } from '@/components/ImageViewer'
+import { useUpload } from '@/components/UploadFile/src/useUpload'
+
+defineOptions({ name: 'UploadImg' })
+
+type FileTypes =
+  | 'image/apng'
+  | 'image/bmp'
+  | 'image/gif'
+  | 'image/jpeg'
+  | 'image/pjpeg'
+  | 'image/png'
+  | 'image/svg+xml'
+  | 'image/tiff'
+  | 'image/webp'
+  | 'image/x-icon'
+
+// 接受父组件参数
+const props = defineProps({
+  modelValue: propTypes.string.def(''),
+  drag: propTypes.bool.def(true), // 是否支持拖拽上传 ==> 非必传(默认为 true)
+  disabled: propTypes.bool.def(false), // 是否禁用上传组件 ==> 非必传(默认为 false)
+  fileSize: propTypes.number.def(5), // 图片大小限制 ==> 非必传(默认为 5M)
+  fileType: propTypes.array.def(['image/jpeg', 'image/png', 'image/gif']), // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"])
+  height: propTypes.string.def('150px'), // 组件高度 ==> 非必传(默认为 150px)
+  width: propTypes.string.def('150px'), // 组件宽度 ==> 非必传(默认为 150px)
+  borderradius: propTypes.string.def('8px'), // 组件边框圆角 ==> 非必传(默认为 8px)
+  showDelete: propTypes.bool.def(true), // 是否显示删除按钮
+  showBtnText: propTypes.bool.def(true) // 是否显示按钮文字
+})
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+// 生成组件唯一id
+const uuid = ref('id-' + generateUUID())
+// 查看图片
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    zIndex: 9999999,
+    urlList: [imgUrl]
+  })
+}
+
+const emit = defineEmits(['update:modelValue'])
+
+const deleteImg = () => {
+  emit('update:modelValue', '')
+}
+
+const { uploadUrl, httpRequest } = useUpload()
+
+const editImg = () => {
+  const dom = document.querySelector(`#${uuid.value} .el-upload__input`)
+  dom && dom.dispatchEvent(new MouseEvent('click'))
+}
+
+const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
+  const imgSize = rawFile.size / 1024 / 1024 < props.fileSize
+  const imgType = props.fileType
+  if (!imgType.includes(rawFile.type as FileTypes))
+    message.notifyWarning('上传图片不符合所需的格式!')
+  if (!imgSize) message.notifyWarning(`上传图片大小不能超过 ${props.fileSize}M!`)
+  return imgType.includes(rawFile.type as FileTypes) && imgSize
+}
+
+// 图片上传成功提示
+const uploadSuccess: UploadProps['onSuccess'] = (res: any): void => {
+  message.success('上传成功')
+  emit('update:modelValue', res.data)
+}
+
+// 图片上传错误提示
+const uploadError = () => {
+  message.notifyError('图片上传失败,请您重新上传!')
+}
+</script>
+<style lang="scss" scoped>
+.is-error {
+  .upload {
+    :deep(.el-upload),
+    :deep(.el-upload-dragger) {
+      border: 1px dashed var(--el-color-danger) !important;
+
+      &:hover {
+        border-color: var(--el-color-primary) !important;
+      }
+    }
+  }
+}
+
+:deep(.disabled) {
+  .el-upload,
+  .el-upload-dragger {
+    cursor: not-allowed !important;
+    background: var(--el-disabled-bg-color);
+    border: 1px dashed var(--el-border-color-darker) !important;
+
+    &:hover {
+      border: 1px dashed var(--el-border-color-darker) !important;
+    }
+  }
+}
+
+.upload-box {
+  .no-border {
+    :deep(.el-upload) {
+      border: none !important;
+    }
+  }
+
+  :deep(.upload) {
+    .el-upload {
+      position: relative;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: v-bind(width);
+      height: v-bind(height);
+      overflow: hidden;
+      border: 1px dashed var(--el-border-color-darker);
+      border-radius: v-bind(borderradius);
+      transition: var(--el-transition-duration-fast);
+
+      &:hover {
+        border-color: var(--el-color-primary);
+
+        .upload-handle {
+          opacity: 1;
+        }
+      }
+
+      .el-upload-dragger {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 100%;
+        height: 100%;
+        padding: 0;
+        overflow: hidden;
+        background-color: transparent;
+        border: 1px dashed var(--el-border-color-darker);
+        border-radius: v-bind(borderradius);
+
+        &:hover {
+          border: 1px dashed var(--el-color-primary);
+        }
+      }
+
+      .el-upload-dragger.is-dragover {
+        background-color: var(--el-color-primary-light-9);
+        border: 2px dashed var(--el-color-primary) !important;
+      }
+
+      .upload-image {
+        width: 100%;
+        height: 100%;
+        object-fit: contain;
+      }
+
+      .upload-empty {
+        position: relative;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        font-size: 12px;
+        line-height: 30px;
+        color: var(--el-color-info);
+
+        .el-icon {
+          font-size: 28px;
+          color: var(--el-text-color-secondary);
+        }
+      }
+
+      .upload-handle {
+        position: absolute;
+        top: 0;
+        right: 0;
+        display: flex;
+        width: 100%;
+        height: 100%;
+        cursor: pointer;
+        background: rgb(0 0 0 / 60%);
+        opacity: 0;
+        box-sizing: border-box;
+        transition: var(--el-transition-duration-fast);
+        align-items: center;
+        justify-content: center;
+
+        .handle-icon {
+          display: flex;
+          flex-direction: column;
+          align-items: center;
+          justify-content: center;
+          padding: 0 6%;
+          color: aliceblue;
+
+          .el-icon {
+            margin-bottom: 40%;
+            font-size: 130%;
+            line-height: 130%;
+          }
+
+          span {
+            font-size: 85%;
+            line-height: 85%;
+          }
+        }
+      }
+    }
+  }
+
+  .el-upload__tip {
+    line-height: 18px;
+    text-align: center;
+  }
+}
+</style>

+ 323 - 0
src/components/UploadFile/src/UploadImgs.vue

@@ -0,0 +1,323 @@
+<template>
+  <div class="upload-box">
+    <el-upload
+      v-model:file-list="fileList"
+      :accept="fileType.join(',')"
+      :action="uploadUrl"
+      :before-upload="beforeUpload"
+      :class="['upload', drag ? 'no-border' : '']"
+      :disabled="disabled"
+      :drag="drag"
+      :http-request="httpRequest"
+      :limit="limit"
+      :multiple="true"
+      :on-error="uploadError"
+      :on-exceed="handleExceed"
+      :on-success="uploadSuccess"
+      list-type="picture-card"
+    >
+      <div class="upload-empty">
+        <slot name="empty">
+          <Icon icon="ep:plus" />
+          <!-- <span>请上传图片</span> -->
+        </slot>
+      </div>
+      <template #file="{ file }">
+        <img :src="file.url" class="upload-image" />
+        <div class="upload-handle" @click.stop>
+          <div class="handle-icon" @click="handlePictureCardPreview(file)">
+            <Icon icon="ep:zoom-in" />
+            <span>查看</span>
+          </div>
+          <div v-if="!disabled" class="handle-icon" @click="handleRemove(file)">
+            <Icon icon="ep:delete" />
+            <span>删除</span>
+          </div>
+        </div>
+      </template>
+    </el-upload>
+    <div class="el-upload__tip">
+      <slot name="tip"></slot>
+    </div>
+    <el-image-viewer
+      v-if="imgViewVisible"
+      :url-list="[viewImageUrl]"
+      @close="imgViewVisible = false"
+    />
+  </div>
+</template>
+<script lang="ts" setup>
+import type { UploadFile, UploadProps, UploadUserFile } from 'element-plus'
+import { ElNotification } from 'element-plus'
+
+import { propTypes } from '@/utils/propTypes'
+import { useUpload } from '@/components/UploadFile/src/useUpload'
+
+defineOptions({ name: 'UploadImgs' })
+
+const message = useMessage() // 消息弹窗
+
+type FileTypes =
+  | 'image/apng'
+  | 'image/bmp'
+  | 'image/gif'
+  | 'image/jpeg'
+  | 'image/pjpeg'
+  | 'image/png'
+  | 'image/svg+xml'
+  | 'image/tiff'
+  | 'image/webp'
+  | 'image/x-icon'
+
+const props = defineProps({
+  modelValue: propTypes.oneOfType<string | string[]>([String, Array<String>]).isRequired,
+  drag: propTypes.bool.def(true), // 是否支持拖拽上传 ==> 非必传(默认为 true)
+  disabled: propTypes.bool.def(false), // 是否禁用上传组件 ==> 非必传(默认为 false)
+  limit: propTypes.number.def(5), // 最大图片上传数 ==> 非必传(默认为 5张)
+  fileSize: propTypes.number.def(5), // 图片大小限制 ==> 非必传(默认为 5M)
+  fileType: propTypes.array.def(['image/jpeg', 'image/png', 'image/gif']), // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"])
+  height: propTypes.string.def('150px'), // 组件高度 ==> 非必传(默认为 150px)
+  width: propTypes.string.def('150px'), // 组件宽度 ==> 非必传(默认为 150px)
+  borderradius: propTypes.string.def('8px') // 组件边框圆角 ==> 非必传(默认为 8px)
+})
+
+const { uploadUrl, httpRequest } = useUpload()
+
+const fileList = ref<UploadUserFile[]>([])
+const uploadNumber = ref<number>(0)
+const uploadList = ref<UploadUserFile[]>([])
+/**
+ * @description 文件上传之前判断
+ * @param rawFile 上传的文件
+ * */
+const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
+  const imgSize = rawFile.size / 1024 / 1024 < props.fileSize
+  const imgType = props.fileType
+  if (!imgType.includes(rawFile.type as FileTypes))
+    ElNotification({
+      title: '温馨提示',
+      message: '上传图片不符合所需的格式!',
+      type: 'warning'
+    })
+  if (!imgSize)
+    ElNotification({
+      title: '温馨提示',
+      message: `上传图片大小不能超过 ${props.fileSize}M!`,
+      type: 'warning'
+    })
+  uploadNumber.value++
+  return imgType.includes(rawFile.type as FileTypes) && imgSize
+}
+
+// 图片上传成功
+interface UploadEmits {
+  (e: 'update:modelValue', value: string[]): void
+}
+
+const emit = defineEmits<UploadEmits>()
+const uploadSuccess: UploadProps['onSuccess'] = (res: any): void => {
+  message.success('上传成功')
+  // 删除自身
+  const index = fileList.value.findIndex((item) => item.response?.data === res.data)
+  fileList.value.splice(index, 1)
+  uploadList.value.push({ name: res.data, url: res.data })
+  if (uploadList.value.length == uploadNumber.value) {
+    fileList.value.push(...uploadList.value)
+    uploadList.value = []
+    uploadNumber.value = 0
+    emitUpdateModelValue()
+  }
+}
+
+// 监听模型绑定值变动
+watch(
+  () => props.modelValue,
+  (val: string | string[]) => {
+    if (!val) {
+      fileList.value = [] // fix:处理掉缓存,表单重置后上传组件的内容并没有重置
+      return
+    }
+
+    fileList.value = [] // 保障数据为空
+    fileList.value.push(
+      ...(val as string[]).map((url) => ({ name: url.substring(url.lastIndexOf('/') + 1), url }))
+    )
+  },
+  { immediate: true, deep: true }
+)
+// 发送图片链接列表更新
+const emitUpdateModelValue = () => {
+  let result: string[] = fileList.value.map((file) => file.url!)
+  emit('update:modelValue', result)
+}
+// 删除图片
+const handleRemove = (uploadFile: UploadFile) => {
+  fileList.value = fileList.value.filter(
+    (item) => item.url !== uploadFile.url || item.name !== uploadFile.name
+  )
+  emit(
+    'update:modelValue',
+    fileList.value.map((file) => file.url!)
+  )
+}
+
+// 图片上传错误提示
+const uploadError = () => {
+  ElNotification({
+    title: '温馨提示',
+    message: '图片上传失败,请您重新上传!',
+    type: 'error'
+  })
+}
+
+// 文件数超出提示
+const handleExceed = () => {
+  ElNotification({
+    title: '温馨提示',
+    message: `当前最多只能上传 ${props.limit} 张图片,请移除后上传!`,
+    type: 'warning'
+  })
+}
+
+// 图片预览
+const viewImageUrl = ref('')
+const imgViewVisible = ref(false)
+const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
+  viewImageUrl.value = uploadFile.url!
+  imgViewVisible.value = true
+}
+</script>
+
+<style lang="scss" scoped>
+.is-error {
+  .upload {
+    :deep(.el-upload--picture-card),
+    :deep(.el-upload-dragger) {
+      border: 1px dashed var(--el-color-danger) !important;
+
+      &:hover {
+        border-color: var(--el-color-primary) !important;
+      }
+    }
+  }
+}
+
+:deep(.disabled) {
+  .el-upload--picture-card,
+  .el-upload-dragger {
+    cursor: not-allowed;
+    background: var(--el-disabled-bg-color) !important;
+    border: 1px dashed var(--el-border-color-darker);
+
+    &:hover {
+      border-color: var(--el-border-color-darker) !important;
+    }
+  }
+}
+
+.upload-box {
+  .no-border {
+    :deep(.el-upload--picture-card) {
+      border: none !important;
+    }
+  }
+
+  :deep(.upload) {
+    .el-upload-dragger {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 100%;
+      height: 100%;
+      padding: 0;
+      overflow: hidden;
+      border: 1px dashed var(--el-border-color-darker);
+      border-radius: v-bind(borderradius);
+
+      &:hover {
+        border: 1px dashed var(--el-color-primary);
+      }
+    }
+
+    .el-upload-dragger.is-dragover {
+      background-color: var(--el-color-primary-light-9);
+      border: 2px dashed var(--el-color-primary) !important;
+    }
+
+    .el-upload-list__item,
+    .el-upload--picture-card {
+      width: v-bind(width);
+      height: v-bind(height);
+      background-color: transparent;
+      border-radius: v-bind(borderradius);
+    }
+
+    .upload-image {
+      width: 100%;
+      height: 100%;
+      object-fit: contain;
+    }
+
+    .upload-handle {
+      position: absolute;
+      top: 0;
+      right: 0;
+      display: flex;
+      width: 100%;
+      height: 100%;
+      cursor: pointer;
+      background: rgb(0 0 0 / 60%);
+      opacity: 0;
+      box-sizing: border-box;
+      transition: var(--el-transition-duration-fast);
+      align-items: center;
+      justify-content: center;
+
+      .handle-icon {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        padding: 0 6%;
+        color: aliceblue;
+
+        .el-icon {
+          margin-bottom: 15%;
+          font-size: 140%;
+        }
+
+        span {
+          font-size: 100%;
+        }
+      }
+    }
+
+    .el-upload-list__item {
+      &:hover {
+        .upload-handle {
+          opacity: 1;
+        }
+      }
+    }
+
+    .upload-empty {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      font-size: 12px;
+      line-height: 30px;
+      color: var(--el-color-info);
+
+      .el-icon {
+        font-size: 28px;
+        color: var(--el-text-color-secondary);
+      }
+    }
+  }
+
+  .el-upload__tip {
+    line-height: 15px;
+    text-align: center;
+  }
+}
+</style>

+ 271 - 0
src/components/UploadFile/src/UploadThumbnail.vue

@@ -0,0 +1,271 @@
+<template>
+  <div class="upload-box">
+    <el-upload
+      :id="uuid"
+      :accept="fileType.join(',')"
+      :action="uploadThumbnailUrl"
+      :before-upload="beforeUpload"
+      :class="['upload', drag ? 'no-border' : '']"
+      :disabled="disabled"
+      :drag="drag"
+      :http-request="httpRequest"
+      :multiple="false"
+      :on-error="uploadError"
+      :on-success="uploadSuccess"
+      :show-file-list="false"
+    >
+      <template v-if="modelValue">
+        <img :src="modelValue" class="upload-image" />
+        <div class="upload-handle" @click.stop>
+          <div v-if="!disabled" class="handle-icon" @click="editImg">
+            <Icon icon="ep:edit" />
+            <span v-if="showBtnText">{{ t('action.edit') }}</span>
+          </div>
+          <div class="handle-icon" @click="imagePreview(modelValue)">
+            <Icon icon="ep:zoom-in" />
+            <span v-if="showBtnText">{{ t('action.detail') }}</span>
+          </div>
+          <div v-if="showDelete && !disabled" class="handle-icon" @click="deleteImg">
+            <Icon icon="ep:delete" />
+            <span v-if="showBtnText">{{ t('action.del') }}</span>
+          </div>
+        </div>
+      </template>
+      <template v-else>
+        <div class="upload-empty">
+          <slot name="empty">
+            <Icon icon="ep:plus" />
+            <!-- <span>请上传图片</span> -->
+          </slot>
+        </div>
+      </template>
+    </el-upload>
+    <div class="el-upload__tip">
+      <slot name="tip"></slot>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import type { UploadProps } from 'element-plus'
+
+import { generateUUID } from '@/utils'
+import { propTypes } from '@/utils/propTypes'
+import { createImageViewer } from '@/components/ImageViewer'
+import {useUploadThumbnail} from "@/components/UploadFile/src/useUploadThumbnail";
+
+defineOptions({ name: 'UploadThumbnail' })
+
+type FileTypes =
+  | 'image/apng'
+  | 'image/bmp'
+  | 'image/gif'
+  | 'image/jpeg'
+  | 'image/pjpeg'
+  | 'image/png'
+  | 'image/svg+xml'
+  | 'image/tiff'
+  | 'image/webp'
+  | 'image/x-icon'
+
+// 接受父组件参数
+const props = defineProps({
+  modelValue: propTypes.string.def(''),
+  drag: propTypes.bool.def(true), // 是否支持拖拽上传 ==> 非必传(默认为 true)
+  disabled: propTypes.bool.def(false), // 是否禁用上传组件 ==> 非必传(默认为 false)
+  fileSize: propTypes.number.def(5), // 图片大小限制 ==> 非必传(默认为 5M)
+  fileType: propTypes.array.def(['image/jpeg', 'image/png', 'image/gif']), // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"])
+  height: propTypes.string.def('150px'), // 组件高度 ==> 非必传(默认为 150px)
+  width: propTypes.string.def('150px'), // 组件宽度 ==> 非必传(默认为 150px)
+  borderradius: propTypes.string.def('8px'), // 组件边框圆角 ==> 非必传(默认为 8px)
+  showDelete: propTypes.bool.def(true), // 是否显示删除按钮
+  showBtnText: propTypes.bool.def(true) // 是否显示按钮文字
+})
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+// 生成组件唯一id
+const uuid = ref('id-' + generateUUID())
+// 查看图片
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    zIndex: 9999999,
+    urlList: [imgUrl]
+  })
+}
+
+const emit = defineEmits(['update:modelValue'])
+
+const deleteImg = () => {
+  emit('update:modelValue', '')
+}
+
+const { uploadThumbnailUrl, httpRequest } = useUploadThumbnail()
+
+const editImg = () => {
+  const dom = document.querySelector(`#${uuid.value} .el-upload__input`)
+  dom && dom.dispatchEvent(new MouseEvent('click'))
+}
+
+const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
+  const imgSize = rawFile.size / 1024 / 1024 < props.fileSize
+  const imgType = props.fileType
+  if (!imgType.includes(rawFile.type as FileTypes))
+    message.notifyWarning('上传图片不符合所需的格式!')
+  if (!imgSize) message.notifyWarning(`上传图片大小不能超过 ${props.fileSize}M!`)
+  return imgType.includes(rawFile.type as FileTypes) && imgSize
+}
+
+// 图片上传成功提示
+const uploadSuccess: UploadProps['onSuccess'] = (res: any): void => {
+  message.success('上传成功')
+  emit('update:modelValue', res.data)
+}
+
+// 图片上传错误提示
+const uploadError = () => {
+  message.notifyError('图片上传失败,请您重新上传!')
+}
+</script>
+<style lang="scss" scoped>
+.is-error {
+  .upload {
+    :deep(.el-upload),
+    :deep(.el-upload-dragger) {
+      border: 1px dashed var(--el-color-danger) !important;
+
+      &:hover {
+        border-color: var(--el-color-primary) !important;
+      }
+    }
+  }
+}
+
+:deep(.disabled) {
+  .el-upload,
+  .el-upload-dragger {
+    cursor: not-allowed !important;
+    background: var(--el-disabled-bg-color);
+    border: 1px dashed var(--el-border-color-darker) !important;
+
+    &:hover {
+      border: 1px dashed var(--el-border-color-darker) !important;
+    }
+  }
+}
+
+.upload-box {
+  .no-border {
+    :deep(.el-upload) {
+      border: none !important;
+    }
+  }
+
+  :deep(.upload) {
+    .el-upload {
+      position: relative;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: v-bind(width);
+      height: v-bind(height);
+      overflow: hidden;
+      border: 1px dashed var(--el-border-color-darker);
+      border-radius: v-bind(borderradius);
+      transition: var(--el-transition-duration-fast);
+
+      &:hover {
+        border-color: var(--el-color-primary);
+
+        .upload-handle {
+          opacity: 1;
+        }
+      }
+
+      .el-upload-dragger {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 100%;
+        height: 100%;
+        padding: 0;
+        overflow: hidden;
+        background-color: transparent;
+        border: 1px dashed var(--el-border-color-darker);
+        border-radius: v-bind(borderradius);
+
+        &:hover {
+          border: 1px dashed var(--el-color-primary);
+        }
+      }
+
+      .el-upload-dragger.is-dragover {
+        background-color: var(--el-color-primary-light-9);
+        border: 2px dashed var(--el-color-primary) !important;
+      }
+
+      .upload-image {
+        width: 100%;
+        height: 100%;
+        object-fit: contain;
+      }
+
+      .upload-empty {
+        position: relative;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        font-size: 12px;
+        line-height: 30px;
+        color: var(--el-color-info);
+
+        .el-icon {
+          font-size: 28px;
+          color: var(--el-text-color-secondary);
+        }
+      }
+
+      .upload-handle {
+        position: absolute;
+        top: 0;
+        right: 0;
+        display: flex;
+        width: 100%;
+        height: 100%;
+        cursor: pointer;
+        background: rgb(0 0 0 / 60%);
+        opacity: 0;
+        box-sizing: border-box;
+        transition: var(--el-transition-duration-fast);
+        align-items: center;
+        justify-content: center;
+
+        .handle-icon {
+          display: flex;
+          flex-direction: column;
+          align-items: center;
+          justify-content: center;
+          padding: 0 6%;
+          color: aliceblue;
+
+          .el-icon {
+            margin-bottom: 40%;
+            font-size: 130%;
+            line-height: 130%;
+          }
+
+          span {
+            font-size: 85%;
+            line-height: 85%;
+          }
+        }
+      }
+    }
+  }
+
+  .el-upload__tip {
+    line-height: 18px;
+    text-align: center;
+  }
+}
+</style>

+ 97 - 0
src/components/UploadFile/src/useUpload.ts

@@ -0,0 +1,97 @@
+import * as FileApi from '@/api/infra/file'
+import CryptoJS from 'crypto-js'
+import { UploadRawFile, UploadRequestOptions } from 'element-plus/es/components/upload/src/upload'
+import axios from 'axios'
+
+export const useUpload = () => {
+  // 后端上传地址
+  const uploadUrl = import.meta.env.VITE_UPLOAD_URL
+  // 是否使用前端直连上传
+  const isClientUpload = UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE
+  // 重写ElUpload上传方法
+  const httpRequest = async (options: UploadRequestOptions) => {
+    // 模式一:前端上传
+    if (isClientUpload) {
+      // 1.1 生成文件名称
+      const fileName = await generateFileName(options.file)
+      // 1.2 获取文件预签名地址
+      const presignedInfo = await FileApi.getFilePresignedUrl(fileName)
+      // 1.3 上传文件(不能使用 ElUpload 的 ajaxUpload 方法的原因:其使用的是 FormData 上传,Minio 不支持)
+      return axios.put(presignedInfo.uploadUrl, options.file, {
+        headers: {
+          'Content-Type': options.file.type,
+        }
+      }).then(() => {
+        // 1.4. 记录文件信息到后端(异步)
+        createFile(presignedInfo, fileName, options.file)
+        // 通知成功,数据格式保持与后端上传的返回结果一致
+        return { data: presignedInfo.url }
+      })
+    } else {
+      // 模式二:后端上传
+      // 重写 el-upload httpRequest 文件上传成功会走成功的钩子,失败走失败的钩子
+      return new Promise((resolve, reject) => {
+        FileApi.updateFile({ file: options.file })
+          .then((res) => {
+            if (res.code === 0) {
+              resolve(res)
+            } else {
+              reject(res)
+            }
+          })
+          .catch((res) => {
+            reject(res)
+          })
+      })
+    }
+  }
+
+  return {
+    uploadUrl,
+    httpRequest
+  }
+}
+
+/**
+ * 创建文件信息
+ * @param vo 文件预签名信息
+ * @param name 文件名称
+ * @param file 文件
+ */
+function createFile(vo: FileApi.FilePresignedUrlRespVO, name: string, file: UploadRawFile) {
+  const fileVo = {
+    configId: vo.configId,
+    url: vo.url,
+    path: name,
+    name: file.name,
+    type: file.type,
+    size: file.size
+  }
+  FileApi.createFile(fileVo)
+  return fileVo
+}
+
+/**
+ * 生成文件名称(使用算法SHA256)
+ * @param file 要上传的文件
+ */
+async function generateFileName(file: UploadRawFile) {
+  // 读取文件内容
+  const data = await file.arrayBuffer()
+  const wordArray = CryptoJS.lib.WordArray.create(data)
+  // 计算SHA256
+  const sha256 = CryptoJS.SHA256(wordArray).toString()
+  // 拼接后缀
+  const ext = file.name.substring(file.name.lastIndexOf('.'))
+  return `${sha256}${ext}`
+}
+
+/**
+ * 上传类型
+ */
+enum UPLOAD_TYPE {
+  // 客户端直接上传(只支持S3服务)
+  CLIENT = 'client',
+  // 客户端发送到后端上传
+  SERVER = 'server'
+}

+ 99 - 0
src/components/UploadFile/src/useUploadThumbnail.ts

@@ -0,0 +1,99 @@
+import * as FileApi from '@/api/infra/file'
+import CryptoJS from 'crypto-js'
+import { UploadRawFile, UploadRequestOptions } from 'element-plus/es/components/upload/src/upload'
+import axios from 'axios'
+
+export const useUploadThumbnail = () => {
+  // 后端上传地址
+  const uploadThumbnailUrl = import.meta.env.VITE_UPLOAD_URL
+  // 是否使用前端直连上传
+  const isClientUpload = UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE
+  // 重写ElUpload上传方法
+  const httpRequest = async (options: UploadRequestOptions) => {
+    // 模式一:前端上传
+    if (isClientUpload) {
+      // 1.1 生成文件名称
+      const fileName = await generateFileName(options.file)
+      // 1.2 获取文件预签名地址
+      const presignedInfo = await FileApi.getFilePresignedUrl(fileName)
+      // 1.3 上传文件(不能使用 ElUpload 的 ajaxUpload 方法的原因:其使用的是 FormData 上传,Minio 不支持)
+      return axios.put(presignedInfo.uploadUrl, options.file, {
+        headers: {
+          'Content-Type': options.file.type,
+        }
+      }).then(() => {
+        // 1.4. 记录文件信息到后端(异步)
+        createFile(presignedInfo, fileName, options.file)
+        // 通知成功,数据格式保持与后端上传的返回结果一致
+        return { data: presignedInfo.url }
+      })
+    } else {
+      // 模式二:后端上传
+      // 重写 el-upload httpRequest 文件上传成功会走成功的钩子,失败走失败的钩子
+      return new Promise((resolve, reject) => {
+        FileApi.uploadThumbnail({ file: options.file })
+          .then((res) => {
+            if (res.code === 0) {
+              resolve(res)
+            } else {
+              reject(res)
+            }
+          })
+          .catch((res) => {
+            reject(res)
+          })
+      })
+    }
+  }
+
+  return {
+    uploadThumbnailUrl,
+    httpRequest
+  }
+}
+
+/**
+ * 创建文件信息
+ * @param vo 文件预签名信息
+ * @param name 文件名称
+ * @param file 文件
+ */
+function createFile(vo: FileApi.FilePresignedUrlRespVO, name: string, file: UploadRawFile) {
+  const fileVo = {
+    configId: vo.configId,
+    url: vo.url,
+    path: name,
+    name: file.name,
+    type: file.type,
+    size: file.size
+  }
+  FileApi.createFile(fileVo)
+  return fileVo
+}
+
+
+
+/**
+ * 生成文件名称(使用算法SHA256)
+ * @param file 要上传的文件
+ */
+async function generateFileName(file: UploadRawFile) {
+  // 读取文件内容
+  const data = await file.arrayBuffer()
+  const wordArray = CryptoJS.lib.WordArray.create(data)
+  // 计算SHA256
+  const sha256 = CryptoJS.SHA256(wordArray).toString()
+  // 拼接后缀
+  const ext = file.name.substring(file.name.lastIndexOf('.'))
+  return `${sha256}${ext}`
+}
+
+/**
+ * 上传类型
+ */
+enum UPLOAD_TYPE {
+  // 客户端直接上传(只支持S3服务)
+  CLIENT = 'client',
+  // 客户端发送到后端上传
+  SERVER = 'server'
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 373 - 0
src/components/Verifition/src/Verify.vue


+ 250 - 0
src/components/Verifition/src/Verify/VerifyPoints.vue

@@ -0,0 +1,250 @@
+<template>
+  <div style="position: relative">
+    <div class="verify-img-out">
+      <div
+        :style="{
+          width: setSize.imgWidth,
+          height: setSize.imgHeight,
+          'background-size': setSize.imgWidth + ' ' + setSize.imgHeight,
+          'margin-bottom': vSpace + 'px'
+        }"
+        class="verify-img-panel"
+      >
+        <div v-show="showRefresh" class="verify-refresh" style="z-index: 3" @click="refresh">
+          <i class="iconfont icon-refresh"></i>
+        </div>
+        <img
+          ref="canvas"
+          :src="'data:image/png;base64,' + pointBackImgBase"
+          alt=""
+          style="display: block; width: 100%; height: 100%"
+          @click="bindingClick ? canvasClick($event) : undefined"
+        />
+
+        <div
+          v-for="(tempPoint, index) in tempPoints"
+          :key="index"
+          :style="{
+            'background-color': '#1abd6c',
+            color: '#fff',
+            'z-index': 9999,
+            width: '20px',
+            height: '20px',
+            'text-align': 'center',
+            'line-height': '20px',
+            'border-radius': '50%',
+            position: 'absolute',
+            top: parseInt(tempPoint.y - 10) + 'px',
+            left: parseInt(tempPoint.x - 10) + 'px'
+          }"
+          class="point-area"
+        >
+          {{ index + 1 }}
+        </div>
+      </div>
+    </div>
+    <!-- 'height': this.barSize.height, -->
+    <div
+      :style="{
+        width: setSize.imgWidth,
+        color: barAreaColor,
+        'border-color': barAreaBorderColor,
+        'line-height': barSize.height
+      }"
+      class="verify-bar-area"
+    >
+      <span class="verify-msg">{{ text }}</span>
+    </div>
+  </div>
+</template>
+<script setup type="text/babel">
+/**
+ * VerifyPoints
+ * @description 点选
+ * */
+import { resetSize } from './../utils/util'
+import { aesEncrypt } from './../utils/ase'
+import { getCode, reqCheck } from '@/api/login'
+import { getCurrentInstance, nextTick, onMounted, reactive, ref, toRefs } from 'vue'
+
+const props = defineProps({
+  //弹出式pop,固定fixed
+  mode: {
+    type: String,
+    default: 'fixed'
+  },
+  captchaType: {
+    type: String
+  },
+  //间隔
+  vSpace: {
+    type: Number,
+    default: 5
+  },
+  imgSize: {
+    type: Object,
+    default() {
+      return {
+        width: '310px',
+        height: '155px'
+      }
+    }
+  },
+  barSize: {
+    type: Object,
+    default() {
+      return {
+        width: '310px',
+        height: '40px'
+      }
+    }
+  }
+})
+
+const { t } = useI18n()
+const { mode, captchaType } = toRefs(props)
+const { proxy } = getCurrentInstance()
+let secretKey = ref(''), //后端返回的ase加密秘钥
+  checkNum = ref(3), //默认需要点击的字数
+  fontPos = reactive([]), //选中的坐标信息
+  checkPosArr = reactive([]), //用户点击的坐标
+  num = ref(1), //点击的记数
+  pointBackImgBase = ref(''), //后端获取到的背景图片
+  poinTextList = reactive([]), //后端返回的点击字体顺序
+  backToken = ref(''), //后端返回的token值
+  setSize = reactive({
+    imgHeight: 0,
+    imgWidth: 0,
+    barHeight: 0,
+    barWidth: 0
+  }),
+  tempPoints = reactive([]),
+  text = ref(''),
+  barAreaColor = ref(undefined),
+  barAreaBorderColor = ref(undefined),
+  showRefresh = ref(true),
+  bindingClick = ref(true)
+
+const init = () => {
+  //加载页面
+  fontPos.splice(0, fontPos.length)
+  checkPosArr.splice(0, checkPosArr.length)
+  num.value = 1
+  getPictrue()
+  nextTick(() => {
+    let { imgHeight, imgWidth, barHeight, barWidth } = resetSize(proxy)
+    setSize.imgHeight = imgHeight
+    setSize.imgWidth = imgWidth
+    setSize.barHeight = barHeight
+    setSize.barWidth = barWidth
+    proxy.$parent.$emit('ready', proxy)
+  })
+}
+onMounted(() => {
+  // 禁止拖拽
+  init()
+  proxy.$el.onselectstart = function () {
+    return false
+  }
+})
+const canvas = ref(null)
+const canvasClick = (e) => {
+  checkPosArr.push(getMousePos(canvas, e))
+  if (num.value == checkNum.value) {
+    num.value = createPoint(getMousePos(canvas, e))
+    //按比例转换坐标值
+    let arr = pointTransfrom(checkPosArr, setSize)
+    checkPosArr.length = 0
+    checkPosArr.push(...arr)
+    //等创建坐标执行完
+    setTimeout(() => {
+      // var flag = this.comparePos(this.fontPos, this.checkPosArr);
+      //发送后端请求
+      var captchaVerification = secretKey.value
+        ? aesEncrypt(backToken.value + '---' + JSON.stringify(checkPosArr), secretKey.value)
+        : backToken.value + '---' + JSON.stringify(checkPosArr)
+      let data = {
+        captchaType: captchaType.value,
+        pointJson: secretKey.value
+          ? aesEncrypt(JSON.stringify(checkPosArr), secretKey.value)
+          : JSON.stringify(checkPosArr),
+        token: backToken.value
+      }
+      reqCheck(data).then((res) => {
+        if (res.repCode == '0000') {
+          barAreaColor.value = '#4cae4c'
+          barAreaBorderColor.value = '#5cb85c'
+          text.value = t('captcha.success')
+          bindingClick.value = false
+          if (mode.value == 'pop') {
+            setTimeout(() => {
+              proxy.$parent.clickShow = false
+              refresh()
+            }, 1500)
+          }
+          proxy.$parent.$emit('success', { captchaVerification })
+        } else {
+          proxy.$parent.$emit('error', proxy)
+          barAreaColor.value = '#d9534f'
+          barAreaBorderColor.value = '#d9534f'
+          text.value = t('captcha.fail')
+          setTimeout(() => {
+            refresh()
+          }, 700)
+        }
+      })
+    }, 400)
+  }
+  if (num.value < checkNum.value) {
+    num.value = createPoint(getMousePos(canvas, e))
+  }
+}
+//获取坐标
+const getMousePos = function (obj, e) {
+  var x = e.offsetX
+  var y = e.offsetY
+  return { x, y }
+}
+//创建坐标点
+const createPoint = function (pos) {
+  tempPoints.push(Object.assign({}, pos))
+  return num.value + 1
+}
+const refresh = async function () {
+  tempPoints.splice(0, tempPoints.length)
+  barAreaColor.value = '#000'
+  barAreaBorderColor.value = '#ddd'
+  bindingClick.value = true
+  fontPos.splice(0, fontPos.length)
+  checkPosArr.splice(0, checkPosArr.length)
+  num.value = 1
+  await getPictrue()
+  showRefresh.value = true
+}
+
+// 请求背景图片和验证图片
+const getPictrue = async () => {
+  let data = {
+    captchaType: captchaType.value
+  }
+  const res = await getCode(data)
+  if (res.repCode == '0000') {
+    pointBackImgBase.value = res.repData.originalImageBase64
+    backToken.value = res.repData.token
+    secretKey.value = res.repData.secretKey
+    poinTextList.value = res.repData.wordList
+    text.value = t('captcha.point') + '【' + poinTextList.value.join(',') + '】'
+  } else {
+    text.value = res.repMsg
+  }
+}
+//坐标转换函数
+const pointTransfrom = function (pointArr, imgSize) {
+  var newPointArr = pointArr.map((p) => {
+    let x = Math.round((310 * p.x) / parseInt(imgSize.imgWidth))
+    let y = Math.round((155 * p.y) / parseInt(imgSize.imgHeight))
+    return { x, y }
+  })
+  return newPointArr
+}
+</script>

+ 376 - 0
src/components/Verifition/src/Verify/VerifySlide.vue

@@ -0,0 +1,376 @@
+<template>
+  <div style="position: relative">
+    <div
+      v-if="type === '2'"
+      :style="{ height: parseInt(setSize.imgHeight) + vSpace + 'px' }"
+      class="verify-img-out"
+    >
+      <div :style="{ width: setSize.imgWidth, height: setSize.imgHeight }" class="verify-img-panel">
+        <img
+          :src="'data:image/png;base64,' + backImgBase"
+          alt=""
+          style="display: block; width: 100%; height: 100%"
+        />
+        <div v-show="showRefresh" class="verify-refresh" @click="refresh">
+          <i class="iconfont icon-refresh"></i>
+        </div>
+        <transition name="tips">
+          <span v-if="tipWords" :class="passFlag ? 'suc-bg' : 'err-bg'" class="verify-tips">
+            {{ tipWords }}
+          </span>
+        </transition>
+      </div>
+    </div>
+    <!-- 公共部分 -->
+    <div
+      :style="{ width: setSize.imgWidth, height: barSize.height, 'line-height': barSize.height }"
+      class="verify-bar-area"
+    >
+      <span class="verify-msg" v-text="text"></span>
+      <div
+        :style="{
+          width: leftBarWidth !== undefined ? leftBarWidth : barSize.height,
+          height: barSize.height,
+          'border-color': leftBarBorderColor,
+          transaction: transitionWidth
+        }"
+        class="verify-left-bar"
+      >
+        <span class="verify-msg" v-text="finishText"></span>
+        <div
+          :style="{
+            width: barSize.height,
+            height: barSize.height,
+            'background-color': moveBlockBackgroundColor,
+            left: moveBlockLeft,
+            transition: transitionLeft
+          }"
+          class="verify-move-block"
+          @mousedown="start"
+          @touchstart="start"
+        >
+          <i :class="['verify-icon iconfont', iconClass]" :style="{ color: iconColor }"></i>
+          <div
+            v-if="type === '2'"
+            :style="{
+              width: Math.floor((parseInt(setSize.imgWidth) * 47) / 310) + 'px',
+              height: setSize.imgHeight,
+              top: '-' + (parseInt(setSize.imgHeight) + vSpace) + 'px',
+              'background-size': setSize.imgWidth + ' ' + setSize.imgHeight
+            }"
+            class="verify-sub-block"
+          >
+            <img
+              :src="'data:image/png;base64,' + blockBackImgBase"
+              alt=""
+              style="display: block; width: 100%; height: 100%; -webkit-user-drag: none"
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+<script setup type="text/babel">
+/**
+ * VerifySlide
+ * @description 滑块
+ * */
+import { aesEncrypt } from './../utils/ase'
+import { resetSize } from './../utils/util'
+import { getCode, reqCheck } from '@/api/login'
+
+const props = defineProps({
+  captchaType: {
+    type: String
+  },
+  type: {
+    type: String,
+    default: '1'
+  },
+  //弹出式pop,固定fixed
+  mode: {
+    type: String,
+    default: 'fixed'
+  },
+  vSpace: {
+    type: Number,
+    default: 5
+  },
+  explain: {
+    type: String,
+    default: ''
+  },
+  imgSize: {
+    type: Object,
+    default() {
+      return {
+        width: '310px',
+        height: '155px'
+      }
+    }
+  },
+  blockSize: {
+    type: Object,
+    default() {
+      return {
+        width: '50px',
+        height: '50px'
+      }
+    }
+  },
+  barSize: {
+    type: Object,
+    default() {
+      return {
+        width: '310px',
+        height: '30px'
+      }
+    }
+  }
+})
+
+const { t } = useI18n()
+const { mode, captchaType, type, blockSize, explain } = toRefs(props)
+const { proxy } = getCurrentInstance()
+let secretKey = ref(''), //后端返回的ase加密秘钥
+  passFlag = ref(''), //是否通过的标识
+  backImgBase = ref(''), //验证码背景图片
+  blockBackImgBase = ref(''), //验证滑块的背景图片
+  backToken = ref(''), //后端返回的唯一token值
+  startMoveTime = ref(''), //移动开始的时间
+  endMovetime = ref(''), //移动结束的时间
+  tipWords = ref(''),
+  text = ref(''),
+  finishText = ref(''),
+  setSize = reactive({
+    imgHeight: 0,
+    imgWidth: 0,
+    barHeight: 0,
+    barWidth: 0
+  }),
+  moveBlockLeft = ref(undefined),
+  leftBarWidth = ref(undefined),
+  // 移动中样式
+  moveBlockBackgroundColor = ref(undefined),
+  leftBarBorderColor = ref('#ddd'),
+  iconColor = ref(undefined),
+  iconClass = ref('icon-right'),
+  status = ref(false), //鼠标状态
+  isEnd = ref(false), //是够验证完成
+  showRefresh = ref(true),
+  transitionLeft = ref(''),
+  transitionWidth = ref(''),
+  startLeft = ref(0)
+
+const barArea = computed(() => {
+  return proxy.$el.querySelector('.verify-bar-area')
+})
+const init = () => {
+  if (explain.value === '') {
+    text.value = t('captcha.slide')
+  } else {
+    text.value = explain.value
+  }
+  getPictrue()
+  nextTick(() => {
+    let { imgHeight, imgWidth, barHeight, barWidth } = resetSize(proxy)
+    setSize.imgHeight = imgHeight
+    setSize.imgWidth = imgWidth
+    setSize.barHeight = barHeight
+    setSize.barWidth = barWidth
+    proxy.$parent.$emit('ready', proxy)
+  })
+
+  window.removeEventListener('touchmove', function (e) {
+    move(e)
+  })
+  window.removeEventListener('mousemove', function (e) {
+    move(e)
+  })
+
+  //鼠标松开
+  window.removeEventListener('touchend', function () {
+    end()
+  })
+  window.removeEventListener('mouseup', function () {
+    end()
+  })
+
+  window.addEventListener('touchmove', function (e) {
+    move(e)
+  })
+  window.addEventListener('mousemove', function (e) {
+    move(e)
+  })
+
+  //鼠标松开
+  window.addEventListener('touchend', function () {
+    end()
+  })
+  window.addEventListener('mouseup', function () {
+    end()
+  })
+}
+watch(type, () => {
+  init()
+})
+onMounted(() => {
+  // 禁止拖拽
+  init()
+  proxy.$el.onselectstart = function () {
+    return false
+  }
+})
+//鼠标按下
+const start = (e) => {
+  e = e || window.event
+  if (!e.touches) {
+    //兼容PC端
+    var x = e.clientX
+  } else {
+    //兼容移动端
+    var x = e.touches[0].pageX
+  }
+  startLeft.value = Math.floor(x - barArea.value.getBoundingClientRect().left)
+  startMoveTime.value = +new Date() //开始滑动的时间
+  if (isEnd.value == false) {
+    text.value = ''
+    moveBlockBackgroundColor.value = '#337ab7'
+    leftBarBorderColor.value = '#337AB7'
+    iconColor.value = '#fff'
+    e.stopPropagation()
+    status.value = true
+  }
+}
+//鼠标移动
+const move = (e) => {
+  e = e || window.event
+  if (status.value && isEnd.value == false) {
+    if (!e.touches) {
+      //兼容PC端
+      var x = e.clientX
+    } else {
+      //兼容移动端
+      var x = e.touches[0].pageX
+    }
+    var bar_area_left = barArea.value.getBoundingClientRect().left
+    var move_block_left = x - bar_area_left //小方块相对于父元素的left值
+    if (
+      move_block_left >=
+      barArea.value.offsetWidth - parseInt(parseInt(blockSize.value.width) / 2) - 2
+    ) {
+      move_block_left =
+        barArea.value.offsetWidth - parseInt(parseInt(blockSize.value.width) / 2) - 2
+    }
+    if (move_block_left <= 0) {
+      move_block_left = parseInt(parseInt(blockSize.value.width) / 2)
+    }
+    //拖动后小方块的left值
+    moveBlockLeft.value = move_block_left - startLeft.value + 'px'
+    leftBarWidth.value = move_block_left - startLeft.value + 'px'
+  }
+}
+
+//鼠标松开
+const end = () => {
+  endMovetime.value = +new Date()
+  //判断是否重合
+  if (status.value && isEnd.value == false) {
+    var moveLeftDistance = parseInt((moveBlockLeft.value || '0').replace('px', ''))
+    moveLeftDistance = (moveLeftDistance * 310) / parseInt(setSize.imgWidth)
+    let data = {
+      captchaType: captchaType.value,
+      pointJson: secretKey.value
+        ? aesEncrypt(JSON.stringify({ x: moveLeftDistance, y: 5.0 }), secretKey.value)
+        : JSON.stringify({ x: moveLeftDistance, y: 5.0 }),
+      token: backToken.value
+    }
+    reqCheck(data).then((res) => {
+      if (res.repCode == '0000') {
+        moveBlockBackgroundColor.value = '#5cb85c'
+        leftBarBorderColor.value = '#5cb85c'
+        iconColor.value = '#fff'
+        iconClass.value = 'icon-check'
+        showRefresh.value = false
+        isEnd.value = true
+        if (mode.value == 'pop') {
+          setTimeout(() => {
+            proxy.$parent.clickShow = false
+            refresh()
+          }, 1500)
+        }
+        passFlag.value = true
+        tipWords.value = `${((endMovetime.value - startMoveTime.value) / 1000).toFixed(2)}s
+            ${t('captcha.success')}`
+        var captchaVerification = secretKey.value
+          ? aesEncrypt(
+              backToken.value + '---' + JSON.stringify({ x: moveLeftDistance, y: 5.0 }),
+              secretKey.value
+            )
+          : backToken.value + '---' + JSON.stringify({ x: moveLeftDistance, y: 5.0 })
+        setTimeout(() => {
+          tipWords.value = ''
+          proxy.$parent.closeBox()
+          proxy.$parent.$emit('success', { captchaVerification })
+        }, 1000)
+      } else {
+        moveBlockBackgroundColor.value = '#d9534f'
+        leftBarBorderColor.value = '#d9534f'
+        iconColor.value = '#fff'
+        iconClass.value = 'icon-close'
+        passFlag.value = false
+        setTimeout(function () {
+          refresh()
+        }, 1000)
+        proxy.$parent.$emit('error', proxy)
+        tipWords.value = t('captcha.fail')
+        setTimeout(() => {
+          tipWords.value = ''
+        }, 1000)
+      }
+    })
+    status.value = false
+  }
+}
+
+const refresh = async () => {
+  showRefresh.value = true
+  finishText.value = ''
+
+  transitionLeft.value = 'left .3s'
+  moveBlockLeft.value = 0
+
+  leftBarWidth.value = undefined
+  transitionWidth.value = 'width .3s'
+
+  leftBarBorderColor.value = '#ddd'
+  moveBlockBackgroundColor.value = '#fff'
+  iconColor.value = '#000'
+  iconClass.value = 'icon-right'
+  isEnd.value = false
+
+  await getPictrue()
+  setTimeout(() => {
+    transitionWidth.value = ''
+    transitionLeft.value = ''
+    text.value = explain.value
+  }, 300)
+}
+
+// 请求背景图片和验证图片
+const getPictrue = async () => {
+  let data = {
+    captchaType: captchaType.value
+  }
+  const res = await getCode(data)
+  if (res.repCode == '0000') {
+    backImgBase.value = res.repData.originalImageBase64
+    blockBackImgBase.value = res.repData.jigsawImageBase64
+    backToken.value = res.repData.token
+    secretKey.value = res.repData.secretKey
+  } else {
+    tipWords.value = res.repMsg
+  }
+}
+</script>

+ 97 - 0
src/components/Verifition/src/utils/util.ts

@@ -0,0 +1,97 @@
+export function resetSize(vm) {
+  let img_width, img_height, bar_width, bar_height //图片的宽度、高度,移动条的宽度、高度
+  const EmployeeWindow = window as any
+  const parentWidth = vm.$el.parentNode.offsetWidth || EmployeeWindow.offsetWidth
+  const parentHeight = vm.$el.parentNode.offsetHeight || EmployeeWindow.offsetHeight
+  if (vm.imgSize.width.indexOf('%') != -1) {
+    img_width = (parseInt(vm.imgSize.width) / 100) * parentWidth + 'px'
+  } else {
+    img_width = vm.imgSize.width
+  }
+
+  if (vm.imgSize.height.indexOf('%') != -1) {
+    img_height = (parseInt(vm.imgSize.height) / 100) * parentHeight + 'px'
+  } else {
+    img_height = vm.imgSize.height
+  }
+
+  if (vm.barSize.width.indexOf('%') != -1) {
+    bar_width = (parseInt(vm.barSize.width) / 100) * parentWidth + 'px'
+  } else {
+    bar_width = vm.barSize.width
+  }
+
+  if (vm.barSize.height.indexOf('%') != -1) {
+    bar_height = (parseInt(vm.barSize.height) / 100) * parentHeight + 'px'
+  } else {
+    bar_height = vm.barSize.height
+  }
+
+  return { imgWidth: img_width, imgHeight: img_height, barWidth: bar_width, barHeight: bar_height }
+}
+
+export const _code_chars = [
+  1,
+  2,
+  3,
+  4,
+  5,
+  6,
+  7,
+  8,
+  9,
+  'a',
+  'b',
+  'c',
+  'd',
+  'e',
+  'f',
+  'g',
+  'h',
+  'i',
+  'j',
+  'k',
+  'l',
+  'm',
+  'n',
+  'o',
+  'p',
+  'q',
+  'r',
+  's',
+  't',
+  'u',
+  'v',
+  'w',
+  'x',
+  'y',
+  'z',
+  'A',
+  'B',
+  'C',
+  'D',
+  'E',
+  'F',
+  'G',
+  'H',
+  'I',
+  'J',
+  'K',
+  'L',
+  'M',
+  'N',
+  'O',
+  'P',
+  'Q',
+  'R',
+  'S',
+  'T',
+  'U',
+  'V',
+  'W',
+  'X',
+  'Y',
+  'Z'
+]
+export const _code_color1 = ['#fffff0', '#f0ffff', '#f0fff0', '#fff0f0']
+export const _code_color2 = ['#FF0033', '#006699', '#993366', '#FF9900', '#66CC66', '#FF33CC']

+ 491 - 0
src/components/bpmnProcessDesigner/package/penal/listeners/UserTaskListeners.vue

@@ -0,0 +1,491 @@
+<template>
+  <div class="panel-tab__content">
+    <el-table :data="elementListenersList" size="small" border>
+      <el-table-column label="序号" width="50px" type="index" />
+      <el-table-column
+        label="事件类型"
+        min-width="80px"
+        show-overflow-tooltip
+        :formatter="(row) => listenerEventTypeObject[row.event]"
+      />
+      <el-table-column label="事件id" min-width="80px" prop="id" show-overflow-tooltip />
+      <el-table-column
+        label="监听器类型"
+        min-width="80px"
+        show-overflow-tooltip
+        :formatter="(row) => listenerTypeObject[row.listenerType]"
+      />
+      <el-table-column label="操作" width="90px">
+        <template #default="scope">
+          <el-button size="small" link @click="openListenerForm(scope.row, scope.$index)"
+            >编辑</el-button
+          >
+          <el-divider direction="vertical" />
+          <el-button
+            size="small"
+            link
+            style="color: #ff4d4f"
+            @click="removeListener(scope.row, scope.$index)"
+            >移除</el-button
+          >
+        </template>
+      </el-table-column>
+    </el-table>
+    <div class="element-drawer__button">
+      <XButton
+        size="small"
+        type="primary"
+        preIcon="ep:plus"
+        title="添加监听器"
+        @click="openListenerForm(null)"
+      />
+      <XButton
+        type="success"
+        preIcon="ep:select"
+        title="选择监听器"
+        size="small"
+        @click="openProcessListenerDialog"
+      />
+    </div>
+
+    <!-- 监听器 编辑/创建 部分 -->
+    <el-drawer
+      v-model="listenerFormModelVisible"
+      title="任务监听器"
+      :size="`${width}px`"
+      append-to-body
+      destroy-on-close
+    >
+      <el-form size="small" :model="listenerForm" label-width="96px" ref="listenerFormRef">
+        <el-form-item
+          label="事件类型"
+          prop="event"
+          :rules="{ required: true, trigger: ['blur', 'change'] }"
+        >
+          <el-select v-model="listenerForm.event">
+            <el-option
+              v-for="i in Object.keys(listenerEventTypeObject)"
+              :key="i"
+              :label="listenerEventTypeObject[i]"
+              :value="i"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item
+          label="监听器ID"
+          prop="id"
+          :rules="{ required: true, trigger: ['blur', 'change'] }"
+        >
+          <el-input v-model="listenerForm.id" clearable />
+        </el-form-item>
+        <el-form-item
+          label="监听器类型"
+          prop="listenerType"
+          :rules="{ required: true, trigger: ['blur', 'change'] }"
+        >
+          <el-select v-model="listenerForm.listenerType">
+            <el-option
+              v-for="i in Object.keys(listenerTypeObject)"
+              :key="i"
+              :label="listenerTypeObject[i]"
+              :value="i"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item
+          v-if="listenerForm.listenerType === 'classListener'"
+          label="Java类"
+          prop="class"
+          key="listener-class"
+          :rules="{ required: true, trigger: ['blur', 'change'] }"
+        >
+          <el-input v-model="listenerForm.class" clearable />
+        </el-form-item>
+        <el-form-item
+          v-if="listenerForm.listenerType === 'expressionListener'"
+          label="表达式"
+          prop="expression"
+          key="listener-expression"
+          :rules="{ required: true, trigger: ['blur', 'change'] }"
+        >
+          <el-input v-model="listenerForm.expression" clearable />
+        </el-form-item>
+        <el-form-item
+          v-if="listenerForm.listenerType === 'delegateExpressionListener'"
+          label="代理表达式"
+          prop="delegateExpression"
+          key="listener-delegate"
+          :rules="{ required: true, trigger: ['blur', 'change'] }"
+        >
+          <el-input v-model="listenerForm.delegateExpression" clearable />
+        </el-form-item>
+        <template v-if="listenerForm.listenerType === 'scriptListener'">
+          <el-form-item
+            label="脚本格式"
+            prop="scriptFormat"
+            key="listener-script-format"
+            :rules="{ required: true, trigger: ['blur', 'change'], message: '请填写脚本格式' }"
+          >
+            <el-input v-model="listenerForm.scriptFormat" clearable />
+          </el-form-item>
+          <el-form-item
+            label="脚本类型"
+            prop="scriptType"
+            key="listener-script-type"
+            :rules="{ required: true, trigger: ['blur', 'change'], message: '请选择脚本类型' }"
+          >
+            <el-select v-model="listenerForm.scriptType">
+              <el-option label="内联脚本" value="inlineScript" />
+              <el-option label="外部脚本" value="externalScript" />
+            </el-select>
+          </el-form-item>
+          <el-form-item
+            v-if="listenerForm.scriptType === 'inlineScript'"
+            label="脚本内容"
+            prop="value"
+            key="listener-script"
+            :rules="{ required: true, trigger: ['blur', 'change'], message: '请填写脚本内容' }"
+          >
+            <el-input v-model="listenerForm.value" clearable />
+          </el-form-item>
+          <el-form-item
+            v-if="listenerForm.scriptType === 'externalScript'"
+            label="资源地址"
+            prop="resource"
+            key="listener-resource"
+            :rules="{ required: true, trigger: ['blur', 'change'], message: '请填写资源地址' }"
+          >
+            <el-input v-model="listenerForm.resource" clearable />
+          </el-form-item>
+        </template>
+
+        <template v-if="listenerForm.event === 'timeout'">
+          <el-form-item label="定时器类型" prop="eventDefinitionType" key="eventDefinitionType">
+            <el-select v-model="listenerForm.eventDefinitionType">
+              <el-option label="日期" value="date" />
+              <el-option label="持续时长" value="duration" />
+              <el-option label="循环" value="cycle" />
+              <el-option label="无" value="null" />
+            </el-select>
+          </el-form-item>
+          <el-form-item
+            v-if="!!listenerForm.eventDefinitionType && listenerForm.eventDefinitionType !== 'null'"
+            label="定时器"
+            prop="eventTimeDefinitions"
+            key="eventTimeDefinitions"
+            :rules="{ required: true, trigger: ['blur', 'change'], message: '请填写定时器配置' }"
+          >
+            <el-input v-model="listenerForm.eventTimeDefinitions" clearable />
+          </el-form-item>
+        </template>
+      </el-form>
+
+      <el-divider />
+      <p class="listener-filed__title">
+        <span><Icon icon="ep:menu" />注入字段:</span>
+        <el-button size="small" type="primary" @click="openListenerFieldForm(null)"
+          >添加字段</el-button
+        >
+      </p>
+      <el-table
+        :data="fieldsListOfListener"
+        size="small"
+        max-height="240"
+        fit
+        border
+        style="flex: none"
+      >
+        <el-table-column label="序号" width="50px" type="index" />
+        <el-table-column label="字段名称" min-width="100px" prop="name" />
+        <el-table-column
+          label="字段类型"
+          min-width="80px"
+          show-overflow-tooltip
+          :formatter="(row) => fieldTypeObject[row.fieldType]"
+        />
+        <el-table-column
+          label="字段值/表达式"
+          min-width="100px"
+          show-overflow-tooltip
+          :formatter="(row) => row.string || row.expression"
+        />
+        <el-table-column label="操作" width="100px">
+          <template #default="scope">
+            <el-button size="small" link @click="openListenerFieldForm(scope.row, scope.$index)"
+              >编辑</el-button
+            >
+            <el-divider direction="vertical" />
+            <el-button
+              size="small"
+              link
+              style="color: #ff4d4f"
+              @click="removeListenerField(scope.row, scope.$index)"
+              >移除</el-button
+            >
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="element-drawer__button">
+        <el-button size="small" @click="listenerFormModelVisible = false">取 消</el-button>
+        <el-button size="small" type="primary" @click="saveListenerConfig">保 存</el-button>
+      </div>
+    </el-drawer>
+
+    <!-- 注入西段 编辑/创建 部分 -->
+    <el-dialog
+      title="字段配置"
+      v-model="listenerFieldFormModelVisible"
+      width="600px"
+      append-to-body
+      destroy-on-close
+    >
+      <el-form
+        :model="listenerFieldForm"
+        size="small"
+        label-width="96px"
+        ref="listenerFieldFormRef"
+        style="height: 136px"
+      >
+        <el-form-item
+          label="字段名称:"
+          prop="name"
+          :rules="{ required: true, trigger: ['blur', 'change'] }"
+        >
+          <el-input v-model="listenerFieldForm.name" clearable />
+        </el-form-item>
+        <el-form-item
+          label="字段类型:"
+          prop="fieldType"
+          :rules="{ required: true, trigger: ['blur', 'change'] }"
+        >
+          <el-select v-model="listenerFieldForm.fieldType">
+            <el-option
+              v-for="i in Object.keys(fieldTypeObject)"
+              :key="i"
+              :label="fieldTypeObject[i]"
+              :value="i"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item
+          v-if="listenerFieldForm.fieldType === 'string'"
+          label="字段值:"
+          prop="string"
+          key="field-string"
+          :rules="{ required: true, trigger: ['blur', 'change'] }"
+        >
+          <el-input v-model="listenerFieldForm.string" clearable />
+        </el-form-item>
+        <el-form-item
+          v-if="listenerFieldForm.fieldType === 'expression'"
+          label="表达式:"
+          prop="expression"
+          key="field-expression"
+          :rules="{ required: true, trigger: ['blur', 'change'] }"
+        >
+          <el-input v-model="listenerFieldForm.expression" clearable />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button size="small" @click="listenerFieldFormModelVisible = false">取 消</el-button>
+        <el-button size="small" type="primary" @click="saveListenerFiled">确 定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+
+  <!-- 选择弹窗 -->
+  <ProcessListenerDialog ref="processListenerDialogRef" @select="selectProcessListener" />
+</template>
+<script lang="ts" setup>
+import { ElMessageBox } from 'element-plus'
+import { createListenerObject, updateElementExtensions } from '../../utils'
+import {
+  initListenerForm,
+  initListenerType,
+  eventType,
+  listenerType,
+  fieldType,
+  initListenerForm2
+} from './utilSelf'
+import ProcessListenerDialog from '@/components/bpmnProcessDesigner/package/penal/listeners/ProcessListenerDialog.vue'
+
+defineOptions({ name: 'UserTaskListeners' })
+
+const props = defineProps({
+  id: String,
+  type: String
+})
+const prefix = inject('prefix')
+const width = inject('width')
+const elementListenersList = ref<any[]>([])
+const listenerEventTypeObject = ref(eventType)
+const listenerTypeObject = ref(listenerType)
+const listenerFormModelVisible = ref(false)
+const listenerForm = ref<any>({})
+const fieldTypeObject = ref(fieldType)
+const fieldsListOfListener = ref<any[]>([])
+const listenerFieldFormModelVisible = ref(false) // 监听器 注入字段表单弹窗 显示状态
+const editingListenerIndex = ref(-1) // 监听器所在下标,-1 为新增
+const editingListenerFieldIndex = ref(-1) // 字段所在下标,-1 为新增
+const listenerFieldForm = ref<any>({}) // 监听器 注入字段 详情表单
+const bpmnElement = ref()
+const bpmnElementListeners = ref()
+const otherExtensionList = ref()
+const listenerFormRef = ref()
+const listenerFieldFormRef = ref()
+const bpmnInstances = () => (window as any)?.bpmnInstances
+
+const resetListenersList = () => {
+  console.log(
+    bpmnInstances().bpmnElement,
+    'window.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElement'
+  )
+  bpmnElement.value = bpmnInstances().bpmnElement
+  otherExtensionList.value = []
+  bpmnElementListeners.value =
+    bpmnElement.value.businessObject?.extensionElements?.values.filter(
+      (ex) => ex.$type === `${prefix}:TaskListener`
+    ) ?? []
+  elementListenersList.value = bpmnElementListeners.value.map((listener) =>
+    initListenerType(listener)
+  )
+}
+const openListenerForm = (listener, index?) => {
+  if (listener) {
+    listenerForm.value = initListenerForm(listener)
+    editingListenerIndex.value = index
+  } else {
+    listenerForm.value = {}
+    editingListenerIndex.value = -1 // 标记为新增
+  }
+  if (listener && listener.fields) {
+    fieldsListOfListener.value = listener.fields.map((field) => ({
+      ...field,
+      fieldType: field.string ? 'string' : 'expression'
+    }))
+  } else {
+    fieldsListOfListener.value = []
+    listenerForm.value['fields'] = []
+  }
+  // 打开侧边栏并清楚验证状态
+  listenerFormModelVisible.value = true
+  nextTick(() => {
+    if (listenerFormRef.value) listenerFormRef.value.clearValidate()
+  })
+}
+// 移除监听器
+const removeListener = (listener, index?) => {
+  console.log(listener, 'listener')
+  ElMessageBox.confirm('确认移除该监听器吗?', '提示', {
+    confirmButtonText: '确 认',
+    cancelButtonText: '取 消'
+  })
+    .then(() => {
+      bpmnElementListeners.value.splice(index, 1)
+      elementListenersList.value.splice(index, 1)
+      updateElementExtensions(
+        bpmnElement.value,
+        otherExtensionList.value.concat(bpmnElementListeners.value)
+      )
+    })
+    .catch(() => console.info('操作取消'))
+}
+// 保存监听器
+const saveListenerConfig = async () => {
+  let validateStatus = await listenerFormRef.value.validate()
+  if (!validateStatus) return // 验证不通过直接返回
+  const listenerObject = createListenerObject(listenerForm.value, true, prefix)
+  if (editingListenerIndex.value === -1) {
+    bpmnElementListeners.value.push(listenerObject)
+    elementListenersList.value.push(listenerForm.value)
+  } else {
+    bpmnElementListeners.value.splice(editingListenerIndex.value, 1, listenerObject)
+    elementListenersList.value.splice(editingListenerIndex.value, 1, listenerForm.value)
+  }
+  // 保存其他配置
+  otherExtensionList.value =
+    bpmnElement.value.businessObject?.extensionElements?.values?.filter(
+      (ex) => ex.$type !== `${prefix}:TaskListener`
+    ) ?? []
+  updateElementExtensions(
+    bpmnElement.value,
+    otherExtensionList.value.concat(bpmnElementListeners.value)
+  )
+  // 4. 隐藏侧边栏
+  listenerFormModelVisible.value = false
+  listenerForm.value = {}
+}
+// 打开监听器字段编辑弹窗
+const openListenerFieldForm = (field, index?) => {
+  listenerFieldForm.value = field ? JSON.parse(JSON.stringify(field)) : {}
+  editingListenerFieldIndex.value = field ? index : -1
+  listenerFieldFormModelVisible.value = true
+  nextTick(() => {
+    if (listenerFieldFormRef.value) listenerFieldFormRef.value.clearValidate()
+  })
+}
+// 保存监听器注入字段
+const saveListenerFiled = async () => {
+  let validateStatus = await listenerFieldFormRef.value.validate()
+  if (!validateStatus) return // 验证不通过直接返回
+  if (editingListenerFieldIndex.value === -1) {
+    fieldsListOfListener.value.push(listenerFieldForm.value)
+    listenerForm.value.fields.push(listenerFieldForm.value)
+  } else {
+    fieldsListOfListener.value.splice(editingListenerFieldIndex.value, 1, listenerFieldForm.value)
+    listenerForm.value.fields.splice(editingListenerFieldIndex.value, 1, listenerFieldForm.value)
+  }
+  listenerFieldFormModelVisible.value = false
+  nextTick(() => {
+    listenerFieldForm.value = {}
+  })
+}
+// 移除监听器字段
+const removeListenerField = (field, index) => {
+  console.log(field, 'field')
+  ElMessageBox.confirm('确认移除该字段吗?', '提示', {
+    confirmButtonText: '确 认',
+    cancelButtonText: '取 消'
+  })
+    .then(() => {
+      fieldsListOfListener.value.splice(index, 1)
+      listenerForm.value.fields.splice(index, 1)
+    })
+    .catch(() => console.info('操作取消'))
+}
+
+// 打开监听器弹窗
+const processListenerDialogRef = ref()
+const openProcessListenerDialog = async () => {
+  processListenerDialogRef.value.open('task')
+}
+const selectProcessListener = (listener) => {
+  const listenerForm = initListenerForm2(listener)
+  const listenerObject = createListenerObject(listenerForm, true, prefix)
+  bpmnElementListeners.value.push(listenerObject)
+  elementListenersList.value.push(listenerForm)
+
+  // 保存其他配置
+  otherExtensionList.value =
+    bpmnElement.value.businessObject?.extensionElements?.values?.filter(
+      (ex) => ex.$type !== `${prefix}:TaskListener`
+    ) ?? []
+  updateElementExtensions(
+    bpmnElement.value,
+    otherExtensionList.value.concat(bpmnElementListeners.value)
+  )
+}
+
+watch(
+  () => props.id,
+  (val) => {
+    val &&
+      val.length &&
+      nextTick(() => {
+        resetListenersList()
+      })
+  },
+  { immediate: true }
+)
+</script>

+ 89 - 0
src/components/bpmnProcessDesigner/package/penal/listeners/utilSelf.ts

@@ -0,0 +1,89 @@
+// 初始化表单数据
+export function initListenerForm(listener) {
+  let self = {
+    ...listener
+  }
+  if (listener.script) {
+    self = {
+      ...listener,
+      ...listener.script,
+      scriptType: listener.script.resource ? 'externalScript' : 'inlineScript'
+    }
+  }
+  if (listener.event === 'timeout' && listener.eventDefinitions) {
+    if (listener.eventDefinitions.length) {
+      let k = ''
+      for (const key in listener.eventDefinitions[0]) {
+        console.log(listener.eventDefinitions, key)
+        if (key.indexOf('time') !== -1) {
+          k = key
+          self.eventDefinitionType = key.replace('time', '').toLowerCase()
+        }
+      }
+      console.log(k)
+      self.eventTimeDefinitions = listener.eventDefinitions[0][k].body
+    }
+  }
+  return self
+}
+
+export function initListenerType(listener) {
+  let listenerType
+  if (listener.class) listenerType = 'classListener'
+  if (listener.expression) listenerType = 'expressionListener'
+  if (listener.delegateExpression) listenerType = 'delegateExpressionListener'
+  if (listener.script) listenerType = 'scriptListener'
+  return {
+    ...JSON.parse(JSON.stringify(listener)),
+    ...(listener.script ?? {}),
+    listenerType: listenerType
+  }
+}
+
+/** 将 ProcessListenerDO 转换成 initListenerForm 想同的 Form 对象 */
+export function initListenerForm2(processListener) {
+  if (processListener.valueType === 'class') {
+    return {
+      listenerType: 'classListener',
+      class: processListener.value,
+      event: processListener.event,
+      fields: []
+    }
+  } else if (processListener.valueType === 'expression') {
+    return {
+      listenerType: 'expressionListener',
+      expression: processListener.value,
+      event: processListener.event,
+      fields: []
+    }
+  } else if (processListener.valueType === 'delegateExpression') {
+    return {
+      listenerType: 'delegateExpressionListener',
+      delegateExpression: processListener.value,
+      event: processListener.event,
+      fields: []
+    }
+  }
+  throw new Error('未知的监听器类型')
+}
+
+export const listenerType = {
+  classListener: 'Java 类',
+  expressionListener: '表达式',
+  delegateExpressionListener: '代理表达式',
+  scriptListener: '脚本'
+}
+
+export const eventType = {
+  create: '创建',
+  assignment: '指派',
+  complete: '完成',
+  delete: '删除',
+  update: '更新',
+  timeout: '超时'
+}
+
+export const fieldType = {
+  string: '字符串',
+  expression: '表达式'
+}

+ 232 - 0
src/components/bpmnProcessDesigner/package/penal/task/task-components/UserTask.vue

@@ -0,0 +1,232 @@
+<template>
+  <el-form label-width="100px">
+    <el-form-item label="规则类型" prop="candidateStrategy">
+      <el-select
+        v-model="userTaskForm.candidateStrategy"
+        clearable
+        style="width: 100%"
+        @change="changeCandidateStrategy"
+      >
+        <el-option
+          v-for="dict in getIntDictOptions(DICT_TYPE.BPM_TASK_CANDIDATE_STRATEGY)"
+          :key="dict.value"
+          :label="dict.label"
+          :value="dict.value"
+        />
+      </el-select>
+    </el-form-item>
+    <el-form-item
+      v-if="userTaskForm.candidateStrategy == 10"
+      label="指定角色"
+      prop="candidateParam"
+    >
+      <el-select
+        v-model="userTaskForm.candidateParam"
+        clearable
+        multiple
+        style="width: 100%"
+        @change="updateElementTask"
+      >
+        <el-option v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
+      </el-select>
+    </el-form-item>
+    <el-form-item
+      v-if="userTaskForm.candidateStrategy == 20 || userTaskForm.candidateStrategy == 21"
+      label="指定部门"
+      prop="candidateParam"
+      span="24"
+    >
+      <el-tree-select
+        ref="treeRef"
+        v-model="userTaskForm.candidateParam"
+        :data="deptTreeOptions"
+        :props="defaultProps"
+        empty-text="加载中,请稍后"
+        multiple
+        node-key="id"
+        show-checkbox
+        @change="updateElementTask"
+      />
+    </el-form-item>
+    <el-form-item
+      v-if="userTaskForm.candidateStrategy == 22"
+      label="指定岗位"
+      prop="candidateParam"
+      span="24"
+    >
+      <el-select
+        v-model="userTaskForm.candidateParam"
+        clearable
+        multiple
+        style="width: 100%"
+        @change="updateElementTask"
+      >
+        <el-option v-for="item in postOptions" :key="item.id" :label="item.name" :value="item.id" />
+      </el-select>
+    </el-form-item>
+    <el-form-item
+      v-if="userTaskForm.candidateStrategy == 30"
+      label="指定用户"
+      prop="candidateParam"
+      span="24"
+    >
+      <el-select
+        v-model="userTaskForm.candidateParam"
+        clearable
+        multiple
+        style="width: 100%"
+        @change="updateElementTask"
+      >
+        <el-option
+          v-for="item in userOptions"
+          :key="item.id"
+          :label="item.nickname"
+          :value="item.id"
+        />
+      </el-select>
+    </el-form-item>
+    <el-form-item
+      v-if="userTaskForm.candidateStrategy === 40"
+      label="指定用户组"
+      prop="candidateParam"
+    >
+      <el-select
+        v-model="userTaskForm.candidateParam"
+        clearable
+        multiple
+        style="width: 100%"
+        @change="updateElementTask"
+      >
+        <el-option
+          v-for="item in userGroupOptions"
+          :key="item.id"
+          :label="item.name"
+          :value="item.id"
+        />
+      </el-select>
+    </el-form-item>
+    <el-form-item
+      v-if="userTaskForm.candidateStrategy === 60"
+      label="流程表达式"
+      prop="candidateParam"
+    >
+      <el-input
+        type="textarea"
+        v-model="userTaskForm.candidateParam[0]"
+        clearable
+        style="width: 72%"
+        @change="updateElementTask"
+      />
+      <el-button class="ml-5px" size="small" type="success" @click="openProcessExpressionDialog"
+        >选择表达式</el-button
+      >
+      <!-- 选择弹窗 -->
+      <ProcessExpressionDialog ref="processExpressionDialogRef" @select="selectProcessExpression" />
+    </el-form-item>
+  </el-form>
+</template>
+
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { defaultProps, handleTree } from '@/utils/tree'
+import * as RoleApi from '@/api/system/role'
+import * as DeptApi from '@/api/system/dept'
+import * as PostApi from '@/api/system/post'
+import * as UserApi from '@/api/system/user'
+import * as UserGroupApi from '@/api/bpm/userGroup'
+import ProcessExpressionDialog from './ProcessExpressionDialog.vue'
+
+defineOptions({ name: 'UserTask' })
+const props = defineProps({
+  id: String,
+  type: String
+})
+const userTaskForm = ref({
+  candidateStrategy: undefined, // 分配规则
+  candidateParam: [] // 分配选项
+})
+const bpmnElement = ref()
+const bpmnInstances = () => (window as any)?.bpmnInstances
+
+const roleOptions = ref<RoleApi.RoleVO[]>([]) // 角色列表
+const deptTreeOptions = ref() // 部门树
+const postOptions = ref<PostApi.PostVO[]>([]) // 岗位列表
+const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
+const userGroupOptions = ref<UserGroupApi.UserGroupVO[]>([]) // 用户组列表
+
+const resetTaskForm = () => {
+  const businessObject = bpmnElement.value.businessObject
+  if (!businessObject) {
+    return
+  }
+  if (businessObject.candidateStrategy != undefined) {
+    userTaskForm.value.candidateStrategy = parseInt(businessObject.candidateStrategy) as any
+  } else {
+    userTaskForm.value.candidateStrategy = undefined
+  }
+  if (businessObject.candidateParam && businessObject.candidateParam.length > 0) {
+    if (userTaskForm.value.candidateStrategy === 60) {
+      // 特殊:流程表达式,只有一个 input 输入框
+      userTaskForm.value.candidateParam = [businessObject.candidateParam]
+    } else {
+      userTaskForm.value.candidateParam = businessObject.candidateParam
+        .split(',')
+        .map((item) => +item)
+    }
+  } else {
+    userTaskForm.value.candidateParam = []
+  }
+}
+
+/** 更新 candidateStrategy 字段时,需要清空 candidateParam,并触发 bpmn 图更新 */
+const changeCandidateStrategy = () => {
+  userTaskForm.value.candidateParam = []
+  updateElementTask()
+}
+
+/** 选中某个 options 时候,更新 bpmn 图  */
+const updateElementTask = () => {
+  bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), {
+    candidateStrategy: userTaskForm.value.candidateStrategy,
+    candidateParam: userTaskForm.value.candidateParam.join(',')
+  })
+}
+
+// 打开监听器弹窗
+const processExpressionDialogRef = ref()
+const openProcessExpressionDialog = async () => {
+  processExpressionDialogRef.value.open()
+}
+const selectProcessExpression = (expression) => {
+  userTaskForm.value.candidateParam = [expression.expression]
+}
+
+watch(
+  () => props.id,
+  () => {
+    bpmnElement.value = bpmnInstances().bpmnElement
+    nextTick(() => {
+      resetTaskForm()
+    })
+  },
+  { immediate: true }
+)
+
+onMounted(async () => {
+  // 获得角色列表
+  roleOptions.value = await RoleApi.getSimpleRoleList()
+  // 获得部门列表
+  const deptOptions = await DeptApi.getSimpleDeptList()
+  deptTreeOptions.value = handleTree(deptOptions, 'id')
+  // 获得岗位列表
+  postOptions.value = await PostApi.getSimplePostList()
+  // 获得用户列表
+  userOptions.value = await UserApi.getSimpleUserList()
+  // 获得用户组列表
+  userGroupOptions.value = await UserGroupApi.getUserGroupSimpleList()
+})
+
+onBeforeUnmount(() => {
+  bpmnElement.value = null
+})
+</script>

+ 78 - 0
src/components/bpmnProcessDesigner/package/utils.ts

@@ -0,0 +1,78 @@
+import { toRaw } from 'vue'
+const bpmnInstances = () => (window as any)?.bpmnInstances
+// 创建监听器实例
+export function createListenerObject(options, isTask, prefix) {
+  debugger
+  const listenerObj = Object.create(null)
+  listenerObj.event = options.event
+  isTask && (listenerObj.id = options.id) // 任务监听器特有的 id 字段
+  switch (options.listenerType) {
+    case 'scriptListener':
+      listenerObj.script = createScriptObject(options, prefix)
+      break
+    case 'expressionListener':
+      listenerObj.expression = options.expression
+      break
+    case 'delegateExpressionListener':
+      listenerObj.delegateExpression = options.delegateExpression
+      break
+    default:
+      listenerObj.class = options.class
+  }
+  // 注入字段
+  if (options.fields) {
+    listenerObj.fields = options.fields.map((field) => {
+      return createFieldObject(field, prefix)
+    })
+  }
+  // 任务监听器的 定时器 设置
+  if (isTask && options.event === 'timeout' && !!options.eventDefinitionType) {
+    const timeDefinition = bpmnInstances().moddle.create('bpmn:FormalExpression', {
+      body: options.eventTimeDefinitions
+    })
+    const TimerEventDefinition = bpmnInstances().moddle.create('bpmn:TimerEventDefinition', {
+      id: `TimerEventDefinition_${uuid(8)}`,
+      [`time${options.eventDefinitionType.replace(/^\S/, (s) => s.toUpperCase())}`]: timeDefinition
+    })
+    listenerObj.eventDefinitions = [TimerEventDefinition]
+  }
+  return bpmnInstances().moddle.create(
+    `${prefix}:${isTask ? 'TaskListener' : 'ExecutionListener'}`,
+    listenerObj
+  )
+}
+
+// 创建 监听器的注入字段 实例
+export function createFieldObject(option, prefix) {
+  const { name, fieldType, string, expression } = option
+  const fieldConfig = fieldType === 'string' ? { name, string } : { name, expression }
+  return bpmnInstances().moddle.create(`${prefix}:Field`, fieldConfig)
+}
+
+// 创建脚本实例
+export function createScriptObject(options, prefix) {
+  const { scriptType, scriptFormat, value, resource } = options
+  const scriptConfig =
+    scriptType === 'inlineScript' ? { scriptFormat, value } : { scriptFormat, resource }
+  return bpmnInstances().moddle.create(`${prefix}:Script`, scriptConfig)
+}
+
+// 更新元素扩展属性
+export function updateElementExtensions(element, extensionList) {
+  const extensions = bpmnInstances().moddle.create('bpmn:ExtensionElements', {
+    values: extensionList
+  })
+  bpmnInstances().modeling.updateProperties(toRaw(element), {
+    extensionElements: extensions
+  })
+}
+
+// 创建一个id
+export function uuid(length = 8, chars?) {
+  let result = ''
+  const charsString = chars || '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
+  for (let i = length; i > 0; --i) {
+    result += charsString[Math.floor(Math.random() * charsString.length)]
+  }
+  return result
+}

+ 60 - 0
src/hooks/event/useScrollTo.ts

@@ -0,0 +1,60 @@
+export interface ScrollToParams {
+  el: HTMLElement
+  to: number
+  position: string
+  duration?: number
+  callback?: () => void
+}
+
+const easeInOutQuad = (t: number, b: number, c: number, d: number) => {
+  t /= d / 2
+  if (t < 1) {
+    return (c / 2) * t * t + b
+  }
+  t--
+  return (-c / 2) * (t * (t - 2) - 1) + b
+}
+const move = (el: HTMLElement, position: string, amount: number) => {
+  el[position] = amount
+}
+
+export function useScrollTo({
+  el,
+  position = 'scrollLeft',
+  to,
+  duration = 500,
+  callback
+}: ScrollToParams) {
+  const isActiveRef = ref(false)
+  const start = el[position]
+  const change = to - start
+  const increment = 20
+  let currentTime = 0
+
+  function animateScroll() {
+    if (!unref(isActiveRef)) {
+      return
+    }
+    currentTime += increment
+    const val = easeInOutQuad(currentTime, start, change, duration)
+    move(el, position, val)
+    if (currentTime < duration && unref(isActiveRef)) {
+      requestAnimationFrame(animateScroll)
+    } else {
+      if (callback) {
+        callback()
+      }
+    }
+  }
+
+  function run() {
+    isActiveRef.value = true
+    animateScroll()
+  }
+
+  function stop() {
+    isActiveRef.value = false
+  }
+
+  return { start: run, stop }
+}

+ 39 - 0
src/hooks/web/useCache.ts

@@ -0,0 +1,39 @@
+/**
+ * 配置浏览器本地存储的方式,可直接存储对象数组。
+ */
+
+import WebStorageCache from 'web-storage-cache'
+
+type CacheType = 'localStorage' | 'sessionStorage'
+
+export const CACHE_KEY = {
+  // 用户相关
+  ROLE_ROUTERS: 'roleRouters',
+  USER: 'user',
+  // 系统设置
+  IS_DARK: 'isDark',
+  LANG: 'lang',
+  THEME: 'theme',
+  LAYOUT: 'layout',
+  DICT_CACHE: 'dictCache',
+  // 登录表单
+  LoginForm: 'loginForm',
+  TenantId: 'tenantId'
+}
+
+export const useCache = (type: CacheType = 'localStorage') => {
+  const wsCache: WebStorageCache = new WebStorageCache({
+    storage: type
+  })
+
+  return {
+    wsCache
+  }
+}
+
+export const deleteUserCache = () => {
+  const { wsCache } = useCache()
+  wsCache.delete(CACHE_KEY.USER)
+  wsCache.delete(CACHE_KEY.ROLE_ROUTERS)
+  // 注意,不要清理 LoginForm 登录表单
+}

+ 9 - 0
src/hooks/web/useConfigGlobal.ts

@@ -0,0 +1,9 @@
+import { ConfigGlobalTypes } from '@/types/configGlobal'
+
+export const useConfigGlobal = () => {
+  const configGlobal = inject('configGlobal', {}) as ConfigGlobalTypes
+
+  return {
+    configGlobal
+  }
+}

+ 326 - 0
src/hooks/web/useCrudSchemas.ts

@@ -0,0 +1,326 @@
+import { reactive } from 'vue'
+import { AxiosPromise } from 'axios'
+import { findIndex } from '@/utils'
+import { eachTree, filter, treeMap } from '@/utils/tree'
+import { getBoolDictOptions, getDictOptions, getIntDictOptions } from '@/utils/dict'
+
+import { FormSchema } from '@/types/form'
+import { TableColumn } from '@/types/table'
+import { DescriptionsSchema } from '@/types/descriptions'
+import { ComponentOptions, ComponentProps } from '@/types/components'
+import { DictTag } from '@/components/DictTag'
+import { cloneDeep, merge } from 'lodash-es'
+
+export type CrudSchema = Omit<TableColumn, 'children'> & {
+  isSearch?: boolean // 是否在查询显示
+  search?: CrudSearchParams // 查询的详细配置
+  isTable?: boolean // 是否在列表显示
+  table?: CrudTableParams // 列表的详细配置
+  isForm?: boolean // 是否在表单显示
+  form?: CrudFormParams // 表单的详细配置
+  isDetail?: boolean // 是否在详情显示
+  detail?: CrudDescriptionsParams // 详情的详细配置
+  children?: CrudSchema[]
+  dictType?: string // 字典类型
+  dictClass?: 'string' | 'number' | 'boolean' // 字典数据类型 string | number | boolean
+}
+
+type CrudSearchParams = {
+  // 是否显示在查询项
+  show?: boolean
+  // 接口
+  api?: () => Promise<any>
+  // 搜索字段
+  field?: string
+} & Omit<FormSchema, 'field'>
+
+type CrudTableParams = {
+  // 是否显示表头
+  show?: boolean
+  // 列宽配置
+  width?: number | string
+  // 列是否固定在左侧或者右侧
+  fixed?: 'left' | 'right'
+} & Omit<FormSchema, 'field'>
+type CrudFormParams = {
+  // 是否显示表单项
+  show?: boolean
+  // 接口
+  api?: () => Promise<any>
+} & Omit<FormSchema, 'field'>
+
+type CrudDescriptionsParams = {
+  // 是否显示表单项
+  show?: boolean
+} & Omit<DescriptionsSchema, 'field'>
+
+interface AllSchemas {
+  searchSchema: FormSchema[]
+  tableColumns: TableColumn[]
+  formSchema: FormSchema[]
+  detailSchema: DescriptionsSchema[]
+}
+
+const { t } = useI18n()
+
+// 过滤所有结构
+export const useCrudSchemas = (
+  crudSchema: CrudSchema[]
+): {
+  allSchemas: AllSchemas
+} => {
+  // 所有结构数据
+  const allSchemas = reactive<AllSchemas>({
+    searchSchema: [],
+    tableColumns: [],
+    formSchema: [],
+    detailSchema: []
+  })
+
+  const searchSchema = filterSearchSchema(crudSchema, allSchemas)
+  allSchemas.searchSchema = searchSchema || []
+
+  const tableColumns = filterTableSchema(crudSchema)
+  allSchemas.tableColumns = tableColumns || []
+
+  const formSchema = filterFormSchema(crudSchema, allSchemas)
+  allSchemas.formSchema = formSchema
+
+  const detailSchema = filterDescriptionsSchema(crudSchema)
+  allSchemas.detailSchema = detailSchema
+
+  return {
+    allSchemas
+  }
+}
+
+// 过滤 Search 结构
+const filterSearchSchema = (crudSchema: CrudSchema[], allSchemas: AllSchemas): FormSchema[] => {
+  const searchSchema: FormSchema[] = []
+
+  // 获取字典列表队列
+  const searchRequestTask: Array<() => Promise<void>> = []
+  eachTree(crudSchema, (schemaItem: CrudSchema) => {
+    // 判断是否显示
+    if (schemaItem?.isSearch || schemaItem.search?.show) {
+      let component = schemaItem?.search?.component || 'Input'
+      const options: ComponentOptions[] = []
+      let comonentProps: ComponentProps = {}
+      if (schemaItem.dictType) {
+        const allOptions: ComponentOptions = { label: '全部', value: '' }
+        options.push(allOptions)
+        getDictOptions(schemaItem.dictType).forEach((dict) => {
+          options.push(dict)
+        })
+        comonentProps = {
+          options: options
+        }
+        if (!schemaItem.search?.component) component = 'Select'
+      }
+
+      // updated by AKing: 解决了当使用默认的dict选项时,form中事件不能触发的问题
+      const searchSchemaItem = merge(
+        {
+          // 默认为 input
+          component,
+          ...schemaItem.search,
+          field: schemaItem.field,
+          label: schemaItem.search?.label || schemaItem.label
+        },
+        { componentProps: comonentProps }
+      )
+      if (searchSchemaItem.api) {
+        searchRequestTask.push(async () => {
+          const res = await (searchSchemaItem.api as () => AxiosPromise)()
+          if (res) {
+            const index = findIndex(allSchemas.searchSchema, (v: FormSchema) => {
+              return v.field === searchSchemaItem.field
+            })
+            if (index !== -1) {
+              allSchemas.searchSchema[index]!.componentProps!.options = filterOptions(
+                res,
+                searchSchemaItem.componentProps.optionsAlias?.labelField
+              )
+            }
+          }
+        })
+      }
+      // 删除不必要的字段
+      delete searchSchemaItem.show
+
+      searchSchema.push(searchSchemaItem)
+    }
+  })
+  for (const task of searchRequestTask) {
+    task()
+  }
+  return searchSchema
+}
+
+// 过滤 table 结构
+const filterTableSchema = (crudSchema: CrudSchema[]): TableColumn[] => {
+  const tableColumns = treeMap<CrudSchema>(crudSchema, {
+    conversion: (schema: CrudSchema) => {
+      if (schema?.isTable !== false && schema?.table?.show !== false) {
+        // add by 芋艿:增加对 dict 字典数据的支持
+        if (!schema.formatter && schema.dictType) {
+          schema.formatter = (_: Recordable, __: TableColumn, cellValue: any) => {
+            return h(DictTag, {
+              type: schema.dictType!, // ! 表示一定不为空
+              value: cellValue
+            })
+          }
+        }
+        return {
+          ...schema.table,
+          ...schema
+        }
+      }
+    }
+  })
+
+  // 第一次过滤会有 undefined 所以需要二次过滤
+  return filter<TableColumn>(tableColumns as TableColumn[], (data) => {
+    if (data.children === void 0) {
+      delete data.children
+    }
+    return !!data.field
+  })
+}
+
+// 过滤 form 结构
+const filterFormSchema = (crudSchema: CrudSchema[], allSchemas: AllSchemas): FormSchema[] => {
+  const formSchema: FormSchema[] = []
+
+  // 获取字典列表队列
+  const formRequestTask: Array<() => Promise<void>> = []
+
+  eachTree(crudSchema, (schemaItem: CrudSchema) => {
+    // 判断是否显示
+    if (schemaItem?.isForm !== false && schemaItem?.form?.show !== false) {
+      let component = schemaItem?.form?.component || 'Input'
+      let defaultValue: any = ''
+      if (schemaItem.form?.value) {
+        defaultValue = schemaItem.form?.value
+      } else {
+        if (component === 'InputNumber') {
+          defaultValue = 0
+        }
+      }
+      let comonentProps: ComponentProps = {}
+      if (schemaItem.dictType) {
+        const options: ComponentOptions[] = []
+        if (schemaItem.dictClass && schemaItem.dictClass === 'number') {
+          getIntDictOptions(schemaItem.dictType).forEach((dict) => {
+            options.push(dict)
+          })
+        } else if (schemaItem.dictClass && schemaItem.dictClass === 'boolean') {
+          getBoolDictOptions(schemaItem.dictType).forEach((dict) => {
+            options.push(dict)
+          })
+        } else {
+          getDictOptions(schemaItem.dictType).forEach((dict) => {
+            options.push(dict)
+          })
+        }
+        comonentProps = {
+          options: options
+        }
+        if (!(schemaItem.form && schemaItem.form.component)) component = 'Select'
+      }
+
+      // updated by AKing: 解决了当使用默认的dict选项时,form中事件不能触发的问题
+      const formSchemaItem = merge(
+        {
+          // 默认为 input
+          component,
+          value: defaultValue,
+          ...schemaItem.form,
+          field: schemaItem.field,
+          label: schemaItem.form?.label || schemaItem.label
+        },
+        { componentProps: comonentProps }
+      )
+
+      if (formSchemaItem.api) {
+        formRequestTask.push(async () => {
+          const res = await (formSchemaItem.api as () => AxiosPromise)()
+          if (res) {
+            const index = findIndex(allSchemas.formSchema, (v: FormSchema) => {
+              return v.field === formSchemaItem.field
+            })
+            if (index !== -1) {
+              allSchemas.formSchema[index]!.componentProps!.options = filterOptions(
+                res,
+                formSchemaItem.componentProps.optionsAlias?.labelField
+              )
+            }
+          }
+        })
+      }
+
+      // 删除不必要的字段
+      delete formSchemaItem.show
+
+      formSchema.push(formSchemaItem)
+    }
+  })
+
+  for (const task of formRequestTask) {
+    task()
+  }
+  return formSchema
+}
+
+// 过滤 descriptions 结构
+const filterDescriptionsSchema = (crudSchema: CrudSchema[]): DescriptionsSchema[] => {
+  const descriptionsSchema: FormSchema[] = []
+
+  eachTree(crudSchema, (schemaItem: CrudSchema) => {
+    // 判断是否显示
+    if (schemaItem?.isDetail !== false && schemaItem.detail?.show !== false) {
+      const descriptionsSchemaItem = {
+        ...schemaItem.detail,
+        field: schemaItem.field,
+        label: schemaItem.detail?.label || schemaItem.label
+      }
+      if (schemaItem.dictType) {
+        descriptionsSchemaItem.dictType = schemaItem.dictType
+      }
+      if (schemaItem.detail?.dateFormat || schemaItem.formatter == 'formatDate') {
+        // 优先使用 detail 下的配置,如果没有默认为 YYYY-MM-DD HH:mm:ss
+        descriptionsSchemaItem.dateFormat = schemaItem?.detail?.dateFormat
+          ? schemaItem?.detail?.dateFormat
+          : 'YYYY-MM-DD HH:mm:ss'
+      }
+
+      // 删除不必要的字段
+      delete descriptionsSchemaItem.show
+
+      descriptionsSchema.push(descriptionsSchemaItem)
+    }
+  })
+
+  return descriptionsSchema
+}
+
+// 给options添加国际化
+const filterOptions = (options: Recordable, labelField?: string) => {
+  return options?.map((v: Recordable) => {
+    if (labelField) {
+      v['labelField'] = t(v.labelField)
+    } else {
+      v['label'] = t(v.label)
+    }
+    return v
+  })
+}
+
+// 将 tableColumns 指定 fields 放到最前面
+export const sortTableColumns = (tableColumns: TableColumn[], field: string) => {
+  const fieldIndex = tableColumns.findIndex((item) => item.field === field)
+  const fieldColumn = cloneDeep(tableColumns[fieldIndex])
+  tableColumns.splice(fieldIndex, 1)
+  // 添加到开头
+  tableColumns.unshift(fieldColumn)
+}

+ 18 - 0
src/hooks/web/useDesign.ts

@@ -0,0 +1,18 @@
+import variables from '@/styles/global.module.scss'
+
+export const useDesign = () => {
+  const scssVariables = variables
+
+  /**
+   * @param scope 类名
+   * @returns 返回空间名-类名
+   */
+  const getPrefixCls = (scope: string) => {
+    return `${scssVariables.namespace}-${scope}`
+  }
+
+  return {
+    variables: scssVariables,
+    getPrefixCls
+  }
+}

+ 22 - 0
src/hooks/web/useEmitt.ts

@@ -0,0 +1,22 @@
+import mitt from 'mitt'
+
+interface Option {
+  name: string // 事件名称
+  callback: Fn // 回调
+}
+
+const emitter = mitt()
+
+export const useEmitt = (option?: Option) => {
+  if (option) {
+    emitter.on(option.name, option.callback)
+
+    onBeforeUnmount(() => {
+      emitter.off(option.name)
+    })
+  }
+
+  return {
+    emitter
+  }
+}

+ 94 - 0
src/hooks/web/useForm.ts

@@ -0,0 +1,94 @@
+import type { Form, FormExpose } from '@/components/Form'
+import type { ElForm } from 'element-plus'
+import type { FormProps } from '@/components/Form/src/types'
+import { FormSchema, FormSetPropsType } from '@/types/form'
+
+export const useForm = (props?: FormProps) => {
+  // From实例
+  const formRef = ref<typeof Form & FormExpose>()
+
+  // ElForm实例
+  const elFormRef = ref<ComponentRef<typeof ElForm>>()
+
+  /**
+   * @param ref Form实例
+   * @param elRef ElForm实例
+   */
+  const register = (ref: typeof Form & FormExpose, elRef: ComponentRef<typeof ElForm>) => {
+    formRef.value = ref
+    elFormRef.value = elRef
+  }
+
+  const getForm = async () => {
+    await nextTick()
+    const form = unref(formRef)
+    if (!form) {
+      console.error('The form is not registered. Please use the register method to register')
+    }
+    return form
+  }
+
+  // 一些内置的方法
+  const methods: {
+    setProps: (props: Recordable) => void
+    setValues: (data: Recordable) => void
+    getFormData: <T = Recordable | undefined>() => Promise<T>
+    setSchema: (schemaProps: FormSetPropsType[]) => void
+    addSchema: (formSchema: FormSchema, index?: number) => void
+    delSchema: (field: string) => void
+  } = {
+    setProps: async (props: FormProps = {}) => {
+      const form = await getForm()
+      form?.setProps(props)
+      if (props.model) {
+        form?.setValues(props.model)
+      }
+    },
+
+    setValues: async (data: Recordable) => {
+      const form = await getForm()
+      form?.setValues(data)
+    },
+
+    /**
+     * @param schemaProps 需要设置的schemaProps
+     */
+    setSchema: async (schemaProps: FormSetPropsType[]) => {
+      const form = await getForm()
+      form?.setSchema(schemaProps)
+    },
+
+    /**
+     * @param formSchema 需要新增数据
+     * @param index 在哪里新增
+     */
+    addSchema: async (formSchema: FormSchema, index?: number) => {
+      const form = await getForm()
+      form?.addSchema(formSchema, index)
+    },
+
+    /**
+     * @param field 删除哪个数据
+     */
+    delSchema: async (field: string) => {
+      const form = await getForm()
+      form?.delSchema(field)
+    },
+
+    /**
+     * @returns form data
+     */
+    getFormData: async <T = Recordable>(): Promise<T> => {
+      const form = await getForm()
+      return form?.formModel as T
+    }
+  }
+
+  props && methods.setProps(props)
+
+  return {
+    register,
+    elFormRef,
+    methods
+  }
+}

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

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

+ 53 - 0
src/hooks/web/useI18n.ts

@@ -0,0 +1,53 @@
+import { i18n } from '@/plugins/vueI18n'
+
+type I18nGlobalTranslation = {
+  (key: string): string
+  (key: string, locale: string): string
+  (key: string, locale: string, list: unknown[]): string
+  (key: string, locale: string, named: Record<string, unknown>): string
+  (key: string, list: unknown[]): string
+  (key: string, named: Record<string, unknown>): string
+}
+
+type I18nTranslationRestParameters = [string, any]
+
+const getKey = (namespace: string | undefined, key: string) => {
+  if (!namespace) {
+    return key
+  }
+  if (key.startsWith(namespace)) {
+    return key
+  }
+  return `${namespace}.${key}`
+}
+
+export const useI18n = (
+  namespace?: string
+): {
+  t: I18nGlobalTranslation
+} => {
+  const normalFn = {
+    t: (key: string) => {
+      return getKey(namespace, key)
+    }
+  }
+
+  if (!i18n) {
+    return normalFn
+  }
+
+  const { t, ...methods } = i18n.global
+
+  const tFn: I18nGlobalTranslation = (key: string, ...arg: any[]) => {
+    if (!key) return ''
+    if (!key.includes('.') && !namespace) return key
+    //@ts-ignore
+    return t(getKey(namespace, key), ...(arg as I18nTranslationRestParameters))
+  }
+  return {
+    ...methods,
+    t: tFn
+  }
+}
+
+export const t = (key: string) => key

+ 8 - 0
src/hooks/web/useIcon.ts

@@ -0,0 +1,8 @@
+import { h } from 'vue'
+import type { VNode } from 'vue'
+import { Icon } from '@/components/Icon'
+import { IconTypes } from '@/types/icon'
+
+export const useIcon = (props: IconTypes): VNode => {
+  return h(Icon, props)
+}

+ 35 - 0
src/hooks/web/useLocale.ts

@@ -0,0 +1,35 @@
+import { i18n } from '@/plugins/vueI18n'
+import { useLocaleStoreWithOut } from '@/store/modules/locale'
+import { setHtmlPageLang } from '@/plugins/vueI18n/helper'
+
+const setI18nLanguage = (locale: LocaleType) => {
+  const localeStore = useLocaleStoreWithOut()
+
+  if (i18n.mode === 'legacy') {
+    i18n.global.locale = locale
+  } else {
+    ;(i18n.global.locale as any).value = locale
+  }
+  localeStore.setCurrentLocale({
+    lang: locale
+  })
+  setHtmlPageLang(locale)
+}
+
+export const useLocale = () => {
+  // Switching the language will change the locale of useI18n
+  // And submit to configuration modification
+  const changeLocale = async (locale: LocaleType) => {
+    const globalI18n = i18n.global
+
+    const langModule = await import(`../../locales/${locale}.ts`)
+
+    globalI18n.setLocaleMessage(locale, langModule.default)
+
+    setI18nLanguage(locale)
+  }
+
+  return {
+    changeLocale
+  }
+}

+ 95 - 0
src/hooks/web/useMessage.ts

@@ -0,0 +1,95 @@
+import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
+import { useI18n } from './useI18n'
+export const useMessage = () => {
+  const { t } = useI18n()
+  return {
+    // 消息提示
+    info(content: string) {
+      ElMessage.info(content)
+    },
+    // 错误消息
+    error(content: string) {
+      ElMessage.error(content)
+    },
+    // 成功消息
+    success(content: string) {
+      ElMessage.success(content)
+    },
+    // 警告消息
+    warning(content: string) {
+      ElMessage.warning(content)
+    },
+    // 弹出提示
+    alert(content: string) {
+      ElMessageBox.alert(content, t('common.confirmTitle'))
+    },
+    // 错误提示
+    alertError(content: string) {
+      ElMessageBox.alert(content, t('common.confirmTitle'), { type: 'error' })
+    },
+    // 成功提示
+    alertSuccess(content: string) {
+      ElMessageBox.alert(content, t('common.confirmTitle'), { type: 'success' })
+    },
+    // 警告提示
+    alertWarning(content: string) {
+      ElMessageBox.alert(content, t('common.confirmTitle'), { type: 'warning' })
+    },
+    // 通知提示
+    notify(content: string) {
+      ElNotification.info(content)
+    },
+    // 错误通知
+    notifyError(content: string) {
+      ElNotification.error(content)
+    },
+    // 成功通知
+    notifySuccess(content: string) {
+      ElNotification.success(content)
+    },
+    // 警告通知
+    notifyWarning(content: string) {
+      ElNotification.warning(content)
+    },
+    // 确认窗体
+    confirm(content: string, tip?: string) {
+      return ElMessageBox.confirm(content, tip ? tip : t('common.confirmTitle'), {
+        confirmButtonText: t('common.ok'),
+        cancelButtonText: t('common.cancel'),
+        type: 'warning'
+      })
+    },
+    // 删除窗体
+    delConfirm(content?: string, tip?: string) {
+      return ElMessageBox.confirm(
+        content ? content : t('common.delMessage'),
+        tip ? tip : t('common.confirmTitle'),
+        {
+          confirmButtonText: t('common.ok'),
+          cancelButtonText: t('common.cancel'),
+          type: 'warning'
+        }
+      )
+    },
+    // 导出窗体
+    exportConfirm(content?: string, tip?: string) {
+      return ElMessageBox.confirm(
+        content ? content : t('common.exportMessage'),
+        tip ? tip : t('common.confirmTitle'),
+        {
+          confirmButtonText: t('common.ok'),
+          cancelButtonText: t('common.cancel'),
+          type: 'warning'
+        }
+      )
+    },
+    // 提交内容
+    prompt(content: string, tip: string) {
+      return ElMessageBox.prompt(content, tip, {
+        confirmButtonText: t('common.ok'),
+        cancelButtonText: t('common.cancel'),
+        type: 'warning'
+      })
+    }
+  }
+}

+ 33 - 0
src/hooks/web/useNProgress.ts

@@ -0,0 +1,33 @@
+import { useCssVar } from '@vueuse/core'
+import type { NProgressOptions } from 'nprogress'
+import NProgress from 'nprogress'
+import 'nprogress/nprogress.css'
+
+const primaryColor = useCssVar('--el-color-primary', document.documentElement)
+
+export const useNProgress = () => {
+  NProgress.configure({ showSpinner: false } as NProgressOptions)
+
+  const initColor = async () => {
+    await nextTick()
+    const bar = document.getElementById('nprogress')?.getElementsByClassName('bar')[0] as ElRef
+    if (bar) {
+      bar.style.background = unref(primaryColor.value)
+    }
+  }
+
+  initColor()
+
+  const start = () => {
+    NProgress.start()
+  }
+
+  const done = () => {
+    NProgress.done()
+  }
+
+  return {
+    start,
+    done
+  }
+}

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

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

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

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

+ 18 - 0
src/hooks/web/usePageLoading.ts

@@ -0,0 +1,18 @@
+import { useAppStoreWithOut } from '@/store/modules/app'
+
+const appStore = useAppStoreWithOut()
+
+export const usePageLoading = () => {
+  const loadStart = () => {
+    appStore.setPageLoading(true)
+  }
+
+  const loadDone = () => {
+    appStore.setPageLoading(false)
+  }
+
+  return {
+    loadStart,
+    loadDone
+  }
+}

+ 223 - 0
src/hooks/web/useTable.ts

@@ -0,0 +1,223 @@
+import download from '@/utils/download'
+import { Table, TableExpose } from '@/components/Table'
+import { ElMessage, ElMessageBox, ElTable } from 'element-plus'
+import { computed, nextTick, reactive, ref, unref, watch } from 'vue'
+import type { TableProps } from '@/components/Table/src/types'
+
+import { TableSetPropsType } from '@/types/table'
+
+const { t } = useI18n()
+interface ResponseType<T = any> {
+  list: T[]
+  total?: number
+}
+
+interface UseTableConfig<T = any> {
+  getListApi: (option: any) => Promise<T>
+  delListApi?: (option: any) => Promise<T>
+  exportListApi?: (option: any) => Promise<T>
+  // 返回数据格式配置
+  response?: ResponseType
+  // 默认传递的参数
+  defaultParams?: Recordable
+  props?: TableProps
+}
+
+interface TableObject<T = any> {
+  pageSize: number
+  currentPage: number
+  total: number
+  tableList: T[]
+  params: any
+  loading: boolean
+  exportLoading: boolean
+  currentRow: Nullable<T>
+}
+
+export const useTable = <T = any>(config?: UseTableConfig<T>) => {
+  const tableObject = reactive<TableObject<T>>({
+    // 页数
+    pageSize: 10,
+    // 当前页
+    currentPage: 1,
+    // 总条数
+    total: 10,
+    // 表格数据
+    tableList: [],
+    // AxiosConfig 配置
+    params: {
+      ...(config?.defaultParams || {})
+    },
+    // 加载中
+    loading: true,
+    // 导出加载中
+    exportLoading: false,
+    // 当前行的数据
+    currentRow: null
+  })
+
+  const paramsObj = computed(() => {
+    return {
+      ...tableObject.params,
+      pageSize: tableObject.pageSize,
+      pageNo: tableObject.currentPage
+    }
+  })
+
+  watch(
+    () => tableObject.currentPage,
+    () => {
+      methods.getList()
+    }
+  )
+
+  watch(
+    () => tableObject.pageSize,
+    () => {
+      // 当前页不为1时,修改页数后会导致多次调用getList方法
+      if (tableObject.currentPage === 1) {
+        methods.getList()
+      } else {
+        tableObject.currentPage = 1
+        methods.getList()
+      }
+    }
+  )
+
+  // Table实例
+  const tableRef = ref<typeof Table & TableExpose>()
+
+  // ElTable实例
+  const elTableRef = ref<ComponentRef<typeof ElTable>>()
+
+  const register = (ref: typeof Table & TableExpose, elRef: ComponentRef<typeof ElTable>) => {
+    tableRef.value = ref
+    elTableRef.value = elRef
+  }
+
+  const getTable = async () => {
+    await nextTick()
+    const table = unref(tableRef)
+    if (!table) {
+      console.error('The table is not registered. Please use the register method to register')
+    }
+    return table
+  }
+
+  const delData = async (ids: string | number | string[] | number[]) => {
+    let idsLength = 1
+    if (ids instanceof Array) {
+      idsLength = ids.length
+      await Promise.all(
+        ids.map(async (id: string | number) => {
+          await (config?.delListApi && config?.delListApi(id))
+        })
+      )
+    } else {
+      await (config?.delListApi && config?.delListApi(ids))
+    }
+    ElMessage.success(t('common.delSuccess'))
+
+    // 计算出临界点
+    tableObject.currentPage =
+      tableObject.total % tableObject.pageSize === idsLength || tableObject.pageSize === 1
+        ? tableObject.currentPage > 1
+          ? tableObject.currentPage - 1
+          : tableObject.currentPage
+        : tableObject.currentPage
+    await methods.getList()
+  }
+
+  const methods = {
+    getList: async () => {
+      tableObject.loading = true
+      const res = await config?.getListApi(unref(paramsObj)).finally(() => {
+        tableObject.loading = false
+      })
+      if (res) {
+        tableObject.tableList = (res as unknown as ResponseType).list
+        tableObject.total = (res as unknown as ResponseType).total ?? 0
+      }
+    },
+    setProps: async (props: TableProps = {}) => {
+      const table = await getTable()
+      table?.setProps(props)
+    },
+    setColumn: async (columnProps: TableSetPropsType[]) => {
+      const table = await getTable()
+      table?.setColumn(columnProps)
+    },
+    getSelections: async () => {
+      const table = await getTable()
+      return (table?.selections || []) as T[]
+    },
+    // 与Search组件结合
+    setSearchParams: (data: Recordable) => {
+      tableObject.params = Object.assign(tableObject.params, {
+        pageSize: tableObject.pageSize,
+        pageNo: 1,
+        ...data
+      })
+      // 页码不等于1时更新页码重新获取数据,页码等于1时重新获取数据
+      if (tableObject.currentPage !== 1) {
+        tableObject.currentPage = 1
+      } else {
+        methods.getList()
+      }
+    },
+    // 删除数据
+    delList: async (
+      ids: string | number | string[] | number[],
+      multiple: boolean,
+      message = true
+    ) => {
+      const tableRef = await getTable()
+      if (multiple) {
+        if (!tableRef?.selections.length) {
+          ElMessage.warning(t('common.delNoData'))
+          return
+        }
+      }
+      if (message) {
+        ElMessageBox.confirm(t('common.delMessage'), t('common.confirmTitle'), {
+          confirmButtonText: t('common.ok'),
+          cancelButtonText: t('common.cancel'),
+          type: 'warning'
+        }).then(async () => {
+          await delData(ids)
+        })
+      } else {
+        await delData(ids)
+      }
+    },
+    // 导出列表
+    exportList: async (fileName: string) => {
+      tableObject.exportLoading = true
+      ElMessageBox.confirm(t('common.exportMessage'), t('common.confirmTitle'), {
+        confirmButtonText: t('common.ok'),
+        cancelButtonText: t('common.cancel'),
+        type: 'warning'
+      })
+        .then(async () => {
+          const res = await config?.exportListApi?.(unref(paramsObj) as unknown as T)
+          if (res) {
+            download.excel(res as unknown as Blob, fileName)
+          }
+        })
+        .finally(() => {
+          tableObject.exportLoading = false
+        })
+    }
+  }
+
+  config?.props && methods.setProps(config.props)
+
+  return {
+    register,
+    elTableRef,
+    tableObject,
+    methods,
+    // add by 芋艿:返回 tableMethods 属性,和 tableObject 更统一
+    tableMethods: methods
+  }
+}

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

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

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

@@ -0,0 +1,49 @@
+import { useTimeAgo as useTimeAgoCore, UseTimeAgoMessages } from '@vueuse/core'
+import { useLocaleStoreWithOut } from '@/store/modules/locale'
+
+const TIME_AGO_MESSAGE_MAP: {
+  'zh-CN': UseTimeAgoMessages
+  en: UseTimeAgoMessages
+} = {
+  // @ts-ignore
+  'zh-CN': {
+    justNow: '刚刚',
+    past: (n) => (n.match(/\d/) ? `${n}前` : n),
+    future: (n) => (n.match(/\d/) ? `${n}后` : n),
+    month: (n, past) => (n === 1 ? (past ? '上个月' : '下个月') : `${n} 个月`),
+    year: (n, past) => (n === 1 ? (past ? '去年' : '明年') : `${n} 年`),
+    day: (n, past) => (n === 1 ? (past ? '昨天' : '明天') : `${n} 天`),
+    week: (n, past) => (n === 1 ? (past ? '上周' : '下周') : `${n} 周`),
+    hour: (n) => `${n} 小时`,
+    minute: (n) => `${n} 分钟`,
+    second: (n) => `${n} 秒`
+  },
+  // @ts-ignore
+  en: {
+    justNow: 'just now',
+    past: (n) => (n.match(/\d/) ? `${n} ago` : n),
+    future: (n) => (n.match(/\d/) ? `in ${n}` : n),
+    month: (n, past) =>
+      n === 1 ? (past ? 'last month' : 'next month') : `${n} month${n > 1 ? 's' : ''}`,
+    year: (n, past) =>
+      n === 1 ? (past ? 'last year' : 'next year') : `${n} year${n > 1 ? 's' : ''}`,
+    day: (n, past) => (n === 1 ? (past ? 'yesterday' : 'tomorrow') : `${n} day${n > 1 ? 's' : ''}`),
+    week: (n, past) =>
+      n === 1 ? (past ? 'last week' : 'next week') : `${n} week${n > 1 ? 's' : ''}`,
+    hour: (n) => `${n} hour${n > 1 ? 's' : ''}`,
+    minute: (n) => `${n} minute${n > 1 ? 's' : ''}`,
+    second: (n) => `${n} second${n > 1 ? 's' : ''}`
+  }
+}
+
+export const useTimeAgo = (time: Date | number | string) => {
+  const localeStore = useLocaleStoreWithOut()
+
+  const currentLocale = computed(() => localeStore.getCurrentLocale)
+
+  const timeAgo = useTimeAgoCore(time, {
+    messages: TIME_AGO_MESSAGE_MAP[unref(currentLocale).lang]
+  })
+
+  return timeAgo
+}

+ 24 - 0
src/hooks/web/useTitle.ts

@@ -0,0 +1,24 @@
+import { watch, ref } from 'vue'
+import { isString } from '@/utils/is'
+import { useAppStoreWithOut } from '@/store/modules/app'
+
+const appStore = useAppStoreWithOut()
+
+export const useTitle = (newTitle?: string) => {
+  const { t } = useI18n()
+  const title = ref(
+    newTitle ? `${appStore.getTitle} - ${t(newTitle as string)}` : appStore.getTitle
+  )
+
+  watch(
+    title,
+    (n, o) => {
+      if (isString(n) && n !== o && document) {
+        document.title = n
+      }
+    },
+    { immediate: true }
+  )
+
+  return title
+}

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

@@ -0,0 +1,60 @@
+import { useI18n } from '@/hooks/web/useI18n'
+import { FormItemRule } from 'element-plus'
+
+const { t } = useI18n()
+
+interface LengthRange {
+  min: number
+  max: number
+  message?: string
+}
+
+export const useValidator = () => {
+  const required = (message?: string): FormItemRule => {
+    return {
+      required: true,
+      message: message || t('common.required')
+    }
+  }
+
+  const lengthRange = (options: LengthRange): FormItemRule => {
+    const { min, max, message } = options
+
+    return {
+      min,
+      max,
+      message: message || t('common.lengthRange', { min, max })
+    }
+  }
+
+  const notSpace = (message?: string): FormItemRule => {
+    return {
+      validator: (_, val, callback) => {
+        if (val?.indexOf(' ') !== -1) {
+          callback(new Error(message || t('common.notSpace')))
+        } else {
+          callback()
+        }
+      }
+    }
+  }
+
+  const notSpecialCharacters = (message?: string): FormItemRule => {
+    return {
+      validator: (_, val, callback) => {
+        if (/[`~!@#$%^&*()_+<>?:"{},.\/;'[\]]/gi.test(val)) {
+          callback(new Error(message || t('common.notSpecialCharacters')))
+        } else {
+          callback()
+        }
+      }
+    }
+  }
+
+  return {
+    required,
+    lengthRange,
+    notSpace,
+    notSpecialCharacters
+  }
+}

+ 55 - 0
src/hooks/web/useWatermark.ts

@@ -0,0 +1,55 @@
+const domSymbol = Symbol('watermark-dom')
+
+export function useWatermark(appendEl: HTMLElement | null = document.body) {
+  let func: Fn = () => {}
+  const id = domSymbol.toString()
+  const clear = () => {
+    const domId = document.getElementById(id)
+    if (domId) {
+      const el = appendEl
+      el && el.removeChild(domId)
+    }
+    window.removeEventListener('resize', func)
+  }
+  const createWatermark = (str: string) => {
+    clear()
+
+    const can = document.createElement('canvas')
+    can.width = 300
+    can.height = 240
+
+    const cans = can.getContext('2d')
+    if (cans) {
+      cans.rotate((-20 * Math.PI) / 120)
+      cans.font = '15px Vedana'
+      cans.fillStyle = 'rgba(0, 0, 0, 0.15)'
+      cans.textAlign = 'left'
+      cans.textBaseline = 'middle'
+      cans.fillText(str, can.width / 20, can.height)
+    }
+
+    const div = document.createElement('div')
+    div.id = id
+    div.style.pointerEvents = 'none'
+    div.style.top = '0px'
+    div.style.left = '0px'
+    div.style.position = 'absolute'
+    div.style.zIndex = '100000000'
+    div.style.width = document.documentElement.clientWidth + 'px'
+    div.style.height = document.documentElement.clientHeight + 'px'
+    div.style.background = 'url(' + can.toDataURL('image/png') + ') left top repeat'
+    const el = appendEl
+    el && el.appendChild(div)
+    return id
+  }
+
+  function setWatermark(str: string) {
+    createWatermark(str)
+    func = () => {
+      createWatermark(str)
+    }
+    window.addEventListener('resize', func)
+  }
+
+  return { setWatermark, clear }
+}

+ 50 - 0
src/layout/components/Menu/src/components/useRenderMenuItem.tsx

@@ -0,0 +1,50 @@
+import { ElSubMenu, ElMenuItem } from 'element-plus'
+import { hasOneShowingChild } from '../helper'
+import { isUrl } from '@/utils/is'
+import { useRenderMenuTitle } from './useRenderMenuTitle'
+import { pathResolve } from '@/utils/routerHelper'
+
+const { renderMenuTitle } = useRenderMenuTitle()
+
+export const useRenderMenuItem = () =>
+  // allRouters: AppRouteRecordRaw[] = [],
+  {
+    const renderMenuItem = (routers: AppRouteRecordRaw[], parentPath = '/') => {
+      return routers
+        .filter((v) => !v.meta?.hidden)
+        .map((v) => {
+          const meta = v.meta ?? {}
+          const { oneShowingChild, onlyOneChild } = hasOneShowingChild(v.children, v)
+          const fullPath = isUrl(v.path) ? v.path : pathResolve(parentPath, v.path) // getAllParentPath<AppRouteRecordRaw>(allRouters, v.path).join('/')
+
+          if (
+            oneShowingChild &&
+            (!onlyOneChild?.children || onlyOneChild?.noShowingChildren) &&
+            !meta?.alwaysShow
+          ) {
+            return (
+              <ElMenuItem
+                index={onlyOneChild ? pathResolve(fullPath, onlyOneChild.path) : fullPath}
+              >
+                {{
+                  default: () => renderMenuTitle(onlyOneChild ? onlyOneChild?.meta : meta)
+                }}
+              </ElMenuItem>
+            )
+          } else {
+            return (
+              <ElSubMenu index={fullPath}>
+                {{
+                  title: () => renderMenuTitle(meta),
+                  default: () => renderMenuItem(v.children!, fullPath)
+                }}
+              </ElSubMenu>
+            )
+          }
+        })
+    }
+
+    return {
+      renderMenuItem
+    }
+  }

+ 27 - 0
src/layout/components/Menu/src/components/useRenderMenuTitle.tsx

@@ -0,0 +1,27 @@
+import type { RouteMeta } from 'vue-router'
+import { Icon } from '@/components/Icon'
+import { useI18n } from '@/hooks/web/useI18n'
+
+export const useRenderMenuTitle = () => {
+  const renderMenuTitle = (meta: RouteMeta) => {
+    const { t } = useI18n()
+    const { title = 'Please set title', icon } = meta
+
+    return icon ? (
+      <>
+        <Icon icon={meta.icon}></Icon>
+        <span class="v-menu__title overflow-hidden overflow-ellipsis whitespace-nowrap">
+          {t(title as string)}
+        </span>
+      </>
+    ) : (
+      <span class="v-menu__title overflow-hidden overflow-ellipsis whitespace-nowrap">
+        {t(title as string)}
+      </span>
+    )
+  }
+
+  return {
+    renderMenuTitle
+  }
+}

+ 113 - 0
src/layout/components/UserInfo/src/UserInfo.vue

@@ -0,0 +1,113 @@
+<script lang="ts" setup>
+import { ElMessageBox } from 'element-plus'
+
+import avatarImg from '@/assets/imgs/avatar.jpg'
+import { useDesign } from '@/hooks/web/useDesign'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { useUserStore } from '@/store/modules/user'
+import LockDialog from './components/LockDialog.vue'
+import LockPage from './components/LockPage.vue'
+import { useLockStore } from '@/store/modules/lock'
+
+defineOptions({ name: 'UserInfo' })
+
+const { t } = useI18n()
+
+const { push, replace } = useRouter()
+
+const userStore = useUserStore()
+
+const tagsViewStore = useTagsViewStore()
+
+const { getPrefixCls } = useDesign()
+
+const prefixCls = getPrefixCls('user-info')
+
+const avatar = computed(() => userStore.user.avatar ?? avatarImg)
+const userName = computed(() => userStore.user.nickname ?? 'Admin')
+
+// 锁定屏幕
+const lockStore = useLockStore()
+const getIsLock = computed(() => lockStore.getLockInfo?.isLock ?? false)
+const dialogVisible = ref<boolean>(false)
+const lockScreen = () => {
+  dialogVisible.value = true
+}
+
+const loginOut = async () => {
+  try {
+    await ElMessageBox.confirm(t('common.loginOutMessage'), t('common.reminder'), {
+      confirmButtonText: t('common.ok'),
+      cancelButtonText: t('common.cancel'),
+      type: 'warning'
+    })
+    await userStore.loginOut()
+    tagsViewStore.delAllViews()
+    replace('/login?redirect=/index')
+  } catch {}
+}
+const toProfile = async () => {
+  push('/user/profile')
+}
+// const toDocument = () => {
+//   window.open('https://doc.iocoder.cn/')
+// }
+</script>
+
+<template>
+  <ElDropdown class="custom-hover" :class="prefixCls" trigger="click">
+    <div class="flex items-center">
+      <ElAvatar :src="avatar" alt="" class="w-[calc(var(--logo-height)-25px)] rounded-[50%]" />
+      <span class="pl-[5px] text-14px text-[var(--top-header-text-color)] <lg:hidden">
+        {{ userName }}
+      </span>
+    </div>
+    <template #dropdown>
+      <ElDropdownMenu>
+        <ElDropdownItem>
+          <Icon icon="ep:tools" />
+          <div @click="toProfile">{{ t('common.profile') }}</div>
+        </ElDropdownItem>
+        <!-- <ElDropdownItem>
+          <Icon icon="ep:menu" />
+          <div @click="toDocument">{{ t('common.document') }}</div>
+        </ElDropdownItem> -->
+        <ElDropdownItem divided>
+          <Icon icon="ep:lock" />
+          <div @click="lockScreen">{{ t('lock.lockScreen') }}</div>
+        </ElDropdownItem>
+        <ElDropdownItem divided @click="loginOut">
+          <Icon icon="ep:switch-button" />
+          <div>{{ t('common.loginOut') }}</div>
+        </ElDropdownItem>
+      </ElDropdownMenu>
+    </template>
+  </ElDropdown>
+
+  <LockDialog v-if="dialogVisible" v-model="dialogVisible" />
+
+  <teleport to="body">
+    <transition name="fade-bottom" mode="out-in">
+      <LockPage v-if="getIsLock" />
+    </transition>
+  </teleport>
+</template>
+
+<style scoped lang="scss">
+.fade-bottom-enter-active,
+.fade-bottom-leave-active {
+  transition:
+    opacity 0.25s,
+    transform 0.3s;
+}
+
+.fade-bottom-enter-from {
+  opacity: 0;
+  transform: translateY(-10%);
+}
+
+.fade-bottom-leave-to {
+  opacity: 0;
+  transform: translateY(10%);
+}
+</style>

+ 306 - 0
src/layout/components/useRenderLayout.tsx

@@ -0,0 +1,306 @@
+import { computed } from 'vue'
+import { useAppStore } from '@/store/modules/app'
+import { Menu } from '@/layout/components/Menu'
+import { TabMenu } from '@/layout/components/TabMenu'
+import { TagsView } from '@/layout/components/TagsView'
+import { Logo } from '@/layout/components/Logo'
+import AppView from './AppView.vue'
+import ToolHeader from './ToolHeader.vue'
+import { ElScrollbar } from 'element-plus'
+import { useDesign } from '@/hooks/web/useDesign'
+
+const { getPrefixCls } = useDesign()
+
+const prefixCls = getPrefixCls('layout')
+
+const appStore = useAppStore()
+
+const pageLoading = computed(() => appStore.getPageLoading)
+
+// 标签页
+const tagsView = computed(() => appStore.getTagsView)
+
+// 菜单折叠
+const collapse = computed(() => appStore.getCollapse)
+
+// logo
+const logo = computed(() => appStore.logo)
+
+// 固定头部
+const fixedHeader = computed(() => appStore.getFixedHeader)
+
+// 是否是移动端
+const mobile = computed(() => appStore.getMobile)
+
+// 固定菜单
+const fixedMenu = computed(() => appStore.getFixedMenu)
+
+export const useRenderLayout = () => {
+  const renderClassic = () => {
+    return (
+      <>
+        <div
+          class={[
+            'absolute top-0 left-0 h-full layout-border__right',
+            { '!fixed z-3000': mobile.value }
+          ]}
+        >
+          {logo.value ? (
+            <Logo
+              class={[
+                'bg-[var(--left-menu-bg-color)] relative',
+                {
+                  '!pl-0': mobile.value && collapse.value,
+                  'w-[var(--left-menu-min-width)]': appStore.getCollapse,
+                  'w-[var(--left-menu-max-width)]': !appStore.getCollapse
+                }
+              ]}
+              style="transition: all var(--transition-time-02);"
+            ></Logo>
+          ) : undefined}
+          <Menu class={[{ '!h-[calc(100%-var(--logo-height))]': logo.value }]}></Menu>
+        </div>
+        <div
+          class={[
+            `${prefixCls}-content`,
+            'absolute top-0 h-[100%]',
+            {
+              'w-[calc(100%-var(--left-menu-min-width))] left-[var(--left-menu-min-width)]':
+                collapse.value && !mobile.value && !mobile.value,
+              'w-[calc(100%-var(--left-menu-max-width))] left-[var(--left-menu-max-width)]':
+                !collapse.value && !mobile.value && !mobile.value,
+              'fixed !w-full !left-0': mobile.value
+            }
+          ]}
+          style="transition: all var(--transition-time-02);"
+        >
+          <ElScrollbar
+            v-loading={pageLoading.value}
+            class={[
+              `${prefixCls}-content-scrollbar`,
+              {
+                '!h-[calc(100%-var(--top-tool-height)-var(--tags-view-height))] mt-[calc(var(--top-tool-height)+var(--tags-view-height))]':
+                  fixedHeader.value
+              }
+            ]}
+          >
+            <div
+              class={[
+                {
+                  'fixed top-0 left-0 z-10': fixedHeader.value,
+                  'w-[calc(100%-var(--left-menu-min-width))] !left-[var(--left-menu-min-width)]':
+                    collapse.value && fixedHeader.value && !mobile.value,
+                  'w-[calc(100%-var(--left-menu-max-width))] !left-[var(--left-menu-max-width)]':
+                    !collapse.value && fixedHeader.value && !mobile.value,
+                  '!w-full !left-0': mobile.value
+                }
+              ]}
+              style="transition: all var(--transition-time-02);"
+            >
+              <ToolHeader
+                class={[
+                  'bg-[var(--top-header-bg-color)]',
+                  {
+                    'layout-border__bottom': !tagsView.value
+                  }
+                ]}
+              ></ToolHeader>
+
+              {tagsView.value ? (
+                <TagsView class="layout-border__top layout-border__bottom"></TagsView>
+              ) : undefined}
+            </div>
+
+            <AppView></AppView>
+          </ElScrollbar>
+        </div>
+      </>
+    )
+  }
+
+  const renderTopLeft = () => {
+    return (
+      <>
+        <div class="relative flex items-center bg-[var(--top-header-bg-color)] layout-border__bottom dark:bg-[var(--el-bg-color)]">
+          {logo.value ? <Logo class="custom-hover"></Logo> : undefined}
+
+          <ToolHeader class="flex-1"></ToolHeader>
+        </div>
+        <div class="absolute left-0 top-[var(--logo-height)+1px] h-[calc(100%-1px-var(--logo-height))] w-full flex">
+          <Menu class="relative layout-border__right !h-full"></Menu>
+          <div
+            class={[
+              `${prefixCls}-content`,
+              'h-[100%]',
+              {
+                'w-[calc(100%-var(--left-menu-min-width))] left-[var(--left-menu-min-width)]':
+                  collapse.value,
+                'w-[calc(100%-var(--left-menu-max-width))] left-[var(--left-menu-max-width)]':
+                  !collapse.value
+              }
+            ]}
+            style="transition: all var(--transition-time-02);"
+          >
+            <ElScrollbar
+              v-loading={pageLoading.value}
+              class={[
+                `${prefixCls}-content-scrollbar`,
+                {
+                  '!h-[calc(100%-var(--tags-view-height))] mt-[calc(var(--tags-view-height))]':
+                    fixedHeader.value && tagsView.value
+                }
+              ]}
+            >
+              {tagsView.value ? (
+                <TagsView
+                  class={[
+                    'layout-border__bottom absolute',
+                    {
+                      '!fixed top-0 left-0 z-10': fixedHeader.value,
+                      'w-[calc(100%-var(--left-menu-min-width))] !left-[var(--left-menu-min-width)] mt-[calc(var(--logo-height)+1px)]':
+                        collapse.value && fixedHeader.value,
+                      'w-[calc(100%-var(--left-menu-max-width))] !left-[var(--left-menu-max-width)] mt-[calc(var(--logo-height)+1px)]':
+                        !collapse.value && fixedHeader.value
+                    }
+                  ]}
+                  style="transition: width var(--transition-time-02), left var(--transition-time-02);"
+                ></TagsView>
+              ) : undefined}
+
+              <AppView></AppView>
+            </ElScrollbar>
+          </div>
+        </div>
+      </>
+    )
+  }
+
+  const renderTop = () => {
+    return (
+      <>
+        <div
+          class={[
+            'flex items-center justify-between bg-[var(--top-header-bg-color)] relative',
+            {
+              'layout-border__bottom': !tagsView.value
+            }
+          ]}
+        >
+          {logo.value ? <Logo class="custom-hover"></Logo> : undefined}
+          <Menu class="h-[var(--top-tool-height)] flex-1 px-10px"></Menu>
+          <ToolHeader></ToolHeader>
+        </div>
+        <div
+          class={[
+            `${prefixCls}-content`,
+            'w-full',
+            {
+              'h-[calc(100%-var(--app-footer-height))]': !fixedHeader.value,
+              'h-[calc(100%-var(--tags-view-height)-var(--app-footer-height))]': fixedHeader.value
+            }
+          ]}
+        >
+          <ElScrollbar
+            v-loading={pageLoading.value}
+            class={[
+              `${prefixCls}-content-scrollbar`,
+              {
+                'mt-[var(--tags-view-height)] !pb-[calc(var(--tags-view-height)+var(--app-footer-height))]':
+                  fixedHeader.value,
+                'pb-[var(--app-footer-height)]': !fixedHeader.value
+              }
+            ]}
+          >
+            {tagsView.value ? (
+              <TagsView
+                class={[
+                  'layout-border__bottom layout-border__top relative',
+                  {
+                    '!fixed w-full top-[calc(var(--top-tool-height)+1px)] left-0': fixedHeader.value
+                  }
+                ]}
+                style="transition: width var(--transition-time-02), left var(--transition-time-02);"
+              ></TagsView>
+            ) : undefined}
+
+            <AppView></AppView>
+          </ElScrollbar>
+        </div>
+      </>
+    )
+  }
+
+  const renderCutMenu = () => {
+    return (
+      <>
+        <div class="relative flex items-center bg-[var(--top-header-bg-color)] layout-border__bottom">
+          {logo.value ? <Logo class="custom-hover !pr-15px"></Logo> : undefined}
+
+          <ToolHeader class="flex-1"></ToolHeader>
+        </div>
+        <div class="absolute left-0 top-[var(--logo-height)] h-[calc(100%-var(--logo-height))] w-[calc(100%-2px)] flex">
+          <TabMenu></TabMenu>
+          <div
+            class={[
+              `${prefixCls}-content`,
+              'h-[100%]',
+              {
+                'w-[calc(100%-var(--tab-menu-min-width))] left-[var(--tab-menu-min-width)]':
+                  collapse.value && !fixedMenu.value,
+                'w-[calc(100%-var(--tab-menu-max-width))] left-[var(--tab-menu-max-width)]':
+                  !collapse.value && !fixedMenu.value,
+                'w-[calc(100%-var(--tab-menu-min-width)-var(--left-menu-max-width))] ml-[var(--left-menu-max-width)]':
+                  collapse.value && fixedMenu.value,
+                'w-[calc(100%-var(--tab-menu-max-width)-var(--left-menu-max-width))] ml-[var(--left-menu-max-width)]':
+                  !collapse.value && fixedMenu.value
+              }
+            ]}
+            style="transition: all var(--transition-time-02);"
+          >
+            <ElScrollbar
+              v-loading={pageLoading.value}
+              class={[
+                `${prefixCls}-content-scrollbar`,
+                {
+                  '!h-[calc(100%-var(--tags-view-height))] mt-[calc(var(--tags-view-height))]':
+                    fixedHeader.value && tagsView.value
+                }
+              ]}
+            >
+              {tagsView.value ? (
+                <TagsView
+                  class={[
+                    'relative layout-border__bottom layout-border__top',
+                    {
+                      '!fixed top-0 left-0 z-10': fixedHeader.value,
+                      'w-[calc(100%-var(--tab-menu-min-width))] !left-[var(--tab-menu-min-width)] mt-[var(--logo-height)]':
+                        collapse.value && fixedHeader.value,
+                      'w-[calc(100%-var(--tab-menu-max-width))] !left-[var(--tab-menu-max-width)] mt-[var(--logo-height)]':
+                        !collapse.value && fixedHeader.value,
+                      '!fixed top-0 !left-[var(--tab-menu-min-width)+var(--left-menu-max-width)] z-10':
+                        fixedHeader.value && fixedMenu.value,
+                      'w-[calc(100%-var(--tab-menu-min-width)-var(--left-menu-max-width))] !left-[var(--tab-menu-min-width)+var(--left-menu-max-width)] mt-[var(--logo-height)]':
+                        collapse.value && fixedHeader.value && fixedMenu.value,
+                      'w-[calc(100%-var(--tab-menu-max-width)-var(--left-menu-max-width))] !left-[var(--tab-menu-max-width)+var(--left-menu-max-width)] mt-[var(--logo-height)]':
+                        !collapse.value && fixedHeader.value && fixedMenu.value
+                    }
+                  ]}
+                  style="transition: width var(--transition-time-02), left var(--transition-time-02);"
+                ></TagsView>
+              ) : undefined}
+
+              <AppView></AppView>
+            </ElScrollbar>
+          </div>
+        </div>
+      </>
+    )
+  }
+
+  return {
+    renderClassic,
+    renderTopLeft,
+    renderTop,
+    renderCutMenu
+  }
+}

+ 103 - 0
src/store/modules/user.ts

@@ -0,0 +1,103 @@
+import { store } from '@/store'
+import { defineStore } from 'pinia'
+import { getAccessToken, removeToken } from '@/utils/auth'
+import { CACHE_KEY, useCache, deleteUserCache } from '@/hooks/web/useCache'
+import { getInfo, loginOut } from '@/api/login'
+
+const { wsCache } = useCache()
+
+interface UserVO {
+  id: number
+  avatar: string
+  nickname: string
+  deptId: number
+}
+
+interface UserInfoVO {
+  // USER 缓存
+  permissions: string[]
+  roles: string[]
+  isSetUser: boolean
+  user: UserVO
+}
+
+export const useUserStore = defineStore('admin-user', {
+  state: (): UserInfoVO => ({
+    permissions: [],
+    roles: [],
+    isSetUser: false,
+    user: {
+      id: 0,
+      avatar: '',
+      nickname: '',
+      deptId: 0
+    }
+  }),
+  getters: {
+    getPermissions(): string[] {
+      return this.permissions
+    },
+    getRoles(): string[] {
+      return this.roles
+    },
+    getIsSetUser(): boolean {
+      return this.isSetUser
+    },
+    getUser(): UserVO {
+      return this.user
+    }
+  },
+  actions: {
+    async setUserInfoAction() {
+      if (!getAccessToken()) {
+        this.resetState()
+        return null
+      }
+      let userInfo = wsCache.get(CACHE_KEY.USER)
+      if (!userInfo) {
+        userInfo = await getInfo()
+      }
+      this.permissions = userInfo.permissions
+      this.roles = userInfo.roles
+      this.user = userInfo.user
+      this.isSetUser = true
+      wsCache.set(CACHE_KEY.USER, userInfo)
+      wsCache.set(CACHE_KEY.ROLE_ROUTERS, userInfo.menus)
+    },
+    async setUserAvatarAction(avatar: string) {
+      const userInfo = wsCache.get(CACHE_KEY.USER)
+      // NOTE: 是否需要像`setUserInfoAction`一样判断`userInfo != null`
+      this.user.avatar = avatar
+      userInfo.user.avatar = avatar
+      wsCache.set(CACHE_KEY.USER, userInfo)
+    },
+    async setUserNicknameAction(nickname: string) {
+      const userInfo = wsCache.get(CACHE_KEY.USER)
+      // NOTE: 是否需要像`setUserInfoAction`一样判断`userInfo != null`
+      this.user.nickname = nickname
+      userInfo.user.nickname = nickname
+      wsCache.set(CACHE_KEY.USER, userInfo)
+    },
+    async loginOut() {
+      await loginOut()
+      removeToken()
+      deleteUserCache() // 删除用户缓存
+      this.resetState()
+    },
+    resetState() {
+      this.permissions = []
+      this.roles = []
+      this.isSetUser = false
+      this.user = {
+        id: 0,
+        avatar: '',
+        nickname: '',
+        deptId: 0
+      }
+    }
+  }
+})
+
+export const useUserStoreWithOut = () => {
+  return useUserStore(store)
+}

+ 66 - 0
src/styles/var.css

@@ -0,0 +1,66 @@
+:root {
+  --login-bg-color: #293146;
+
+  --left-menu-max-width: 200px;
+
+  --left-menu-min-width: 64px;
+
+  --left-menu-bg-color: #001529;
+
+  --left-menu-bg-light-color: #0f2438;
+
+  --left-menu-bg-active-color: var(--el-color-primary);
+
+  --left-menu-text-color: #bfcbd9;
+
+  --left-menu-text-active-color: #fff;
+
+  --left-menu-collapse-bg-active-color: var(--el-color-primary);
+  /* left menu end */
+
+  /* logo start */
+  --logo-height: 50px;
+
+  --logo-title-text-color: #fff;
+  /* logo end */
+
+  /* header start */
+  --top-header-bg-color: '#fff';
+
+  --top-header-text-color: 'inherit';
+
+  --top-header-hover-color: #f6f6f6;
+
+  --top-tool-height: var(--logo-height);
+
+  --top-tool-p-x: 0;
+
+  --tags-view-height: 35px;
+  /* header start */
+
+  /* tab menu start */
+  --tab-menu-max-width: 80px;
+
+  --tab-menu-min-width: 30px;
+
+  --tab-menu-collapse-height: 36px;
+  /* tab menu end */
+
+  --app-content-padding: 20px;
+
+  --app-content-bg-color: #f5f7f9;
+
+  --app-footer-height: 50px;
+
+  --transition-time-02: 0.2s;
+}
+
+.dark {
+  --app-content-bg-color: var(--el-bg-color);
+}
+
+html,
+body {
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}

+ 4 - 0
src/styles/variables.scss

@@ -0,0 +1,4 @@
+// 命名空间
+$namespace: v;
+// el命名空间
+$elNamespace: el;

+ 16 - 0
src/utils/tsxHelper.ts

@@ -0,0 +1,16 @@
+import { Slots } from 'vue'
+import { isFunction } from '@/utils/is'
+
+export const getSlot = (slots: Slots, slot = 'default', data?: Recordable) => {
+  // Reflect.has 判断一个对象是否存在某个属性
+  if (!slots || !Reflect.has(slots, slot)) {
+    return null
+  }
+  if (!isFunction(slots[slot])) {
+    console.error(`${slot} is not a function!`)
+    return null
+  }
+  const slotFn = slots[slot]
+  if (!slotFn) return null
+  return slotFn(data)
+}

+ 55 - 0
src/views/Home/types.ts

@@ -0,0 +1,55 @@
+export type WorkplaceTotal = {
+  project: number
+  access: number
+  todo: number
+}
+
+export type Project = {
+  name: string
+  icon: string
+  message: string
+  personal: string
+  time: Date | number | string
+}
+
+export type Notice = {
+  title: string
+  type: string
+  keys: string[]
+  date: Date | number | string
+}
+
+export type Shortcut = {
+  name: string
+  icon: string
+  url: string
+}
+
+export type RadarData = {
+  personal: number
+  team: number
+  max: number
+  name: string
+}
+export type AnalysisTotalTypes = {
+  users: number
+  messages: number
+  moneys: number
+  shoppings: number
+}
+
+export type UserAccessSource = {
+  value: number
+  name: string
+}
+
+export type WeeklyUserActivity = {
+  value: number
+  name: string
+}
+
+export type MonthlySales = {
+  name: string
+  estimate: number
+  actual: number
+}

+ 42 - 0
src/views/Login/components/useLogin.ts

@@ -0,0 +1,42 @@
+import { Ref } from 'vue'
+
+export enum LoginStateEnum {
+  LOGIN,
+  REGISTER,
+  RESET_PASSWORD,
+  MOBILE,
+  QR_CODE,
+  SSO
+}
+
+const currentState = ref(LoginStateEnum.LOGIN)
+
+export function useLoginState() {
+  function setLoginState(state: LoginStateEnum) {
+    currentState.value = state
+  }
+  const getLoginState = computed(() => currentState.value)
+
+  function handleBackLogin() {
+    setLoginState(LoginStateEnum.LOGIN)
+  }
+
+  return {
+    setLoginState,
+    getLoginState,
+    handleBackLogin
+  }
+}
+
+export function useFormValid<T extends Object = any>(formRef: Ref<any>) {
+  async function validForm() {
+    const form = unref(formRef)
+    if (!form) return
+    const data = await form.validate()
+    return data as T
+  }
+
+  return {
+    validForm
+  }
+}

+ 45 - 0
src/views/Profile/components/UserAvatar.vue

@@ -0,0 +1,45 @@
+<template>
+  <div class="change-avatar">
+    <CropperAvatar
+      ref="cropperRef"
+      :btnProps="{ preIcon: 'ant-design:cloud-upload-outlined' }"
+      :showBtn="false"
+      :value="img"
+      width="120px"
+      @change="handelUpload"
+    />
+  </div>
+</template>
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import { uploadAvatar } from '@/api/system/user/profile'
+import { CropperAvatar } from '@/components/Cropper'
+import { useUserStore } from '@/store/modules/user'
+
+
+defineOptions({ name: 'UserAvatar' })
+
+defineProps({
+  img: propTypes.string.def('')
+})
+
+const userStore = useUserStore()
+
+
+const cropperRef = ref()
+const handelUpload = async ({ data }) => {
+  const res = await uploadAvatar({ avatarFile: data })
+  cropperRef.value.close()
+  userStore.setUserAvatarAction(res.data)
+}
+</script>
+
+<style lang="scss" scoped>
+.change-avatar {
+  img {
+    display: block;
+    margin-bottom: 15px;
+    border-radius: 50%;
+  }
+}
+</style>

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

@@ -0,0 +1,107 @@
+<template>
+  <el-table :data="socialUsers" :show-header="false">
+    <el-table-column fixed="left" title="序号" type="seq" width="60" />
+    <el-table-column align="left" label="社交平台" width="120">
+      <template #default="{ row }">
+        <img :src="row.img" alt="" class="h-5 align-middle" />
+        <p class="mr-5">{{ row.title }}</p>
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="操作">
+      <template #default="{ row }">
+        <template v-if="row.openid">
+          已绑定
+          <XTextButton class="mr-5" title="(解绑)" type="primary" @click="unbind(row)" />
+        </template>
+        <template v-else>
+          未绑定
+          <XTextButton class="mr-5" title="(绑定)" type="primary" @click="bind(row)" />
+        </template>
+      </template>
+    </el-table-column>
+  </el-table>
+</template>
+<script lang="ts" setup>
+import { SystemUserSocialTypeEnum } from '@/utils/constants'
+import { getUserProfile, ProfileVO } from '@/api/system/user/profile'
+import { socialAuthRedirect, socialBind, socialUnbind } from '@/api/system/user/socialUser'
+
+defineOptions({ name: 'UserSocial' })
+defineProps<{
+  activeName: string
+}>()
+const message = useMessage()
+const socialUsers = ref<any[]>([])
+const userInfo = ref<ProfileVO>()
+
+const initSocial = async () => {
+  socialUsers.value = [] // 重置避免无限增长
+  const res = await getUserProfile()
+  userInfo.value = res
+  for (const i in SystemUserSocialTypeEnum) {
+    const socialUser = { ...SystemUserSocialTypeEnum[i] }
+    socialUsers.value.push(socialUser)
+    if (userInfo.value?.socialUsers) {
+      for (const j in userInfo.value.socialUsers) {
+        if (socialUser.type === userInfo.value.socialUsers[j].type) {
+          socialUser.openid = userInfo.value.socialUsers[j].openid
+          break
+        }
+      }
+    }
+  }
+}
+const route = useRoute()
+const emit = defineEmits<{
+  (e: 'update:activeName', v: string): void
+}>()
+const bindSocial = () => {
+  // 社交绑定
+  const type = getUrlValue('type')
+  const code = route.query.code
+  const state = route.query.state
+  if (!code) {
+    return
+  }
+  socialBind(type, code, state).then(() => {
+    message.success('绑定成功')
+    emit('update:activeName', 'userSocial')
+  })
+}
+
+// 双层 encode 需要在回调后进行 decode
+function getUrlValue(key: string): string {
+  const url = new URL(decodeURIComponent(location.href))
+  return url.searchParams.get(key) ?? ''
+}
+
+const bind = (row) => {
+  // 双层 encode 解决钉钉回调 type 参数丢失的问题
+  const redirectUri = location.origin + '/user/profile?' + encodeURIComponent(`type=${row.type}`)
+  // 进行跳转
+  socialAuthRedirect(row.type, encodeURIComponent(redirectUri)).then((res) => {
+    window.location.href = res
+  })
+}
+const unbind = async (row) => {
+  const res = await socialUnbind(row.type, row.openid)
+  if (res) {
+    row.openid = undefined
+  }
+  message.success('解绑成功')
+}
+
+onMounted(async () => {
+  await initSocial()
+})
+
+watch(
+  () => route,
+  () => {
+    bindSocial()
+  },
+  {
+    immediate: true
+  }
+)
+</script>

+ 13 - 0
src/views/ai/utils/utils.ts

@@ -0,0 +1,13 @@
+/**
+ * Created by 芋道源码
+ *
+ * AI 枚举类
+ *
+ * 问题:为什么不放在 src/utils/common-utils.ts 呢?
+ * 回答:主要 AI 是可选模块,考虑到独立、解耦,所以放在了 /views/ai/utils/common-utils.ts
+ */
+
+/**  判断字符串是否包含中文  */
+export const hasChinese = (str: string) => {
+  return /[\u4e00-\u9fa5]/.test(str)
+}

+ 132 - 0
src/views/bpm/group/UserGroupForm.vue

@@ -0,0 +1,132 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+    >
+      <el-form-item label="组名" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入组名" />
+      </el-form-item>
+      <el-form-item label="描述">
+        <el-input v-model="formData.description" placeholder="请输入描述" type="textarea" />
+      </el-form-item>
+      <el-form-item label="成员" prop="userIds">
+        <el-select v-model="formData.userIds" multiple placeholder="请选择成员">
+          <el-option
+            v-for="user in userList"
+            :key="user.id"
+            :label="user.nickname"
+            :value="user.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { CommonStatusEnum } from '@/utils/constants'
+import * as UserGroupApi from '@/api/bpm/userGroup'
+import * as UserApi from '@/api/system/user'
+
+defineOptions({ name: 'UserGroupForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  description: undefined,
+  userIds: undefined,
+  status: CommonStatusEnum.ENABLE
+})
+const formRules = reactive({
+  name: [{ required: true, message: '组名不能为空', trigger: 'blur' }],
+  description: [{ required: true, message: '描述不能为空', trigger: 'blur' }],
+  userIds: [{ required: true, message: '成员不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const userList = ref<any[]>([]) // 用户列表
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await UserGroupApi.getUserGroup(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 加载用户列表
+  userList.value = await UserApi.getSimpleUserList()
+}
+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 UserGroupApi.UserGroupVO
+    if (formType.value === 'create') {
+      await UserGroupApi.createUserGroup(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await UserGroupApi.updateUserGroup(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    description: undefined,
+    userIds: undefined,
+    status: CommonStatusEnum.ENABLE
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 157 - 0
src/views/erp/stock/warehouse/WarehouseForm.vue

@@ -0,0 +1,157 @@
+<!-- ERP 仓库表单 -->
+<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="address">
+        <el-input v-model="formData.address" placeholder="请输入仓库地址" />
+      </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="warehousePrice">
+        <el-input-number
+          v-model="formData.warehousePrice"
+          placeholder="请输入仓储费,单位:元/天/KG"
+          :min="0"
+          :precision="2"
+          class="!w-1/1"
+        />
+      </el-form-item>
+      <el-form-item label="搬运费" prop="truckagePrice">
+        <el-input-number
+          v-model="formData.truckagePrice"
+          placeholder="请输入搬运费,单位:元"
+          :min="0"
+          :precision="2"
+          class="!w-1/1"
+        />
+      </el-form-item>
+      <el-form-item label="负责人" prop="principal">
+        <el-input v-model="formData.principal" placeholder="请输入负责人" />
+      </el-form-item>
+      <el-form-item label="排序" prop="sort">
+        <el-input-number
+          v-model="formData.sort"
+          placeholder="请输入排序"
+          :precision="0"
+          class="!w-1/1"
+        />
+      </el-form-item>
+      <el-form-item label="备注" prop="remark">
+        <el-input type="textarea" v-model="formData.remark" placeholder="请输入备注" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
+import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse'
+import { CommonStatusEnum } from '@/utils/constants'
+
+/** ERP 仓库表单 */
+defineOptions({ name: 'WarehouseForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  address: undefined,
+  sort: undefined,
+  remark: undefined,
+  principal: undefined,
+  warehousePrice: undefined,
+  truckagePrice: undefined,
+  status: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '仓库名称不能为空', trigger: 'blur' }],
+  sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await WarehouseApi.getWarehouse(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+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 WarehouseVO
+    if (formType.value === 'create') {
+      await WarehouseApi.createWarehouse(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await WarehouseApi.updateWarehouse(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    address: undefined,
+    sort: undefined,
+    remark: undefined,
+    principal: undefined,
+    warehousePrice: undefined,
+    truckagePrice: undefined,
+    status: CommonStatusEnum.ENABLE
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 138 - 0
src/views/gismodel/twinmodelIcon/TwinModelsForm.vue

@@ -0,0 +1,138 @@
+<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="modelName">
+        <el-input v-model="formData.modelName" placeholder="请输入图标名称" />
+      </el-form-item>
+      <el-form-item label="图标路径" prop="modelPath">
+        <el-input v-model="formData.modelPath" placeholder="请输入图标路径" />
+      </el-form-item>
+      <el-form-item label="图标图片" prop="imagePath">
+        <UploadImg v-model="formData.imagePath" />
+      </el-form-item>
+      <el-form-item label="图标缩略图" prop="thumbnailPath">
+        <UploadThumbnail v-model="formData.thumbnailPath" />
+      </el-form-item>
+      <el-form-item label="图标描述" prop="description">
+        <el-input
+          type="textarea"
+          :rows="2"
+          placeholder="请输入内容"
+          v-model="formData.description"/>
+      </el-form-item>
+      <el-form-item label="图标状态" prop="status">
+        <el-select v-model="formData.status" placeholder="请选择图标状态">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="是否为公共模型" prop="tenantId">
+        <el-radio-group v-model="formData.tenantId" class="!w-240px">
+          <el-radio :label="0">是</el-radio>
+          <el-radio :label="1">否</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, DICT_TYPE } from '@/utils/dict'
+import { TwinModelIconApi, TwinModelsVO } from '@/api/gismodel/twinmodelicon'
+/** 模型管理 表单 */
+defineOptions({ name: 'TwinModelsForm' })
+
+
+
+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,
+  modelName: undefined,
+  modelPath: undefined,
+  imagePath: undefined,
+  thumbnailPath: undefined,
+  description: undefined,
+  status: undefined,
+  tenantId: undefined,
+})
+const formRules = reactive({
+})
+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 TwinModelIconApi.getTwinModels(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+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 TwinModelsVO
+    if (formType.value === 'create') {
+      await TwinModelIconApi.createTwinModels(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await TwinModelIconApi.updateTwinModels(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    modelName: undefined,
+    modelPath: undefined,
+    imagePath: undefined,
+    thumbnailPath: undefined,
+    description: undefined,
+    status: undefined,
+    tenantId: undefined,
+  }
+  formRef.value?.resetFields()
+}
+
+</script>

+ 146 - 0
src/views/gismodel/twinmodels/TwinModelsForm.vue

@@ -0,0 +1,146 @@
+<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="modelName">
+        <el-input v-model="formData.modelName" placeholder="请输入模型名称" />
+      </el-form-item>
+      <el-form-item label="模型路径" prop="modelPath">
+        <el-input v-model="formData.modelPath" placeholder="请输入模型路径" />
+      </el-form-item>
+      <el-form-item label="模型图片上传" prop="imagePath">
+        <UploadImg v-model="formData.imagePath" />
+      </el-form-item>
+      <el-form-item label="缩略图路径" prop="thumbnailPath">
+        <UploadThumbnail v-model="formData.thumbnailPath" />
+      </el-form-item>
+<!--      <el-form-item label="模型描述" prop="description">-->
+<!--        <Editor v-model="formData.description" height="150px" />-->
+<!--      </el-form-item>-->
+      <el-form-item label="模型描述" prop="description">
+        <el-input
+          type="textarea"
+          :rows="2"
+          placeholder="请输入内容"
+          v-model="formData.description"/>
+      </el-form-item>
+      <el-form-item label="模型状态" prop="status">
+        <el-select v-model="formData.status" placeholder="请选择模型状态">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="是否为公共模型" prop="tenantId">
+        <el-radio-group v-model="formData.tenantId" class="!w-240px">
+          <el-radio :label="0">是</el-radio>
+          <el-radio :label="1">否</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, DICT_TYPE } from '@/utils/dict'
+import { TwinModelsApi, TwinModelsVO } from '@/api/gismodel/twinmodels'
+
+/** 模型管理 表单 */
+defineOptions({ name: 'TwinModelsForm' })
+
+
+
+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,
+  modelName: undefined,
+  modelPath: undefined,
+  imagePath: undefined,
+  thumbnailPath: undefined,
+  description: undefined,
+  // modelType: undefined,
+  status: undefined,
+  publicModel: undefined,
+  tenantId: undefined,
+})
+const formRules = reactive({
+})
+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 TwinModelsApi.getTwinModels(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+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 TwinModelsVO
+    if (formType.value === 'create') {
+      await TwinModelsApi.createTwinModels(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await TwinModelsApi.updateTwinModels(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+formData.value = {
+id: undefined,
+modelName: undefined,
+modelPath: undefined,
+imagePath: undefined,
+thumbnailPath: undefined,
+description: undefined,
+// modelType: undefined,
+status: undefined,
+publicModel: undefined,
+tenantId: undefined,
+}
+formRef.value?.resetFields()
+}
+
+</script>

+ 338 - 0
src/views/layer/visualizemessage/VisualizeMessageForm.vue

@@ -0,0 +1,338 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="124px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="上级ID" prop="parentId">
+        <el-input v-model="formData.parentId" placeholder="请输入上级ID,若为顶级项目则为0" />
+      </el-form-item>
+      <el-form-item label="关联图层名" prop="associateLayerName">
+        <el-input v-model="formData.associateLayerName" placeholder="请输入关联的矢量图层名" />
+      </el-form-item>
+      <el-form-item label="关联图层ID" prop="associateLayerId">
+        <el-input v-model="formData.associateLayerId" placeholder="请输入关联的矢量图层ID" />
+      </el-form-item>
+      <el-form-item label="排序" prop="sort">
+        <el-input v-model="formData.sort" placeholder="请输入项目排序" />
+      </el-form-item>
+      <el-form-item label="项目名称" prop="itemName">
+        <el-input v-model="formData.itemName" placeholder="请输入项目名称" />
+      </el-form-item>
+      <el-form-item label="项目编号" prop="itemId">
+        <el-input v-model="formData.itemId" placeholder="请输入项目编号" />
+      </el-form-item>
+      <el-form-item label="执行单位" prop="implementedBy">
+        <el-input v-model="formData.implementedBy" placeholder="请输入执行单位" />
+      </el-form-item>
+      <el-form-item label="项目描述" prop="itemDescribe">
+        <el-input
+          v-model="formData.itemDescribe"
+          :autosize="{ minRows: 2, maxRows: 4 }"
+          type="textarea"
+          placeholder="请输入项目描述"
+        />
+      </el-form-item>
+      <el-form-item label="项目相关附件" prop="itemFile">
+        <UploadFile v-model="formData.itemFile" />
+      </el-form-item>
+      <el-form-item label="项目负责人" prop="itemUserName">
+        <el-input v-model="formData.itemUserName" placeholder="请输入项目负责人" />
+      </el-form-item>
+      <el-form-item label="项目总预算" prop="itemBudget">
+        <el-input v-model="formData.itemBudget" placeholder="请输入项目总预算" />
+      </el-form-item>
+      <el-form-item label="已经分配资金" prop="allocatedFunds">
+        <el-input v-model="formData.allocatedFunds" placeholder="请输入已经分配资金" />
+      </el-form-item>
+      <el-form-item label="项目获得资金" prop="itemGetFunds">
+        <el-input v-model="formData.itemGetFunds" placeholder="请输入项目获得资金" />
+      </el-form-item>
+      <el-form-item label="项目开始时间" prop="itemStartTime">
+        <el-date-picker
+          v-model="formData.itemStartTime"
+          type="date"
+          value-format="x"
+          placeholder="选择项目开始时间"
+        />
+      </el-form-item>
+      <el-form-item label="项目预估结束时间" prop="itemEstimateEndTime">
+        <el-date-picker
+          v-model="formData.itemEstimateEndTime"
+          type="date"
+          value-format="x"
+          placeholder="选择项目预估结束时间"
+        />
+      </el-form-item>
+      <el-form-item label="项目实际结束时间" prop="itemActualEndTime">
+        <el-date-picker
+          v-model="formData.itemActualEndTime"
+          type="date"
+          value-format="x"
+          placeholder="选择项目实际结束时间"
+        />
+      </el-form-item>
+      <el-form-item label="项目状态" prop="status">
+        <el-select
+          v-model="formData.status"
+          placeholder="请选择项目状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="项目影响范围" prop="itemInfluenceRange">
+        <el-input v-model="formData.itemInfluenceRange" placeholder="请输入项目影响范围" />
+      </el-form-item>
+      <el-form-item label="项目风险描述" prop="itemRiskDescription">
+        <Editor v-model="formData.itemRiskDescription" height="150px" />
+      </el-form-item>
+      <el-form-item label="地名" prop="placeName">
+        <el-input v-model="formData.placeName" placeholder="请输入地名" />
+      </el-form-item>
+      <el-form-item label="规划用途" prop="planForUse">
+        <el-input v-model="formData.planForUse" placeholder="请输入规划用途" />
+      </el-form-item>
+      <el-form-item label="地块编号" prop="placeId">
+        <el-input v-model="formData.placeId" placeholder="请输入地块编号" />
+      </el-form-item>
+      <el-form-item label="地块类型" prop="placeType">
+        <el-select v-model="formData.placeType" placeholder="请选择地块类型">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.LAYER_VISUALIZEMESSAGE_PLACE_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="产权人或单位名" prop="propertyOwnerNameOrUnitName">
+        <el-input v-model="formData.propertyOwnerNameOrUnitName" placeholder="请输入产权人或单位名" />
+      </el-form-item>
+      <el-form-item label="联系方式" prop="phone">
+        <el-input v-model="formData.phone" placeholder="请输入联系方式" />
+      </el-form-item>
+      <el-form-item label="产权证编号" prop="titleDeedNumber">
+        <el-input v-model="formData.titleDeedNumber" placeholder="请输入产权证编号" />
+      </el-form-item>
+      <el-form-item label="详细地址" prop="address">
+        <el-input v-model="formData.address" placeholder="请输入详细地址" />
+      </el-form-item>
+      <el-form-item label="图层颜色" prop="layerColor">
+        <el-input v-model="formData.layerColor" placeholder="请输入矢量图层颜色" />
+      </el-form-item>
+      <el-form-item label="产权估值" prop="propertyValuation">
+        <el-input v-model="formData.propertyValuation" placeholder="请输入产权估值" />
+      </el-form-item>
+      <el-form-item label="产权简介" prop="propertyRightsIntroduction">
+        <el-input v-model="formData.propertyRightsIntroduction" placeholder="请输入产权简介" />
+      </el-form-item>
+      <el-form-item label="使用权类型" prop="useType">
+        <el-select v-model="formData.useType" placeholder="请选择使用权类型">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.LAYER_VISUALIZEMESSAGE_USE_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="使用权开始时间" prop="useStartTime">
+        <el-date-picker
+          v-model="formData.useStartTime"
+          type="date"
+          value-format="x"
+          placeholder="选择使用权开始时间"
+        />
+      </el-form-item>
+      <el-form-item label="使用权结束时间" prop="useEndTime">
+        <el-date-picker
+          v-model="formData.useEndTime"
+          type="date"
+          value-format="x"
+          placeholder="选择使用权结束时间"
+        />
+      </el-form-item>
+      <el-form-item label="产权法律诉讼记录" prop="titleProceedingsRecords">
+        <el-input v-model="formData.titleProceedingsRecords" placeholder="请输入产权法律诉讼记录" />
+      </el-form-item>
+      <el-form-item label="产权交易信息" prop="propertyTransactionsInfo">
+        <el-input v-model="formData.propertyTransactionsInfo" placeholder="请输入产权交易信息" />
+      </el-form-item>
+      <el-form-item label="抵押情况" prop="mortgageSituation">
+        <el-input v-model="formData.mortgageSituation" placeholder="请输入抵押情况" />
+      </el-form-item>
+      <el-form-item label="产权人照片上传" prop="propertyOwnerImage">
+        <UploadImg v-model="formData.propertyOwnerImage" />
+      </el-form-item>
+      <el-form-item label="实景照片上传" prop="realImage">
+        <UploadImg v-model="formData.realImage" />
+      </el-form-item>
+      <el-form-item label="图片信息描述" prop="imageDescription">
+        <el-input v-model="formData.imageDescription" placeholder="请输入图片信息描述" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { VisualizeMessageApi, VisualizeMessageVO } from '@/api/layer/visualizemessage'
+import {DICT_TYPE, getIntDictOptions} from "@/utils/dict";
+
+/** 项目信息表 表单 */
+defineOptions({ name: 'VisualizeMessageForm' })
+
+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,
+  parentId: undefined,
+  associateLayerName: undefined,
+  associateLayerId: undefined,
+  placeName: undefined,
+  sort: undefined,
+  itemName: undefined,
+  itemId: undefined,
+  implementedBy: undefined,
+  itemDescribe: undefined,
+  itemFile: undefined,
+  itemUserName: undefined,
+  itemBudget: undefined,
+  allocatedFunds: undefined,
+  itemGetFunds: undefined,
+  itemStartTime: undefined,
+  itemEstimateEndTime: undefined,
+  itemActualEndTime: undefined,
+  status: undefined,
+  itemInfluenceRange: undefined,
+  itemRiskDescription: "",
+  planForUse: undefined,
+  placeId: undefined,
+  placeType: undefined,
+  propertyOwnerNameOrUnitName: undefined,
+  phone: undefined,
+  titleDeedNumber: undefined,
+  address: undefined,
+  layerColor: undefined,
+  propertyValuation: undefined,
+  propertyRightsIntroduction: undefined,
+  useType: undefined,
+  useStartTime: undefined,
+  useEndTime: undefined,
+  titleProceedingsRecords: undefined,
+  propertyTransactionsInfo: undefined,
+  mortgageSituation: undefined,
+  propertyOwnerImage: undefined,
+  realImage: undefined,
+  imageDescription: undefined,
+})
+const formRules = reactive({
+})
+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 VisualizeMessageApi.getVisualizeMessage(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+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 VisualizeMessageVO
+    if (formType.value === 'create') {
+      await VisualizeMessageApi.createVisualizeMessage(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await VisualizeMessageApi.updateVisualizeMessage(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    parentId: undefined,
+    associateLayerName: undefined,
+    associateLayerId: undefined,
+    placeName: undefined,
+    sort: undefined,
+    itemName: undefined,
+    itemId: undefined,
+    implementedBy: undefined,
+    itemDescribe: undefined,
+    itemFile: undefined,
+    itemUserName: undefined,
+    itemBudget: undefined,
+    allocatedFunds: undefined,
+    itemGetFunds: undefined,
+    itemStartTime: undefined,
+    itemEstimateEndTime: undefined,
+    itemActualEndTime: undefined,
+    status: undefined,
+    itemInfluenceRange: undefined,
+    itemRiskDescription: "",
+    planForUse: undefined,
+    placeId: undefined,
+    placeType: undefined,
+    propertyOwnerNameOrUnitName: undefined,
+    phone: undefined,
+    titleDeedNumber: undefined,
+    address: undefined,
+    layerColor: undefined,
+    propertyValuation: undefined,
+    propertyRightsIntroduction: undefined,
+    useType: undefined,
+    useStartTime: undefined,
+    useEndTime: undefined,
+    titleProceedingsRecords: undefined,
+    propertyTransactionsInfo: undefined,
+    mortgageSituation: undefined,
+    propertyOwnerImage: undefined,
+    realImage: undefined,
+    imageDescription: undefined,
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 105 - 0
src/views/mall/product/property/value/ValueForm.vue

@@ -0,0 +1,105 @@
+<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.propertyId" disabled="" />
+      </el-form-item>
+      <el-form-item label="名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入名称" />
+      </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 * as PropertyApi from '@/api/mall/product/property'
+
+defineOptions({ name: 'ProductPropertyValueForm' })
+
+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,
+  propertyId: undefined,
+  name: '',
+  remark: ''
+})
+const formRules = reactive({
+  propertyId: [{ required: true, message: '属性不能为空', trigger: 'blur' }],
+  name: [{ required: true, message: '名称不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, propertyId: number, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  formData.value.propertyId = propertyId
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await PropertyApi.getPropertyValue(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 PropertyApi.PropertyValueVO
+    if (formType.value === 'create') {
+      await PropertyApi.createPropertyValue(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await PropertyApi.updatePropertyValue(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    propertyId: undefined,
+    name: '',
+    remark: ''
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 127 - 0
src/views/mall/trade/brokerage/user/UpdateBindUserForm.vue

@@ -0,0 +1,127 @@
+<template>
+  <Dialog v-model="dialogVisible" title="修改上级推广人" width="500">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="80px"
+    >
+      <el-form-item label="推广人" prop="bindUserId">
+        <el-input
+          v-model="formData.bindUserId"
+          placeholder="请输入推广员编号"
+          v-loading="formLoading"
+        >
+          <template #append>
+            <el-button @click="handleGetUser"><Icon icon="ep:search" class="mr-5px" /></el-button>
+          </template>
+        </el-input>
+      </el-form-item>
+    </el-form>
+    <!-- 展示上级推广人的信息 -->
+    <el-descriptions v-if="bindUser" :column="1" border>
+      <el-descriptions-item label="头像">
+        <el-avatar :src="bindUser.avatar" />
+      </el-descriptions-item>
+      <el-descriptions-item label="昵称">{{ bindUser.nickname }}</el-descriptions-item>
+      <el-descriptions-item label="推广资格">
+        <el-tag v-if="bindUser.brokerageEnabled">有</el-tag>
+        <el-tag v-else type="info">无</el-tag>
+      </el-descriptions-item>
+      <el-descriptions-item label="成为推广员的时间">
+        {{ formatDate(bindUser.brokerageTime) }}
+      </el-descriptions-item>
+    </el-descriptions>
+    <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 BrokerageUserApi from '@/api/mall/trade/brokerage/user'
+import { formatDate } from '@/utils/formatTime'
+
+/** 修改上级推广人表单 */
+defineOptions({ name: 'UpdateBindUserForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref()
+const formRef = ref() // 表单 Ref
+const formRules = reactive({
+  bindUserId: [{ required: true, message: '推广员人不能为空', trigger: 'blur' }]
+})
+
+/** 打开弹窗 */
+const open = async (row: BrokerageUserApi.BrokerageUserVO) => {
+  resetForm()
+  // 设置数据
+  formData.value.id = row.id
+  formData.value.bindUserId = row.bindUserId
+  // 反显上级推广人
+  if (row.bindUserId) {
+    await handleGetUser()
+  }
+  dialogVisible.value = true
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+/** 修改上级推广人 */
+const submitForm = async () => {
+  if (formLoading.value) return
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 未查找到合适的上级
+  if (!bindUser.value) {
+    message.error('请先查询并确认推广人')
+    return
+  }
+
+  // 提交请求
+  formLoading.value = true
+  try {
+    // 发起修改
+    await BrokerageUserApi.updateBindUser(formData.value)
+    message.success(t('common.updateSuccess'))
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success', true)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    bindUserId: undefined
+  }
+  formRef.value?.resetFields()
+  bindUser.value = undefined
+}
+
+/** 查询推广员 */
+const bindUser = ref<BrokerageUserApi.BrokerageUserVO>()
+const handleGetUser = async () => {
+  if (formData.value.bindUserId == formData.value.id) {
+    message.error('不能绑定自己为推广人')
+    return
+  }
+  formLoading.value = true
+  bindUser.value = await BrokerageUserApi.getBrokerageUser(formData.value.bindUserId)
+  if (!bindUser.value) {
+    message.warning('推广员不存在')
+  }
+  formLoading.value = false
+}
+</script>

+ 179 - 0
src/views/member/user/UserForm.vue

@@ -0,0 +1,179 @@
+<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="mobile">
+        <el-input v-model="formData.mobile" placeholder="请输入手机号" />
+      </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="nickname">
+        <el-input v-model="formData.nickname" placeholder="请输入用户昵称" />
+      </el-form-item>
+      <el-form-item label="头像" prop="avatar">
+        <UploadImg v-model="formData.avatar" :limit="1" :is-show-tip="false" />
+      </el-form-item>
+      <el-form-item label="真实名字" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入真实名字" />
+      </el-form-item>
+      <el-form-item label="用户性别" prop="sex">
+        <el-radio-group v-model="formData.sex">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="出生日期" prop="birthday">
+        <el-date-picker
+          v-model="formData.birthday"
+          type="date"
+          value-format="x"
+          placeholder="选择出生日期"
+        />
+      </el-form-item>
+      <el-form-item label="所在地" prop="areaId">
+        <el-tree-select
+          v-model="formData.areaId"
+          :data="areaList"
+          :props="defaultProps"
+          :render-after-expand="true"
+        />
+      </el-form-item>
+      <el-form-item label="用户标签" prop="tagIds">
+        <MemberTagSelect v-model="formData.tagIds" show-add />
+      </el-form-item>
+      <el-form-item label="用户分组" prop="groupId">
+        <MemberGroupSelect v-model="formData.groupId" />
+      </el-form-item>
+      <el-form-item label="会员备注" prop="mark">
+        <el-input type="textarea" v-model="formData.mark" placeholder="请输入会员备注" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import * as UserApi from '@/api/member/user'
+import * as AreaApi from '@/api/system/area'
+import { defaultProps } from '@/utils/tree'
+import MemberTagSelect from '@/views/member/tag/components/MemberTagSelect.vue'
+import MemberGroupSelect from '@/views/member/group/components/MemberGroupSelect.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,
+  mobile: undefined,
+  password: undefined,
+  status: undefined,
+  nickname: undefined,
+  avatar: undefined,
+  name: undefined,
+  sex: undefined,
+  areaId: undefined,
+  birthday: undefined,
+  mark: undefined,
+  tagIds: [],
+  groupId: undefined
+})
+const formRules = reactive({
+  mobile: [{ required: true, message: '手机号不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const areaList = 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 UserApi.getUser(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 获得地区列表
+  areaList.value = await AreaApi.getAreaTree()
+}
+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 UserApi.UserVO
+    if (formType.value === 'create') {
+      // 说明:目前暂时没有新增操作。如果自己业务需要,可以进行扩展
+      // await UserApi.createUser(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await UserApi.updateUser(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    mobile: undefined,
+    password: undefined,
+    status: undefined,
+    nickname: undefined,
+    avatar: undefined,
+    name: undefined,
+    sex: undefined,
+    areaId: undefined,
+    birthday: undefined,
+    mark: undefined,
+    tagIds: [],
+    groupId: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 101 - 0
src/views/member/user/UserLevelUpdateForm.vue

@@ -0,0 +1,101 @@
+<template>
+  <Dialog title="修改用户等级" v-model="dialogVisible" width="600">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="用户编号" prop="id">
+        <el-input v-model="formData.id" placeholder="请输入用户昵称" class="!w-240px" disabled />
+      </el-form-item>
+      <el-form-item label="用户昵称" prop="nickname">
+        <el-input
+          v-model="formData.nickname"
+          placeholder="请输入用户昵称"
+          class="!w-240px"
+          disabled
+        />
+      </el-form-item>
+      <el-form-item label="用户等级" prop="levelId">
+        <MemberLevelSelect v-model="formData.levelId" />
+      </el-form-item>
+      <el-form-item label="修改原因" prop="reason">
+        <el-input type="textarea" v-model="formData.reason" placeholder="请输入修改原因" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import * as UserApi from '@/api/member/user'
+import MemberLevelSelect from '@/views/member/level/components/MemberLevelSelect.vue'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  id: undefined,
+  nickname: undefined,
+  levelId: undefined,
+  reason: undefined
+})
+const formRules = reactive({
+  reason: [{ required: true, message: '修改原因不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (id?: number) => {
+  dialogVisible.value = true
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await UserApi.getUser(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 {
+    await UserApi.updateUserLevel(formData.value)
+
+    message.success(t('common.updateSuccess'))
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    nickname: undefined,
+    levelId: undefined,
+    reason: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 128 - 0
src/views/member/user/UserPointUpdateForm.vue

@@ -0,0 +1,128 @@
+<template>
+  <Dialog title="修改用户积分" v-model="dialogVisible" width="600">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="用户编号" prop="id">
+        <el-input v-model="formData.id" class="!w-240px" disabled />
+      </el-form-item>
+      <el-form-item label="用户昵称" prop="nickname">
+        <el-input v-model="formData.nickname" class="!w-240px" disabled />
+      </el-form-item>
+      <el-form-item label="变动前积分" prop="point">
+        <el-input-number v-model="formData.point" class="!w-240px" disabled />
+      </el-form-item>
+      <el-form-item label="变动类型" prop="changeType">
+        <el-radio-group v-model="formData.changeType">
+          <el-radio :label="1">增加</el-radio>
+          <el-radio :label="-1">减少</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="变动积分" prop="changePoint">
+        <el-input-number v-model="formData.changePoint" class="!w-240px" :min="0" :precision="0" />
+      </el-form-item>
+      <el-form-item label="变动后积分">
+        <el-input-number v-model="pointResult" class="!w-240px" disabled />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import * as UserApi from '@/api/member/user'
+
+/** 修改用户积分表单 */
+defineOptions({ name: 'UpdatePointForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  id: undefined,
+  nickname: undefined,
+  point: 0,
+  changePoint: 0,
+  changeType: 1
+})
+const formRules = reactive({
+  changePoint: [{ required: true, message: '变动积分不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (id?: number) => {
+  dialogVisible.value = true
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await UserApi.getUser(id)
+      formData.value.changeType = 1 // 默认增加积分
+      formData.value.changePoint = 0 // 变动积分默认0
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+
+  if (formData.value.changePoint < 1) {
+    message.error('变动积分不能小于 1')
+    return
+  }
+  if (pointResult.value < 0) {
+    message.error('变动后的积分不能小于 0')
+    return
+  }
+
+  // 提交请求
+  formLoading.value = true
+  try {
+    await UserApi.updateUserPoint({
+      id: formData.value.id,
+      point: formData.value.changePoint * formData.value.changeType
+    })
+
+    message.success(t('common.updateSuccess'))
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    nickname: undefined,
+    levelId: undefined,
+    reason: undefined
+  }
+  formRef.value?.resetFields()
+}
+
+/** 变动后的积分 */
+const pointResult = computed(
+  () => formData.value.point + formData.value.changePoint * formData.value.changeType
+)
+</script>

+ 87 - 0
src/views/member/user/detail/UserAccountInfo.vue

@@ -0,0 +1,87 @@
+<template>
+  <el-descriptions :column="2">
+    <el-descriptions-item>
+      <template #label>
+        <descriptions-item-label label=" 等级 " icon="svg-icon:member_level" />
+      </template>
+      {{ user.levelName || '无' }}
+    </el-descriptions-item>
+    <el-descriptions-item>
+      <template #label>
+        <descriptions-item-label label=" 成长值 " icon="ep:suitcase" />
+      </template>
+      {{ user.experience || 0 }}
+    </el-descriptions-item>
+    <el-descriptions-item>
+      <template #label>
+        <descriptions-item-label label=" 当前积分 " icon="ep:coin" />
+      </template>
+      {{ user.point || 0 }}
+    </el-descriptions-item>
+    <el-descriptions-item>
+      <template #label>
+        <descriptions-item-label label=" 总积分 " icon="ep:coin" />
+      </template>
+      {{ user.totalPoint || 0 }}
+    </el-descriptions-item>
+    <el-descriptions-item>
+      <template #label>
+        <descriptions-item-label label=" 当前余额 " icon="svg-icon:member_balance" />
+      </template>
+      {{ fenToYuan(wallet.balance || 0) }}
+    </el-descriptions-item>
+    <el-descriptions-item>
+      <template #label>
+        <descriptions-item-label label=" 支出金额 " icon="svg-icon:member_expenditure_balance" />
+      </template>
+      {{ fenToYuan(wallet.totalExpense || 0) }}
+    </el-descriptions-item>
+    <el-descriptions-item>
+      <template #label>
+        <descriptions-item-label label=" 充值金额 " icon="svg-icon:member_recharge_balance" />
+      </template>
+      {{ fenToYuan(wallet.totalRecharge || 0) }}
+    </el-descriptions-item>
+  </el-descriptions>
+</template>
+<script setup lang="ts">
+import { DescriptionsItemLabel } from '@/components/Descriptions'
+import * as UserApi from '@/api/member/user'
+import * as WalletApi from '@/api/pay/wallet/balance'
+import { UserTypeEnum } from '@/utils/constants'
+import { fenToYuan } from '@/utils'
+
+const props = defineProps<{ user: UserApi.UserVO }>() // 用户信息
+const WALLET_INIT_DATA = {
+  balance: 0,
+  totalExpense: 0,
+  totalRecharge: 0
+} as WalletApi.WalletVO // 钱包初始化数据
+const wallet = ref<WalletApi.WalletVO>(WALLET_INIT_DATA) // 钱包信息
+
+/** 查询用户钱包信息 */
+const getUserWallet = async () => {
+  if (!props.user.id) {
+    wallet.value = WALLET_INIT_DATA
+    return
+  }
+  const params = { userId: props.user.id }
+  wallet.value = (await WalletApi.getWallet(params)) || WALLET_INIT_DATA
+}
+
+/** 监听用户编号变化 */
+watch(
+  () => props.user.id,
+  () => getUserWallet(),
+  { immediate: true }
+)
+</script>
+<style scoped lang="scss">
+.cell-item {
+  display: inline;
+}
+
+.cell-item::after {
+  content: ':';
+}
+</style>

+ 54 - 0
src/views/member/user/detail/UserAddressList.vue

@@ -0,0 +1,54 @@
+<template>
+  <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+    <el-table-column label="地址编号" align="center" prop="id" width="150px" />
+    <el-table-column label="收件人名称" align="center" prop="name" width="150px" />
+    <el-table-column label="手机号" align="center" prop="mobile" width="150px" />
+    <el-table-column label="地区编码" align="center" prop="areaId" width="150px" />
+    <el-table-column label="收件详细地址" align="center" prop="detailAddress" />
+    <el-table-column label="是否默认" align="center" prop="defaultStatus" width="150px">
+      <template #default="scope">
+        <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="Number(scope.row.defaultStatus)" />
+      </template>
+    </el-table-column>
+    <el-table-column
+      label="创建时间"
+      align="center"
+      prop="createTime"
+      :formatter="dateFormatter"
+      width="180px"
+    />
+  </el-table>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import * as AddressApi from '@/api/member/address'
+
+const { userId }: { userId: number } = defineProps({
+  userId: {
+    type: Number,
+    required: true
+  }
+})
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    list.value = await AddressApi.getAddressList({ userId })
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style scoped lang="scss"></style>

+ 85 - 0
src/views/member/user/detail/UserBasicInfo.vue

@@ -0,0 +1,85 @@
+<template>
+  <el-card shadow="never">
+    <template #header>
+      <slot name="header"></slot>
+    </template>
+    <el-row>
+      <el-col :span="4">
+        <ElAvatar shape="square" :size="140" :src="user.avatar || undefined" />
+      </el-col>
+      <el-col :span="20">
+        <el-descriptions :column="2">
+          <el-descriptions-item>
+            <template #label>
+              <descriptions-item-label label="用户名" icon="ep:user" />
+            </template>
+            {{ user.name || '空' }}
+          </el-descriptions-item>
+          <el-descriptions-item>
+            <template #label>
+              <descriptions-item-label label="昵称" icon="ep:user" />
+            </template>
+            {{ user.nickname }}
+          </el-descriptions-item>
+          <el-descriptions-item label="手机号">
+            <template #label>
+              <descriptions-item-label label="手机号" icon="ep:phone" />
+            </template>
+            {{ user.mobile }}
+          </el-descriptions-item>
+          <el-descriptions-item>
+            <template #label>
+              <descriptions-item-label label="性别" icon="fa:mars-double" />
+            </template>
+            <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="user.sex" />
+          </el-descriptions-item>
+          <el-descriptions-item>
+            <template #label>
+              <descriptions-item-label label="所在地" icon="ep:location" />
+            </template>
+            {{ user.areaName }}
+          </el-descriptions-item>
+          <el-descriptions-item>
+            <template #label>
+              <descriptions-item-label label="注册 IP" icon="ep:position" />
+            </template>
+            {{ user.registerIp }}
+          </el-descriptions-item>
+          <el-descriptions-item>
+            <template #label>
+              <descriptions-item-label label="生日" icon="fa:birthday-cake" />
+            </template>
+            {{ user.birthday ? formatDate(user.birthday) : '空' }}
+          </el-descriptions-item>
+          <el-descriptions-item>
+            <template #label>
+              <descriptions-item-label label="注册时间" icon="ep:calendar" />
+            </template>
+            {{ user.createTime ? formatDate(user.createTime) : '空' }}
+          </el-descriptions-item>
+          <el-descriptions-item>
+            <template #label>
+              <descriptions-item-label label="最后登录时间" icon="ep:calendar" />
+            </template>
+            {{ user.loginDate ? formatDate(user.loginDate) : '空' }}
+          </el-descriptions-item>
+        </el-descriptions>
+      </el-col>
+    </el-row>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { DICT_TYPE } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+import * as UserApi from '@/api/member/user'
+import { DescriptionsItemLabel } from '@/components/Descriptions/index'
+
+const { user } = defineProps<{ user: UserApi.UserVO }>()
+</script>
+<style scoped lang="scss">
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+</style>

+ 125 - 0
src/views/member/user/detail/UserBrokerageList.vue

@@ -0,0 +1,125 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="85px"
+    >
+      <el-form-item label="用户类型" prop="level">
+        <el-radio-group v-model="queryParams.level" @change="handleQuery">
+          <el-radio-button checked>全部</el-radio-button>
+          <el-radio-button label="1">一级推广人</el-radio-button>
+          <el-radio-button label="2">二级推广人</el-radio-button>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="绑定时间" prop="bindUserTime">
+        <el-date-picker
+          v-model="queryParams.bindUserTime"
+          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" min-width="80px" />
+      <el-table-column label="头像" align="center" prop="avatar" width="70px">
+        <template #default="scope">
+          <el-avatar :src="scope.row.avatar" />
+        </template>
+      </el-table-column>
+      <el-table-column label="昵称" align="center" prop="nickname" min-width="80px" />
+      <el-table-column label="等级" align="center" prop="level" min-width="80px">
+        <template #default="scope">
+          <el-tag v-if="scope.row.bindUserId === bindUserId">一级</el-tag>
+          <el-tag v-else>二级</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="绑定时间"
+        align="center"
+        prop="bindUserTime"
+        :formatter="dateFormatter"
+        width="170px"
+      />
+    </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 * as BrokerageUserApi from '@/api/mall/trade/brokerage/user'
+
+/** 推广人列表 */
+defineOptions({ name: 'UserBrokerageList' })
+
+const { bindUserId }: { bindUserId: number } = defineProps({
+  bindUserId: {
+    type: Number,
+    required: true
+  }
+}) //用户编号
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  bindUserId: null,
+  level: '',
+  bindUserTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    queryParams.bindUserId = bindUserId
+    const data = await BrokerageUserApi.getBrokerageUserPage(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()
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 190 - 0
src/views/member/user/detail/UserCouponList.vue

@@ -0,0 +1,190 @@
+<template>
+  <!-- 搜索工作栏 -->
+  <ContentWrap>
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <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>
+    <!-- Tab 选项:真正的内容在 Lab -->
+    <el-tabs v-model="activeTab" type="card" @tab-change="onTabChange">
+      <el-tab-pane
+        v-for="tab in statusTabs"
+        :key="tab.value"
+        :label="tab.label"
+        :name="tab.value"
+      />
+    </el-tabs>
+
+    <!-- 列表 -->
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="优惠劵" align="center" prop="name" />
+      <el-table-column label="优惠券类型" align="center" prop="discountType">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PROMOTION_DISCOUNT_TYPE" :value="scope.row.discountType" />
+        </template>
+      </el-table-column>
+      <el-table-column label="领取方式" align="center" prop="takeType">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PROMOTION_COUPON_TAKE_TYPE" :value="scope.row.takeType" />
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PROMOTION_COUPON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="领取时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180"
+      />
+      <el-table-column
+        label="使用时间"
+        align="center"
+        prop="useTime"
+        :formatter="dateFormatter"
+        width="180"
+      />
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template #default="scope">
+          <el-button
+            v-hasPermi="['promotion:coupon:delete']"
+            type="danger"
+            link
+            @click="handleDelete(scope.row.id)"
+          >
+            回收
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts" name="UserCouponList">
+import { deleteCoupon, getCouponPage } from '@/api/mall/promotion/coupon/coupon'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+
+defineOptions({ name: 'UserCouponList' })
+
+const { userId }: { userId: number } = defineProps({
+  userId: {
+    type: Number,
+    required: true
+  }
+}) //用户编号
+
+const message = useMessage() // 消息弹窗
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 字典表格数据
+// 查询参数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  createTime: [],
+  status: undefined,
+  userIds: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+
+const activeTab = ref('all') // Tab 筛选
+const statusTabs = reactive([
+  {
+    label: '全部',
+    value: 'all'
+  }
+])
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  // 执行查询
+  try {
+    queryParams.userIds = userId
+    const data = await getCouponPage(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.confirm(
+      '回收将会收回会员领取的待使用的优惠券,已使用的将无法回收,确定要回收所选优惠券吗?'
+    )
+    // 发起删除
+    await deleteCoupon(id)
+    message.notifySuccess('回收成功')
+    // 重新加载列表
+    await getList()
+  } catch {}
+}
+
+/** tab 切换 */
+const onTabChange = (tabName) => {
+  queryParams.status = tabName === 'all' ? undefined : tabName
+  getList()
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+  // 设置 statuses 过滤
+  for (const dict of getIntDictOptions(DICT_TYPE.PROMOTION_COUPON_STATUS)) {
+    statusTabs.push({
+      label: dict.label,
+      value: dict.value as string
+    })
+  }
+})
+</script>

+ 158 - 0
src/views/member/user/detail/UserExperienceRecordList.vue

@@ -0,0 +1,158 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="业务类型" prop="bizType">
+        <el-select
+          v-model="queryParams.bizType"
+          placeholder="请选择业务类型"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.MEMBER_EXPERIENCE_BIZ_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="标题" prop="title">
+        <el-input
+          v-model="queryParams.title"
+          placeholder="请输入标题"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+      </el-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="150px" />
+      <el-table-column
+        label="获得时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+      />
+      <el-table-column label="经验" align="center" prop="experience" width="150px">
+        <template #default="scope">
+          <el-tag v-if="scope.row.experience > 0" class="ml-2" type="success" effect="dark">
+            +{{ scope.row.experience }}
+          </el-tag>
+          <el-tag v-else class="ml-2" type="danger" effect="dark">
+            {{ scope.row.experience }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="总经验" align="center" prop="totalExperience" width="150px">
+        <template #default="scope">
+          <el-tag class="ml-2" effect="dark">
+            {{ scope.row.totalExperience }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="标题" align="center" prop="title" width="150px" />
+      <el-table-column label="描述" align="center" prop="description" />
+      <el-table-column label="业务编号" align="center" prop="bizId" width="150px" />
+      <el-table-column label="业务类型" align="center" prop="bizType" width="150px">
+        <!--   TODO 芋艿:此处应创建对应的字典 -->
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.MEMBER_EXPERIENCE_BIZ_TYPE" :value="scope.row.bizType" />
+        </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 * as ExperienceRecordApi from '@/api/member/experience-record/index'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+
+defineOptions({ name: 'UserExperienceRecordList' })
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  userId: null,
+  bizId: null,
+  bizType: null,
+  title: null,
+  description: null,
+  experience: null,
+  totalExperience: null,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ExperienceRecordApi.getExperienceRecordPage(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 { userId } = defineProps({
+  userId: {
+    type: Number,
+    required: true
+  }
+})
+/** 初始化 **/
+onMounted(() => {
+  queryParams.userId = userId
+  getList()
+})
+</script>

+ 96 - 0
src/views/member/user/detail/UserFavoriteList.vue

@@ -0,0 +1,96 @@
+<template>
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list">
+      <el-table-column key="id" align="center" label="商品编号" width="180" prop="id" />
+      <el-table-column label="商品图" min-width="80">
+        <template #default="{ row }">
+          <el-image :src="row.picUrl" class="h-30px w-30px" @click="imagePreview(row.picUrl)" />
+        </template>
+      </el-table-column>
+      <el-table-column :show-overflow-tooltip="true" label="商品名称" min-width="300" prop="name" />
+      <el-table-column align="center" label="商品售价" min-width="90" prop="price">
+        <template #default="{ row }"> {{ floatToFixed2(row.price) }}元</template>
+      </el-table-column>
+      <el-table-column align="center" label="销量" min-width="90" prop="salesCount" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="收藏时间"
+        prop="createTime"
+        width="180"
+      />
+      <el-table-column align="center" label="状态" min-width="80">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PRODUCT_SPU_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script lang="ts" setup>
+import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import * as FavoriteApi from '@/api/mall/product/favorite'
+import { floatToFixed2 } from '@/utils'
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  createTime: [],
+  userId: NaN
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await FavoriteApi.getFavoritePage(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 { userId } = defineProps({
+  userId: {
+    type: Number,
+    required: true
+  }
+})
+
+/** 初始化 **/
+onMounted(() => {
+  queryParams.userId = userId
+  getList()
+})
+</script>

+ 279 - 0
src/views/member/user/detail/UserOrderList.vue

@@ -0,0 +1,279 @@
+<template>
+  <!-- 搜索 -->
+  <ContentWrap>
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <el-form-item label="订单状态" prop="status">
+        <el-select v-model="queryParams.status" class="!w-280px" clearable placeholder="全部">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.TRADE_ORDER_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="支付方式" prop="payChannelCode">
+        <el-select
+          v-model="queryParams.payChannelCode"
+          class="!w-280px"
+          clearable
+          placeholder="全部"
+        >
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.PAY_CHANNEL_CODE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-280px"
+          end-placeholder="自定义时间"
+          start-placeholder="自定义时间"
+          type="daterange"
+          value-format="YYYY-MM-DD HH:mm:ss"
+        />
+      </el-form-item>
+      <el-form-item label="订单来源" prop="terminal">
+        <el-select v-model="queryParams.terminal" class="!w-280px" clearable placeholder="全部">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.TERMINAL)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="订单类型" prop="type">
+        <el-select v-model="queryParams.type" class="!w-280px" clearable placeholder="全部">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.TRADE_ORDER_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="配送方式" prop="deliveryType">
+        <el-select v-model="queryParams.deliveryType" class="!w-280px" clearable placeholder="全部">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.TRADE_DELIVERY_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item
+        v-if="queryParams.deliveryType === DeliveryTypeEnum.EXPRESS.type"
+        label="快递公司"
+        prop="logisticsId"
+      >
+        <el-select v-model="queryParams.logisticsId" class="!w-280px" clearable placeholder="全部">
+          <el-option
+            v-for="item in deliveryExpressList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item
+        v-if="queryParams.deliveryType === DeliveryTypeEnum.PICK_UP.type"
+        label="自提门店"
+        prop="pickUpStoreId"
+      >
+        <el-select
+          v-model="queryParams.pickUpStoreId"
+          class="!w-280px"
+          clearable
+          multiple
+          placeholder="全部"
+        >
+          <el-option
+            v-for="item in pickUpStoreList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item
+        v-if="queryParams.deliveryType === DeliveryTypeEnum.PICK_UP.type"
+        label="核销码"
+        prop="pickUpVerifyCode"
+      >
+        <el-input
+          v-model="queryParams.pickUpVerifyCode"
+          class="!w-280px"
+          clearable
+          placeholder="请输入自提核销码"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="聚合搜索">
+        <el-input
+          v-show="true"
+          v-model="queryParams[queryType.queryParam]"
+          class="!w-280px"
+          clearable
+          placeholder="请输入"
+        >
+          <template #prepend>
+            <el-select
+              v-model="queryType.queryParam"
+              class="!w-110px"
+              clearable
+              placeholder="全部"
+              @change="inputChangeSelect"
+            >
+              <el-option
+                v-for="dict in dynamicSearchList"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </template>
+        </el-input>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <!-- 添加 row-key="id" 解决列数据中的 table#header 数据不刷新的问题  -->
+    <el-table v-loading="loading" :data="list" row-key="id">
+      <OrderTableColumn :list="list" :pick-up-store-list="pickUpStoreList">
+        <template #default="{ row }">
+          <el-button link type="primary" @click="openDetail(row.id)">
+            <Icon icon="ep:notification" />
+            详情
+          </el-button>
+        </template>
+      </OrderTableColumn>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+<script lang="ts" setup>
+import * as OrderApi from '@/api/mall/trade/order/index'
+import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
+import * as PickUpStoreApi from '@/api/mall/trade/delivery/pickUpStore'
+import * as DeliveryExpressApi from '@/api/mall/trade/delivery/express'
+import { FormInstance } from 'element-plus'
+import { OrderTableColumn } from '@/views/mall/trade/order/components'
+import { DeliveryTypeEnum } from '@/utils/constants'
+
+const { push } = useRouter() // 路由跳转
+
+const { userId } = defineProps<{
+  userId: number
+}>()
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const pickUpStoreList = ref<PickUpStoreApi.DeliveryPickUpStoreVO[]>([]) // 自提门店精简列表
+const deliveryExpressList = ref<DeliveryExpressApi.DeliveryExpressVO[]>([]) // 物流公司
+const queryFormRef = ref<FormInstance>() // 搜索的表单
+// 表单搜索
+const queryParams = ref({
+  pageNo: 1, // 页数
+  pageSize: 10, // 每页显示数量
+  userId: userId,
+  status: undefined, // 订单状态
+  payChannelCode: undefined, // 支付方式
+  createTime: undefined, // 创建时间
+  terminal: undefined, // 订单来源
+  type: undefined, // 订单类型
+  deliveryType: undefined, // 配送方式
+  logisticsId: undefined, // 快递公司
+  pickUpStoreId: undefined, // 自提门店
+  pickUpVerifyCode: undefined // 自提核销码
+})
+const queryType = reactive({ queryParam: '' }) // 订单搜索类型 queryParam
+
+// 订单聚合搜索 select 类型配置(动态搜索)
+const dynamicSearchList = ref([
+  { value: 'no', label: '订单号' },
+  { value: 'userNickname', label: '用户昵称' },
+  { value: 'userMobile', label: '用户电话' }
+])
+/**
+ * 聚合搜索切换查询对象时触发
+ * @param val
+ */
+const inputChangeSelect = (val: string) => {
+  dynamicSearchList.value
+    .filter((item) => item.value !== val)
+    ?.forEach((item1) => {
+      // 清除集合搜索无用属性
+      if (queryParams.value.hasOwnProperty(item1.value)) {
+        delete queryParams.value[item1.value]
+      }
+    })
+}
+
+/** 搜索按钮操作 */
+const handleQuery = async () => {
+  queryParams.value.pageNo = 1
+  await getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  queryParams.value.userId = userId
+  handleQuery()
+}
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await OrderApi.getOrderPage(queryParams.value)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 查看订单详情 */
+const openDetail = (id: number) => {
+  push({ name: 'TradeOrderDetail', params: { id } })
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+  pickUpStoreList.value = await PickUpStoreApi.getListAllSimple()
+  deliveryExpressList.value = await DeliveryExpressApi.getSimpleDeliveryExpressList()
+})
+</script>

+ 152 - 0
src/views/member/user/detail/UserPointList.vue

@@ -0,0 +1,152 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="业务类型" prop="bizType">
+        <el-select
+          v-model="queryParams.bizType"
+          placeholder="请选择业务类型"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.MEMBER_POINT_BIZ_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="积分标题" prop="title">
+        <el-input
+          v-model="queryParams.title"
+          placeholder="请输入积分标题"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="获得时间" prop="createDate">
+        <el-date-picker
+          v-model="queryParams.createDate"
+          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">
+      <el-table-column label="编号" align="center" prop="id" width="180" />
+      <el-table-column
+        label="获得时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180"
+      />
+      <el-table-column label="获得积分" align="center" prop="point" width="100">
+        <template #default="scope">
+          <el-tag v-if="scope.row.point > 0" class="ml-2" type="success" effect="dark">
+            +{{ scope.row.point }}
+          </el-tag>
+          <el-tag v-else class="ml-2" type="danger" effect="dark"> {{ scope.row.point }} </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="总积分" align="center" prop="totalPoint" width="100" />
+      <el-table-column label="标题" align="center" prop="title" />
+      <el-table-column label="描述" align="center" prop="description" />
+      <el-table-column label="业务编码" align="center" prop="bizId" />
+      <el-table-column label="业务类型" align="center" prop="bizType">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.MEMBER_POINT_BIZ_TYPE" :value="scope.row.bizType" />
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import * as RecordApi from '@/api//member/point/record'
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  bizType: undefined,
+  title: null,
+  createDate: [],
+  userId: NaN
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await RecordApi.getRecordPage(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 { userId } = defineProps({
+  userId: {
+    type: Number,
+    required: true
+  }
+})
+
+/** 初始化 **/
+onMounted(() => {
+  queryParams.userId = userId
+  getList()
+})
+</script>

+ 135 - 0
src/views/member/user/detail/UserSignList.vue

@@ -0,0 +1,135 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="签到用户" prop="nickname">
+        <el-input
+          v-model="queryParams.nickname"
+          placeholder="请输入签到用户"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="签到天数" prop="day">
+        <el-input
+          v-model="queryParams.day"
+          placeholder="请输入签到天数"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="签到时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column
+        label="签到天数"
+        align="center"
+        prop="day"
+        :formatter="(_, __, cellValue) => ['第', cellValue, '天'].join(' ')"
+      />
+      <el-table-column label="获得积分" align="center" prop="point" width="100">
+        <template #default="scope">
+          <el-tag v-if="scope.row.point > 0" class="ml-2" type="success" effect="dark">
+            +{{ scope.row.point }}
+          </el-tag>
+          <el-tag v-else class="ml-2" type="danger" effect="dark"> {{ scope.row.point }} </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="签到时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+      />
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script lang="ts" setup>
+import { dateFormatter } from '@/utils/formatTime'
+import * as SignInRecordApi from '@/api/member/signin/record'
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  userId: NaN,
+  nickname: null,
+  day: null,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await SignInRecordApi.getSignInRecordPage(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 { userId } = defineProps({
+  userId: {
+    type: Number,
+    required: true
+  }
+})
+
+/** 初始化 **/
+onMounted(() => {
+  queryParams.userId = userId
+  getList()
+})
+</script>

+ 7 - 0
src/views/mp/autoReply/components/types.ts

@@ -0,0 +1,7 @@
+// 消息类型(Follow: 关注时回复;Message: 消息回复;Keyword: 关键词回复)
+// 作为 tab.name,enum 的数字不能随意修改,与 api 参数相关
+export enum MsgType {
+  Follow = 1,
+  Message = 2,
+  Keyword = 3
+}

+ 11 - 0
src/views/mp/components/wx-material-select/types.ts

@@ -0,0 +1,11 @@
+export enum NewsType {
+  Draft = '2',
+  Published = '1'
+}
+
+export enum MaterialType {
+  Image = 'image',
+  Voice = 'voice',
+  Video = 'video',
+  News = 'news'
+}

+ 17 - 0
src/views/mp/components/wx-msg/types.ts

@@ -0,0 +1,17 @@
+export enum MsgType {
+  Event = 'event',
+  Text = 'text',
+  Voice = 'voice',
+  Image = 'image',
+  Video = 'video',
+  Link = 'link',
+  Location = 'location',
+  Music = 'music',
+  News = 'news'
+}
+
+export interface User {
+  nickname: string
+  avatar: string
+  accountId: number
+}

+ 54 - 0
src/views/mp/components/wx-reply/components/types.ts

@@ -0,0 +1,54 @@
+enum ReplyType {
+  News = 'news',
+  Image = 'image',
+  Voice = 'voice',
+  Video = 'video',
+  Music = 'music',
+  Text = 'text'
+}
+
+interface _Reply {
+  accountId: number
+  type: ReplyType
+  name?: string | null
+  content?: string | null
+  mediaId?: string | null
+  url?: string | null
+  title?: string | null
+  description?: string | null
+  thumbMediaId?: string | null
+  thumbMediaUrl?: string | null
+  musicUrl?: string | null
+  hqMusicUrl?: string | null
+  introduction?: string | null
+  articles?: any[]
+}
+
+type Reply = _Reply //Partial<_Reply>
+
+enum NewsType {
+  Published = '1',
+  Draft = '2'
+}
+
+/** 利用旧的reply[accountId, type]初始化新的Reply */
+const createEmptyReply = (old: Reply | Ref<Reply>): Reply => {
+  return {
+    accountId: unref(old).accountId,
+    type: unref(old).type,
+    name: null,
+    content: null,
+    mediaId: null,
+    url: null,
+    title: null,
+    description: null,
+    thumbMediaId: null,
+    thumbMediaUrl: null,
+    musicUrl: null,
+    hqMusicUrl: null,
+    introduction: null,
+    articles: []
+  }
+}
+
+export { Reply, NewsType, ReplyType, createEmptyReply }

+ 40 - 0
src/views/mp/draft/components/types.ts

@@ -0,0 +1,40 @@
+interface NewsItem {
+  title: string
+  thumbMediaId: string
+  author: string
+  digest: string
+  showCoverPic: string
+  content: string
+  contentSourceUrl: string
+  needOpenComment: string
+  onlyFansCanComment: string
+  thumbUrl: string
+}
+
+interface NewsItemList {
+  newsItem: NewsItem[]
+}
+
+interface Article {
+  mediaId: string
+  content: NewsItemList
+  updateTime: number
+}
+
+const createEmptyNewsItem = (): NewsItem => {
+  return {
+    title: '',
+    thumbMediaId: '',
+    author: '',
+    digest: '',
+    showCoverPic: '',
+    content: '',
+    contentSourceUrl: '',
+    needOpenComment: '',
+    onlyFansCanComment: '',
+    thumbUrl: ''
+  }
+}
+
+export type { Article, NewsItem, NewsItemList }
+export { createEmptyNewsItem }

+ 50 - 0
src/views/mp/hooks/useUpload.ts

@@ -0,0 +1,50 @@
+import type { UploadRawFile } from 'element-plus'
+
+const message = useMessage() // 消息
+
+enum UploadType {
+  Image = 'image',
+  Voice = 'voice',
+  Video = 'video'
+}
+
+const useBeforeUpload = (type: UploadType, maxSizeMB: number) => {
+  const fn = (rawFile: UploadRawFile): boolean => {
+    let allowTypes: string[] = []
+    let name = ''
+
+    switch (type) {
+      case UploadType.Image:
+        allowTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/jpg']
+        maxSizeMB = 2
+        name = '图片'
+        break
+      case UploadType.Voice:
+        allowTypes = ['audio/mp3', 'audio/mpeg', 'audio/wma', 'audio/wav', 'audio/amr']
+        maxSizeMB = 2
+        name = '语音'
+        break
+      case UploadType.Video:
+        allowTypes = ['video/mp4']
+        maxSizeMB = 10
+        name = '视频'
+        break
+    }
+    // 格式不正确
+    if (!allowTypes.includes(rawFile.type)) {
+      message.error(`上传${name}格式不对!`)
+      return false
+    }
+    // 大小不正确
+    if (rawFile.size / 1024 / 1024 > maxSizeMB) {
+      message.error(`上传${name}大小不能超过${maxSizeMB}M!`)
+      return false
+    }
+
+    return true
+  }
+
+  return fn
+}
+
+export { UploadType, useBeforeUpload }

+ 77 - 0
src/views/mp/material/components/UploadFile.vue

@@ -0,0 +1,77 @@
+<template>
+  <el-upload
+    :action="UPLOAD_URL"
+    :headers="HEADERS"
+    multiple
+    :limit="1"
+    :file-list="fileList"
+    :data="uploadData"
+    :on-error="onUploadError"
+    :before-upload="onBeforeUpload"
+    :on-success="onUploadSuccess"
+  >
+    <el-button type="primary" plain> 点击上传 </el-button>
+    <template #tip>
+      <span class="el-upload__tip" style="margin-left: 5px">
+        <slot></slot>
+      </span>
+    </template>
+  </el-upload>
+</template>
+<script lang="ts" setup>
+import type { UploadProps, UploadUserFile } from 'element-plus'
+import {
+  HEADERS,
+  UPLOAD_URL,
+  UploadData,
+  UploadType,
+  beforeImageUpload,
+  beforeVoiceUpload
+} from './upload'
+
+const message = useMessage()
+
+const props = defineProps<{ type: UploadType }>()
+
+const accountId = inject<number>('accountId')
+
+const fileList = ref<UploadUserFile[]>([])
+const emit = defineEmits<{
+  (e: 'uploaded', v: void)
+}>()
+
+const uploadData: UploadData = reactive({
+  type: UploadType.Image,
+  title: '',
+  introduction: '',
+  accountId: accountId!
+})
+
+/** 上传前检查 */
+const onBeforeUpload = props.type === UploadType.Image ? beforeImageUpload : beforeVoiceUpload
+
+/** 上传成功处理 */
+const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
+  if (res.code !== 0) {
+    message.alertError('上传出错:' + res.msg)
+    return false
+  }
+
+  // 清空上传时的各种数据
+  fileList.value = []
+  uploadData.title = ''
+  uploadData.introduction = ''
+
+  message.notifySuccess('上传成功')
+  emit('uploaded')
+}
+
+/** 上传失败处理 */
+const onUploadError = (err: Error) => message.error('上传失败: ' + err.message)
+</script>
+
+<style lang="scss" scoped>
+.el-upload__tip {
+  margin-left: 5px;
+}
+</style>

+ 129 - 0
src/views/mp/material/components/UploadVideo.vue

@@ -0,0 +1,129 @@
+<template>
+  <el-dialog title="新建视频" v-model="showDialog" width="600px">
+    <el-upload
+      :action="UPLOAD_URL"
+      :headers="HEADERS"
+      multiple
+      :limit="1"
+      :file-list="fileList"
+      :data="uploadData"
+      :before-upload="beforeVideoUpload"
+      :on-error="onUploadError"
+      :on-success="onUploadSuccess"
+      ref="uploadVideoRef"
+      :auto-upload="false"
+      class="mb-5"
+    >
+      <template #trigger>
+        <el-button type="primary" plain>选择视频</el-button>
+      </template>
+      <template #tip>
+        <span class="el-upload__tip" style="margin-left: 10px"
+          >格式支持 MP4,文件大小不超过 10MB</span
+        >
+      </template>
+    </el-upload>
+    <el-divider />
+    <el-form :model="uploadData" :rules="uploadRules" ref="uploadFormRef">
+      <el-form-item label="标题" prop="title">
+        <el-input
+          v-model="uploadData.title"
+          placeholder="标题将展示在相关播放页面,建议填写清晰、准确、生动的标题"
+        />
+      </el-form-item>
+      <el-form-item label="描述" prop="introduction">
+        <el-input
+          :rows="3"
+          type="textarea"
+          v-model="uploadData.introduction"
+          placeholder="介绍语将展示在相关播放页面,建议填写简洁明确、有信息量的内容"
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="showDialog = false">取 消</el-button>
+      <el-button type="primary" @click="submitVideo">提 交</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script lang="ts" setup>
+import type {
+  FormInstance,
+  FormRules,
+  UploadInstance,
+  UploadProps,
+  UploadUserFile
+} from 'element-plus'
+import { HEADERS, UploadData, UPLOAD_URL, UploadType, beforeVideoUpload } from './upload'
+
+const message = useMessage()
+
+const accountId = inject<number>('accountId')
+
+const uploadRules: FormRules = {
+  title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
+  introduction: [{ required: true, message: '请输入描述', trigger: 'blur' }]
+}
+
+const props = defineProps({
+  modelValue: {
+    type: Boolean,
+    default: false
+  }
+})
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: boolean)
+  (e: 'uploaded', v: void)
+}>()
+
+const showDialog = computed<boolean>({
+  get() {
+    return props.modelValue
+  },
+  set(val) {
+    emit('update:modelValue', val)
+  }
+})
+
+const fileList = ref<UploadUserFile[]>([])
+
+const uploadData: UploadData = reactive({
+  type: UploadType.Video,
+  title: '',
+  introduction: '',
+  accountId: accountId!
+})
+
+const uploadFormRef = ref<FormInstance | null>(null)
+const uploadVideoRef = ref<UploadInstance | null>(null)
+
+const submitVideo = () => {
+  uploadFormRef.value?.validate((valid) => {
+    if (!valid) {
+      return false
+    }
+    uploadVideoRef.value?.submit()
+  })
+}
+
+/** 上传成功处理 */
+const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
+  if (res.code !== 0) {
+    message.error('上传出错:' + res.msg)
+    return false
+  }
+
+  // 清空上传时的各种数据
+  fileList.value = []
+  uploadData.title = ''
+  uploadData.introduction = ''
+
+  showDialog.value = false
+  message.notifySuccess('上传成功')
+  emit('uploaded')
+}
+
+/** 上传失败处理 */
+const onUploadError = (err: Error) => message.error(`上传失败: ${err.message}`)
+</script>

+ 59 - 0
src/views/mp/material/components/VideoTable.vue

@@ -0,0 +1,59 @@
+<template>
+  <el-table :data="props.list" stripe border v-loading="props.loading" style="margin-top: 10px">
+    <el-table-column label="编号" align="center" prop="mediaId" />
+    <el-table-column label="文件名" align="center" prop="name" />
+    <el-table-column label="标题" align="center" prop="title" />
+    <el-table-column label="介绍" align="center" prop="introduction" />
+    <el-table-column label="视频" align="center">
+      <template #default="scope">
+        <WxVideoPlayer v-if="scope.row.url" :url="scope.row.url" />
+      </template>
+    </el-table-column>
+    <el-table-column
+      label="上传时间"
+      align="center"
+      :formatter="dateFormatter"
+      prop="createTime"
+      width="180"
+    >
+      <template #default="scope">
+        <span>{{ scope.row.createTime }}</span>
+      </template>
+    </el-table-column>
+    <el-table-column label="操作" align="center" fixed="right">
+      <template #default="scope">
+        <el-button type="primary" link @click="handleDownload(scope.row.url)">
+          <Icon icon="ep:download" />下载
+        </el-button>
+        <el-button
+          type="primary"
+          link
+          @click="emit('delete', scope.row.id)"
+          v-hasPermi="['mp:material:delete']"
+        >
+          <Icon icon="ep:delete" />删除
+        </el-button>
+      </template>
+    </el-table-column>
+  </el-table>
+</template>
+
+<script lang="ts" setup>
+import WxVideoPlayer from '@/views/mp/components/wx-video-play'
+import { dateFormatter } from '@/utils/formatTime'
+
+const props = defineProps<{
+  list: any[]
+  loading: boolean
+}>()
+
+const emit = defineEmits<{
+  (e: 'delete', v: number)
+  (e: 'download', v: string)
+}>()
+
+// 下载文件
+const handleDownload = (url: string) => {
+  window.open(url, '_blank')
+}
+</script>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است